Compare commits

...

65 Commits

Author SHA1 Message Date
Dhruwang
5cbfc6956b fix 2025-05-06 14:00:20 +05:30
Dhruwang
62f19ba4d9 fix 2025-05-06 13:53:12 +05:30
Dhruwang
70aba27e82 add go in each stage 2025-05-06 13:50:06 +05:30
Dhruwang
e94cf10c36 removed gcc 2025-05-06 12:58:13 +05:30
Dhruwang
0f324c75ab removed superchronic 2025-05-06 12:26:20 +05:30
Dhruwang
4814f8821a fix 2025-05-06 12:21:01 +05:30
Dhruwang
b44df3b6e1 fix 2025-05-06 11:53:19 +05:30
Dhruwang
a626600786 fix 2025-05-06 11:34:16 +05:30
Dhruwang
6fc1f77845 commented add step 2025-05-06 11:19:50 +05:30
Dhruwang
defc5b29e1 added release version 2025-05-06 11:03:02 +05:30
Dhruwang
e6c741bd3b fix 2025-05-06 11:02:44 +05:30
Dhruwang
3207350bd5 fix 2025-05-05 18:17:29 +05:30
Dhruwang
bbe423319e fix 2025-05-05 16:58:37 +05:30
Dhruwang
40d8d86cd6 fix 2025-05-05 16:53:02 +05:30
Dhruwang
87934d9a68 fix 2025-05-05 16:48:54 +05:30
Dhruwang
0d19569936 fix 2025-05-05 16:44:32 +05:30
Dhruwang
d67dd965ab fix 2025-05-05 16:41:10 +05:30
Dhruwang
328e2db17f fixed zstd veraion 2025-05-05 16:36:24 +05:30
Dhruwang
46e5975653 fix build 2025-05-05 16:32:53 +05:30
Dhruwang
6145f11ddf fix build 2025-05-05 16:28:34 +05:30
Dhruwang
88cff4e52f adding missing package versions and removed edge repo 2025-05-05 16:25:50 +05:30
Dhruwang
801446bb86 Merge branch 'main' of https://github.com/formbricks/formbricks into docker-package-version-update 2025-05-05 16:14:26 +05:30
Harsh Bhat
a53c13d6ed docs: add enterprise features listed under a subpaage (#5594)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-05-05 08:00:47 +00:00
dependabot[bot]
1a0c6e72b2 chore(deps): bump actions/dependency-review-action from 4.5.0 to 4.6.0 (#5270)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 10:08:58 +02:00
dependabot[bot]
ba7c8b79b1 chore(deps): bump actions/checkout from 2.7.0 to 4.2.2 (#5273)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-05 10:08:26 +02:00
dependabot[bot]
d7b504eed0 chore(deps): bump step-security/harden-runner from 2.11.0 to 2.12.0 (#5559)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 10:00:21 +02:00
dependabot[bot]
a1df10eb09 chore(deps): bump sigstore/cosign-installer from 3.5.0 to 3.8.2 (#5560)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-05 09:55:05 +02:00
victorvhs017
92be409d4f chore: add tests to api V1 - part 2 (#5605) 2025-05-05 05:55:18 +00:00
victorvhs017
665c7c6bf1 chore: add tests to api V1 (#5593)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-05 05:38:16 +00:00
victorvhs017
6c2ff7ee08 chore: add tests to survey editor components - part 3 (#5587)
Co-authored-by: use-tusk[bot] <144006087+use-tusk[bot]@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-05-05 04:43:37 +00:00
Dhruwang
bc5d048c39 fix 2025-04-30 19:29:10 +05:30
Dhruwang
f236047438 fix 2025-04-30 19:25:34 +05:30
Dhruwang
beb7ed0f3f fix redirect 2025-04-30 19:20:57 +05:30
Dhruwang
184bcd12c9 fix test 2025-04-30 19:17:15 +05:30
Dhruwang
a21911b777 sonarqube fixes 2025-04-30 19:15:24 +05:30
Dhruwang
c1df575b83 removed unrelated changes 2025-04-30 18:22:35 +05:30
Dhruwang
c6dba4454f fix 2025-04-30 18:16:39 +05:30
Dhruwang
81c7b54eae restored changes 2025-04-30 16:28:20 +05:30
Dhruwang
f0c2d75a4b fix 2025-04-30 16:07:21 +05:30
Dhruwang
44feb59cfc fix build 2025-04-30 16:03:51 +05:30
Dhruwang
3a4885c459 fix build 2025-04-30 16:00:54 +05:30
Dhruwang
6076ddd8c8 fix build 2025-04-30 15:58:12 +05:30
Dhruwang
f96530fef5 fix 2025-04-30 15:54:23 +05:30
Dhruwang
3c22bd3ccb fix build 2025-04-30 15:51:19 +05:30
Dhruwang
d05f5b26f8 added verification step 2025-04-30 15:41:47 +05:30
Dhruwang
3765e0da54 fix build 2025-04-30 14:43:47 +05:30
Dhruwang
9eea429b44 fix copy syntax 2025-04-30 14:40:42 +05:30
Dhruwang
a05a391080 fix location 2025-04-30 14:37:55 +05:30
Dhruwang
d10da85ac0 fix 2025-04-30 14:34:15 +05:30
Dhruwang
19ea25d483 fix build 2025-04-30 14:31:07 +05:30
Dhruwang
60e26a9ada fix build 2025-04-30 14:24:02 +05:30
Dhruwang
579351cdcd custom versions 2025-04-30 14:19:28 +05:30
Dhruwang
2dbc9559d5 fix build 2025-04-30 10:56:39 +05:30
Dhruwang
fdd84f84a5 fix build 2025-04-30 10:41:23 +05:30
Dhruwang
6bfc54b43c fix 2025-04-30 10:27:43 +05:30
Dhruwang
d18003507e updated openssl 2025-04-30 10:04:58 +05:30
Dhruwang
777485e63d fix alpine version 2025-04-30 09:43:06 +05:30
Dhruwang
0471a0f0c3 Merge branch 'main' of https://github.com/formbricks/formbricks into docker-package-version-update 2025-04-30 09:35:48 +05:30
Dhruwang
6290c6020d manual setup for libxml2 2025-04-30 09:35:43 +05:30
Dhruwang
304db65c66 fix 2025-04-25 11:00:57 +05:30
Dhruwang
1f979c91d3 fix 2025-04-25 10:55:57 +05:30
Dhruwang
3f532b859c added libxml2 version 2025-04-25 10:39:41 +05:30
Dhruwang
05043b1762 custom package versions 2025-04-25 10:22:56 +05:30
Dhruwang
6c724a0b1b updated base image 2025-04-24 17:18:31 +05:30
Dhruwang
f185ff85c5 updated alpine version 2025-04-24 17:09:28 +05:30
79 changed files with 7646 additions and 55 deletions

View File

@@ -19,7 +19,7 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -13,11 +13,11 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Build & Cache Web Binaries

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -17,11 +17,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0

View File

@@ -44,7 +44,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v4.2.2
- name: Tailscale
uses: tailscale/github-action@v3

View File

@@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v3
uses: actions/checkout@v4.2.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

View File

@@ -46,11 +46,11 @@ jobs:
--health-retries=5
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -51,7 +51,7 @@ jobs:
statuses: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0
with:
egress-policy: audit
- name: fail if conditional jobs failed

View File

@@ -31,12 +31,12 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
@@ -45,7 +45,7 @@ jobs:
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action

View File

@@ -38,12 +38,12 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Get Release Tag
id: extract_release_tag
@@ -65,7 +65,7 @@ jobs:
# https://github.com/sigstore/cosign-installer
- name: Install cosign
if: github.event_name != 'pull_request'
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
# Login against a Docker registry except on PR
# https://github.com/docker/login-action

View File

@@ -19,7 +19,7 @@ jobs:
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -35,12 +35,12 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -26,7 +26,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -14,11 +14,11 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x

View File

@@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -18,7 +18,7 @@ jobs:
if: github.event.action == 'opened'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit

View File

@@ -84,6 +84,12 @@ RUN apk add --no-cache curl \
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
# In the runner stage
RUN apk update && \
apk upgrade && \
# This explicitly removes old package versions
rm -rf /var/cache/apk/*
WORKDIR /home/nextjs
# Ensure no write permissions are assigned to the copied resources

View File

@@ -0,0 +1,276 @@
import { convertResponseValue } from "@/lib/responses";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
import {
TWeeklyEmailResponseData,
TWeeklySummaryEnvironmentData,
TWeeklySummarySurveyData,
} from "@formbricks/types/weekly-summary";
import { getNotificationResponse } from "./notificationResponse";
vi.mock("@/lib/responses", () => ({
convertResponseValue: vi.fn(),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: vi.fn((survey) => survey),
}));
describe("getNotificationResponse", () => {
afterEach(() => {
cleanup();
});
test("should return a notification response with calculated insights and survey data when provided with an environment containing multiple surveys", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: { question1: "Answer 1" } },
{ id: "response2", finished: false, data: { question1: "Answer 2" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey2",
name: "Survey 2",
status: "inProgress",
questions: [
{
id: "question2",
headline: { default: "Question 2" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display2" }],
responses: [
{ id: "response3", finished: true, data: { question2: "Answer 3" } },
{ id: "response4", finished: true, data: { question2: "Answer 4" } },
{ id: "response5", finished: false, data: { question2: "Answer 5" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(2);
expect(notificationResponse.insights.totalCompletedResponses).toBe(3);
expect(notificationResponse.insights.totalDisplays).toBe(2);
expect(notificationResponse.insights.totalResponses).toBe(5);
expect(notificationResponse.insights.completionRate).toBe(60);
expect(notificationResponse.insights.numLiveSurvey).toBe(2);
expect(notificationResponse.surveys[0].id).toBe("survey1");
expect(notificationResponse.surveys[0].name).toBe("Survey 1");
expect(notificationResponse.surveys[0].status).toBe("inProgress");
expect(notificationResponse.surveys[0].responseCount).toBe(2);
expect(notificationResponse.surveys[1].id).toBe("survey2");
expect(notificationResponse.surveys[1].name).toBe("Survey 2");
expect(notificationResponse.surveys[1].status).toBe("inProgress");
expect(notificationResponse.surveys[1].responseCount).toBe(3);
});
test("should calculate the correct completion rate and other insights when surveys have responses with varying statuses", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: { question1: "Answer 1" } },
{ id: "response2", finished: false, data: { question1: "Answer 2" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey2",
name: "Survey 2",
status: "inProgress",
questions: [
{
id: "question2",
headline: { default: "Question 2" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display2" }],
responses: [
{ id: "response3", finished: true, data: { question2: "Answer 3" } },
{ id: "response4", finished: true, data: { question2: "Answer 4" } },
{ id: "response5", finished: false, data: { question2: "Answer 5" } },
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
{
id: "survey3",
name: "Survey 3",
status: "inProgress",
questions: [
{
id: "question3",
headline: { default: "Question 3" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display3" }],
responses: [{ id: "response6", finished: false, data: { question3: "Answer 6" } }],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(3);
expect(notificationResponse.insights.totalCompletedResponses).toBe(3);
expect(notificationResponse.insights.totalDisplays).toBe(3);
expect(notificationResponse.insights.totalResponses).toBe(6);
expect(notificationResponse.insights.completionRate).toBe(50);
expect(notificationResponse.insights.numLiveSurvey).toBe(3);
expect(notificationResponse.surveys[0].id).toBe("survey1");
expect(notificationResponse.surveys[0].name).toBe("Survey 1");
expect(notificationResponse.surveys[0].status).toBe("inProgress");
expect(notificationResponse.surveys[0].responseCount).toBe(2);
expect(notificationResponse.surveys[1].id).toBe("survey2");
expect(notificationResponse.surveys[1].name).toBe("Survey 2");
expect(notificationResponse.surveys[1].status).toBe("inProgress");
expect(notificationResponse.surveys[1].responseCount).toBe(3);
expect(notificationResponse.surveys[2].id).toBe("survey3");
expect(notificationResponse.surveys[2].name).toBe("Survey 3");
expect(notificationResponse.surveys[2].status).toBe("inProgress");
expect(notificationResponse.surveys[2].responseCount).toBe(1);
});
test("should return default insights and an empty surveys array when the environment contains no surveys", () => {
const mockEnvironment = {
id: "env1",
surveys: [],
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.environmentId).toBe("env1");
expect(notificationResponse.projectName).toBe(projectName);
expect(notificationResponse.surveys).toHaveLength(0);
expect(notificationResponse.insights.totalCompletedResponses).toBe(0);
expect(notificationResponse.insights.totalDisplays).toBe(0);
expect(notificationResponse.insights.totalResponses).toBe(0);
expect(notificationResponse.insights.completionRate).toBe(0);
expect(notificationResponse.insights.numLiveSurvey).toBe(0);
});
test("should handle missing response data gracefully when a response doesn't contain data for a question ID", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "text",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [
{ id: "response1", finished: true, data: {} }, // Response missing data for question1
],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
// Mock the convertResponseValue function to handle the missing data case
vi.mocked(convertResponseValue).mockReturnValue("");
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.surveys).toHaveLength(1);
expect(notificationResponse.surveys[0].responses).toHaveLength(1);
expect(notificationResponse.surveys[0].responses[0].responseValue).toBe("");
});
test("should handle unsupported question types gracefully", () => {
const mockSurveys = [
{
id: "survey1",
name: "Survey 1",
status: "inProgress",
questions: [
{
id: "question1",
headline: { default: "Question 1" },
type: "unsupported",
} as unknown as TSurveyQuestion,
],
displays: [{ id: "display1" }],
responses: [{ id: "response1", finished: true, data: { question1: "Answer 1" } }],
} as unknown as TSurvey & { responses: TWeeklyEmailResponseData[] },
] as unknown as TWeeklySummarySurveyData[];
const mockEnvironment = {
id: "env1",
surveys: mockSurveys,
} as unknown as TWeeklySummaryEnvironmentData;
const projectName = "Project Name";
vi.mocked(convertResponseValue).mockReturnValue("Unsupported Response");
const notificationResponse = getNotificationResponse(mockEnvironment, projectName);
expect(notificationResponse).toBeDefined();
expect(notificationResponse.surveys[0].responses[0].responseValue).toBe("Unsupported Response");
});
});

View File

@@ -0,0 +1,48 @@
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getOrganizationIds } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findMany: vi.fn(),
},
},
}));
describe("Organization", () => {
afterEach(() => {
cleanup();
});
test("getOrganizationIds should return an array of organization IDs when the database contains multiple organizations", async () => {
const mockOrganizations = [{ id: "org1" }, { id: "org2" }, { id: "org3" }];
vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations);
const organizationIds = await getOrganizationIds();
expect(organizationIds).toEqual(["org1", "org2", "org3"]);
expect(prisma.organization.findMany).toHaveBeenCalledTimes(1);
expect(prisma.organization.findMany).toHaveBeenCalledWith({
select: {
id: true,
},
});
});
test("getOrganizationIds should return an empty array when the database contains no organizations", async () => {
vi.mocked(prisma.organization.findMany).mockResolvedValue([]);
const organizationIds = await getOrganizationIds();
expect(organizationIds).toEqual([]);
expect(prisma.organization.findMany).toHaveBeenCalledTimes(1);
expect(prisma.organization.findMany).toHaveBeenCalledWith({
select: {
id: true,
},
});
});
});

View File

@@ -0,0 +1,570 @@
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getProjectsByOrganizationId } from "./project";
const mockProjects = [
{
id: "project1",
name: "Project 1",
environments: [
{
id: "env1",
type: "production",
surveys: [],
attributeKeys: [],
},
],
organization: {
memberships: [
{
user: {
id: "user1",
email: "test@example.com",
notificationSettings: {
weeklySummary: {
project1: true,
},
},
locale: "en",
},
},
],
},
},
];
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6); // Set to 6 days ago to be within the last 7 days
const mockProjectsWithNoEnvironments = [
{
id: "project3",
name: "Project 3",
environments: [],
organization: {
memberships: [
{
user: {
id: "user1",
email: "test@example.com",
notificationSettings: {
weeklySummary: {
project3: true,
},
},
locale: "en",
},
},
],
},
},
];
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
findMany: vi.fn(),
},
},
}));
describe("Project Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("getProjectsByOrganizationId", () => {
test("retrieves projects with environments, surveys, and organization memberships for a valid organization ID", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("handles date calculations correctly across DST boundaries", async () => {
const mockDate = new Date(2024, 10, 3, 0, 0, 0); // November 3, 2024, 00:00:00 (example DST boundary)
const sevenDaysAgo = new Date(mockDate);
sevenDaysAgo.setDate(mockDate.getDate() - 7);
vi.useFakeTimers();
vi.setSystemTime(mockDate);
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
await getProjectsByOrganizationId(organizationId);
expect(prisma.project.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
organizationId: organizationId,
},
select: expect.objectContaining({
environments: expect.objectContaining({
select: expect.objectContaining({
surveys: expect.objectContaining({
where: expect.objectContaining({
NOT: expect.objectContaining({
AND: expect.arrayContaining([
expect.objectContaining({ status: "completed" }),
expect.objectContaining({
responses: expect.objectContaining({
none: expect.objectContaining({
createdAt: expect.objectContaining({
gte: sevenDaysAgo,
}),
}),
}),
}),
]),
}),
}),
}),
}),
}),
}),
})
);
vi.useRealTimers();
});
test("includes surveys with 'completed' status but responses within the last 7 days", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjects);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("returns an empty array when an invalid organization ID is provided", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce([]);
const invalidOrganizationId = "invalidOrgId";
const projects = await getProjectsByOrganizationId(invalidOrganizationId);
expect(projects).toEqual([]);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: invalidOrganizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
test("handles projects with no environments", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValueOnce(mockProjectsWithNoEnvironments);
const organizationId = "testOrgId";
const projects = await getProjectsByOrganizationId(organizationId);
expect(projects).toEqual(mockProjectsWithNoEnvironments);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: organizationId,
},
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
NOT: {
AND: [
{ status: "completed" },
{
responses: {
none: {
createdAt: {
gte: expect.any(Date),
},
},
},
},
],
},
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
where: {
createdAt: {
gte: expect.any(Date),
},
},
select: {
id: true,
},
},
hiddenFields: true,
},
},
attributeKeys: {
select: {
id: true,
createdAt: true,
updatedAt: true,
name: true,
description: true,
type: true,
environmentId: true,
key: true,
isUnique: true,
},
},
},
},
organization: {
select: {
memberships: {
select: {
user: {
select: {
id: true,
email: true,
notificationSettings: true,
locale: true,
},
},
},
},
},
},
},
});
});
});
});

View File

@@ -0,0 +1,99 @@
import { cache } from "@/lib/cache";
import { TContact } from "@/modules/ee/contacts/types/contact";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContactByUserId } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
// Mock cache\
vi.mock("@/lib/cache", async () => {
const actual = await vi.importActual("@/lib/cache");
return {
...(actual as any),
cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function
};
});
const environmentId = "test-environment-id";
const userId = "test-user-id";
const contactId = "test-contact-id";
const contactMock: Partial<TContact> & {
attributes: { value: string; attributeKey: { key: string } }[];
} = {
id: contactId,
attributes: [
{ attributeKey: { key: "userId" }, value: userId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
],
};
describe("getContactByUserId", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return contact if found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(contactMock as any);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
expect(contact).toEqual(contactMock);
});
test("should return null if contact not found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
},
});
expect(contact).toBeNull();
});
});

View File

@@ -0,0 +1,309 @@
import { cache } from "@/lib/cache";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurveys } from "@/lib/survey/service";
import { anySurveyHasFilters } from "@/lib/survey/utils";
import { diffInDays } from "@/lib/utils/datetime";
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getSyncSurveys } from "./survey";
// Mock dependencies
vi.mock("@/lib/cache", async () => {
const actual = await vi.importActual("@/lib/cache");
return {
...(actual as any),
cache: vi.fn((fn) => fn()), // Mock cache function to just execute the passed function
};
});
vi.mock("@/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/lib/survey/utils", () => ({
anySurveyHasFilters: vi.fn(),
}));
vi.mock("@/lib/utils/datetime", () => ({
diffInDays: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
evaluateSegment: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
display: {
findMany: vi.fn(),
},
response: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const environmentId = "test-env-id";
const contactId = "test-contact-id";
const contactAttributes = { userId: "user1", email: "test@example.com" };
const deviceType = "desktop";
const mockProject = {
id: "proj1",
name: "Test Project",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org1",
environments: [],
recontactDays: 10,
inAppSurveyBranding: true,
linkSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
languages: [],
} as unknown as TProject;
const baseSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey 1",
environmentId: environmentId,
type: "app",
status: "inProgress",
questions: [],
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
segment: null,
surveyClosedMessage: null,
singleUse: null,
styling: null,
pin: null,
resultShareKey: null,
displayLimit: null,
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
endings: [],
triggers: [],
languages: [],
variables: [],
hiddenFields: { enabled: false },
createdBy: null,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
runOnDate: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
recaptcha: { enabled: false, threshold: 0.5 },
};
describe("getSyncSurveys", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
vi.mocked(evaluateSegment).mockResolvedValue(true);
vi.mocked(diffInDays).mockReturnValue(100); // Assume enough days passed
});
afterEach(() => {
vi.resetAllMocks();
});
test("should throw error if product not found", async () => {
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
"Product not found"
);
});
test("should return empty array if no surveys found", async () => {
vi.mocked(getSurveys).mockResolvedValue([]);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
});
test("should return empty array if no 'app' type surveys in progress", async () => {
const surveys: TSurvey[] = [
{ ...baseSurvey, id: "s1", type: "link", status: "inProgress" },
{ ...baseSurvey, id: "s2", type: "app", status: "paused" },
];
vi.mocked(getSurveys).mockResolvedValue(surveys);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
});
test("should filter by displayOption 'displayOnce'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayOnce" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]); // Already displayed
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.display.findMany).mockResolvedValue([]); // Not displayed yet
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should filter by displayOption 'displayMultiple'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayMultiple" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]); // Already responded
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.response.findMany).mockResolvedValue([]); // Not responded yet
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should filter by displayOption 'displaySome'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displaySome", displayLimit: 2 }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([
{ id: "d1", surveyId: "s1", contactId },
{ id: "d2", surveyId: "s1", contactId },
]); // Display limit reached
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]); // Within limit
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
// Test with response already submitted
vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]);
const result3 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result3).toEqual([]);
});
test("should not filter by displayOption 'respondMultiple'", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "respondMultiple" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(prisma.display.findMany).mockResolvedValue([{ id: "d1", surveyId: "s1", contactId }]);
vi.mocked(prisma.response.findMany).mockResolvedValue([{ id: "r1", surveyId: "s1", contactId }]);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual(surveys);
});
test("should filter by product recontactDays if survey recontactDays is null", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", recontactDays: null }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
const displayDate = new Date();
vi.mocked(prisma.display.findMany).mockResolvedValue([
{ id: "d1", surveyId: "s2", contactId, createdAt: displayDate }, // Display for another survey
]);
vi.mocked(diffInDays).mockReturnValue(5); // Not enough days passed (product.recontactDays = 10)
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]);
expect(diffInDays).toHaveBeenCalledWith(expect.any(Date), displayDate);
vi.mocked(diffInDays).mockReturnValue(15); // Enough days passed
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual(surveys);
});
test("should return surveys if no segment filters exist", async () => {
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1" }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual(surveys);
expect(evaluateSegment).not.toHaveBeenCalled();
});
test("should evaluate segment filters if they exist", async () => {
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
// Case 1: Segment evaluation matches
vi.mocked(evaluateSegment).mockResolvedValue(true);
const result1 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result1).toEqual(surveys);
expect(evaluateSegment).toHaveBeenCalledWith(
{
attributes: contactAttributes,
deviceType,
environmentId,
contactId,
userId: contactAttributes.userId,
},
segment.filters
);
// Case 2: Segment evaluation does not match
vi.mocked(evaluateSegment).mockResolvedValue(false);
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result2).toEqual([]);
});
test("should handle Prisma errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "test",
});
vi.mocked(getSurveys).mockRejectedValue(prismaError);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
DatabaseError
);
expect(logger.error).toHaveBeenCalledWith(prismaError);
});
test("should handle general errors", async () => {
const generalError = new Error("Something went wrong");
vi.mocked(getSurveys).mockRejectedValue(generalError);
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
generalError
);
});
test("should throw ResourceNotFoundError if resolved surveys are null after filtering", async () => {
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
vi.mocked(getSurveys).mockResolvedValue(surveys);
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
vi.mocked(evaluateSegment).mockResolvedValue(false); // Ensure all surveys are filtered out
// This scenario is tricky to force directly as the code checks `if (!surveys)` before returning.
// However, if `Promise.all` somehow resolved to null/undefined (highly unlikely), it should throw.
// We can simulate this by mocking `Promise.all` if needed, but the current code structure makes this hard to test.
// Let's assume the filter logic works correctly and test the intended path.
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
expect(result).toEqual([]); // Expect empty array, not an error in this case.
});
});

View File

@@ -0,0 +1,247 @@
import { parseRecallInfo } from "@/lib/utils/recall";
import { describe, expect, test, vi } from "vitest";
import { TAttributes } from "@formbricks/types/attributes";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyEnding,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { replaceAttributeRecall } from "./utils";
vi.mock("@/lib/utils/recall", () => ({
parseRecallInfo: vi.fn((text, attributes) => {
const recallPattern = /recall:([a-zA-Z0-9_-]+)/;
const match = text.match(recallPattern);
if (match && match[1]) {
const recallKey = match[1];
const attributeValue = attributes[recallKey];
if (attributeValue !== undefined) {
return text.replace(recallPattern, `parsed-${attributeValue}`);
}
}
return text; // Return original text if no match or attribute not found
}),
}));
const baseSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
environmentId: "env1",
type: "app",
status: "inProgress",
questions: [],
endings: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
languages: [
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
],
triggers: [],
recontactDays: null,
displayLimit: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
hiddenFields: { enabled: false },
variables: [],
createdBy: null,
isSingleResponsePerEmailEnabled: false,
isVerifyEmailEnabled: false,
projectOverwrites: null,
runOnDate: null,
showLanguageSwitch: false,
isBackButtonHidden: false,
followUps: [],
recaptcha: { enabled: false, threshold: 0.5 },
displayOption: "displayOnce",
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
segment: null,
pin: null,
resultShareKey: null,
};
const attributes: TAttributes = {
name: "John Doe",
email: "john.doe@example.com",
plan: "premium",
};
describe("replaceAttributeRecall", () => {
test("should replace recall info in question headlines and subheaders", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Hello recall:name!" },
subheader: { default: "Your email is recall:email" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
expect(result.questions[0].subheader?.default).toBe("Your email is parsed-john.doe@example.com");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your email is recall:email", attributes);
});
test("should replace recall info in welcome card headline", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
welcomeCard: {
enabled: true,
headline: { default: "Welcome, recall:name!" },
html: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
},
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.welcomeCard.headline?.default).toBe("Welcome, parsed-John Doe!");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Welcome, recall:name!", attributes);
});
test("should replace recall info in end screen headlines and subheaders", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
endings: [
{
type: "endScreen",
headline: { default: "Thank you, recall:name!" },
subheader: { default: "Your plan: recall:plan" },
buttonLabel: { default: "Finish" },
buttonLink: "https://example.com",
} as unknown as TSurveyEnding,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.endings[0].type).toBe("endScreen");
if (result.endings[0].type === "endScreen") {
expect(result.endings[0].headline?.default).toBe("Thank you, parsed-John Doe!");
expect(result.endings[0].subheader?.default).toBe("Your plan: parsed-premium");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Thank you, recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your plan: recall:plan", attributes);
}
});
test("should handle multiple languages", () => {
const surveyMultiLang: TSurvey = {
...baseSurvey,
languages: [
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
{ language: { id: "lang2", code: "es" } as unknown as TLanguage, default: false, enabled: true },
],
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Hello recall:name!", es: "Hola recall:name!" },
required: true,
buttonLabel: { default: "Next", es: "Siguiente" },
placeholder: { default: "Type here...", es: "Escribe aquí..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyMultiLang, attributes);
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
expect(result.questions[0].headline.es).toBe("Hola parsed-John Doe!");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hola recall:name!", attributes);
});
test("should not replace if recall key is not in attributes", () => {
const surveyWithRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Your company: recall:company" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
};
const result = replaceAttributeRecall(surveyWithRecall, attributes);
expect(result.questions[0].headline.default).toBe("Your company: recall:company");
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your company: recall:company", attributes);
});
test("should handle surveys with no recall information", async () => {
const surveyNoRecall: TSurvey = {
...baseSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Just a regular question" },
required: true,
buttonLabel: { default: "Next" },
placeholder: { default: "Type here..." },
longAnswer: false,
logic: [],
} as unknown as TSurveyQuestion,
],
welcomeCard: {
enabled: true,
headline: { default: "Welcome!" },
html: { default: "<p>Some content</p>" },
buttonLabel: { default: "Start" },
timeToFinish: false,
showResponseCount: false,
},
endings: [
{
type: "endScreen",
headline: { default: "Thank you!" },
buttonLabel: { default: "Finish" },
} as unknown as TSurveyEnding,
],
};
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
const result = replaceAttributeRecall(surveyNoRecall, attributes);
expect(result).toEqual(surveyNoRecall); // Should be unchanged
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
parseRecallInfoSpy.mockRestore();
});
test("should handle surveys with empty questions, endings, or disabled welcome card", async () => {
const surveyEmpty: TSurvey = {
...baseSurvey,
questions: [],
endings: [],
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
};
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
const result = replaceAttributeRecall(surveyEmpty, attributes);
expect(result).toEqual(surveyEmpty);
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
parseRecallInfoSpy.mockRestore();
});
});

View File

@@ -0,0 +1,86 @@
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
import { DatabaseError } from "@formbricks/types/errors";
import { TJsEnvironmentStateActionClass } from "@formbricks/types/js";
import { getActionClassesForEnvironmentState } from "./actionClass";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
actionClass: {
findMany: vi.fn(),
},
},
}));
const environmentId = "test-environment-id";
const mockActionClasses: TJsEnvironmentStateActionClass[] = [
{
id: "action1",
type: "code",
name: "Code Action",
key: "code-action",
noCodeConfig: null,
},
{
id: "action2",
type: "noCode",
name: "No Code Action",
key: null,
noCodeConfig: { type: "click" } as TActionClassNoCodeConfig,
},
];
describe("getActionClassesForEnvironmentState", () => {
test("should return action classes successfully", async () => {
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
const result = await getActionClassesForEnvironmentState(environmentId);
expect(result).toEqual(mockActionClasses);
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // ZId is an object
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: { environmentId },
select: {
id: true,
type: true,
name: true,
key: true,
noCodeConfig: true,
},
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getActionClassesForEnvironmentState-${environmentId}`],
{ tags: [`environments-${environmentId}-actionClasses`] }
);
});
test("should throw DatabaseError on prisma error", async () => {
const mockError = new Error("Prisma error");
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(mockError);
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
await expect(getActionClassesForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
await expect(getActionClassesForEnvironmentState(environmentId)).rejects.toThrow(
`Database error when fetching actions for environment ${environmentId}`
);
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
expect(prisma.actionClass.findMany).toHaveBeenCalled();
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getActionClassesForEnvironmentState-${environmentId}`],
{ tags: [`environments-${environmentId}-actionClasses`] }
);
});
});

View File

@@ -0,0 +1,120 @@
import { cache } from "@/lib/cache";
import { projectCache } from "@/lib/project/cache";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TJsEnvironmentStateProject } from "@formbricks/types/js";
import { getProjectForEnvironmentState } from "./project";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/project/cache");
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate"); // Mock validateInputs if needed, though it's often tested elsewhere
const environmentId = "test-environment-id";
const mockProject: TJsEnvironmentStateProject = {
id: "test-project-id",
recontactDays: 30,
clickOutsideClose: true,
darkOverlay: false,
placement: "bottomRight",
inAppSurveyBranding: true,
styling: { allowStyleOverwrite: false },
};
describe("getProjectForEnvironmentState", () => {
beforeEach(() => {
vi.resetAllMocks();
// Mock cache implementation
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
// Mock projectCache tags
vi.mocked(projectCache.tag.byEnvironmentId).mockReturnValue(`project-env-${environmentId}`);
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return project state successfully", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject);
const result = await getProjectForEnvironmentState(environmentId);
expect(result).toEqual(mockProject);
expect(prisma.project.findFirst).toHaveBeenCalledWith({
where: {
environments: {
some: {
id: environmentId,
},
},
},
select: {
id: true,
recontactDays: true,
clickOutsideClose: true,
darkOverlay: true,
placement: true,
inAppSurveyBranding: true,
styling: true,
},
});
expect(cache).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getProjectForEnvironmentState-${environmentId}`],
{
tags: [`project-env-${environmentId}`],
}
);
});
test("should return null if project not found", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValue(null);
const result = await getProjectForEnvironmentState(environmentId);
expect(result).toBeNull();
expect(prisma.project.findFirst).toHaveBeenCalledTimes(1);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should throw DatabaseError on PrismaClientKnownRequestError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2001",
clientVersion: "test",
});
vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError);
await expect(getProjectForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting project for environment state");
expect(cache).toHaveBeenCalledTimes(1);
});
test("should re-throw unknown errors", async () => {
const unknownError = new Error("Something went wrong");
vi.mocked(prisma.project.findFirst).mockRejectedValue(unknownError);
await expect(getProjectForEnvironmentState(environmentId)).rejects.toThrow(unknownError);
expect(logger.error).not.toHaveBeenCalled(); // Should not log unknown errors here
expect(cache).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,143 @@
import { cache } from "@/lib/cache";
import { validateInputs } from "@/lib/utils/validate";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
import { getSurveysForEnvironmentState } from "./survey";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/utils/validate");
vi.mock("@/modules/survey/lib/utils");
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const environmentId = "test-environment-id";
const mockPrismaSurvey = {
id: "survey-1",
welcomeCard: { enabled: false },
name: "Test Survey",
questions: [],
variables: [],
type: "app",
showLanguageSwitch: false,
languages: [],
endings: [],
autoClose: null,
styling: null,
status: "inProgress",
recaptcha: null,
segment: null,
recontactDays: null,
displayLimit: null,
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
};
const mockTransformedSurvey: TJsEnvironmentStateSurvey = {
id: "survey-1",
welcomeCard: { enabled: false } as TJsEnvironmentStateSurvey["welcomeCard"],
name: "Test Survey",
questions: [],
variables: [],
type: "app",
showLanguageSwitch: false,
languages: [],
endings: [],
autoClose: null,
styling: null,
status: "inProgress",
recaptcha: null,
segment: null,
recontactDays: null,
displayLimit: null,
displayOption: "displayOnce",
hiddenFields: { enabled: false },
isBackButtonHidden: false,
triggers: [],
displayPercentage: null,
delay: 0,
projectOverwrites: null,
};
describe("getSurveysForEnvironmentState", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
vi.mocked(validateInputs).mockReturnValue([environmentId]); // Assume validation passes
vi.mocked(transformPrismaSurvey).mockReturnValue(mockTransformedSurvey);
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return transformed surveys on successful fetch", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([mockPrismaSurvey]);
const result = await getSurveysForEnvironmentState(environmentId);
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId },
select: expect.any(Object), // Check if select is called, specific fields are in the original code
});
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
expect(result).toEqual([mockTransformedSurvey]);
expect(logger.error).not.toHaveBeenCalled();
});
test("should return an empty array if no surveys are found", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([]);
const result = await getSurveysForEnvironmentState(environmentId);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId },
select: expect.any(Object),
});
expect(transformPrismaSurvey).not.toHaveBeenCalled();
expect(result).toEqual([]);
expect(logger.error).not.toHaveBeenCalled();
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "5.0.0",
});
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(getSurveysForEnvironmentState(environmentId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys for environment state");
});
test("should rethrow unknown errors", async () => {
const unknownError = new Error("Something went wrong");
vi.mocked(prisma.survey.findMany).mockRejectedValue(unknownError);
await expect(getSurveysForEnvironmentState(environmentId)).rejects.toThrow(unknownError);
expect(logger.error).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,160 @@
import { cache } from "@/lib/cache";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getContact, getContactByUserId } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findUnique: vi.fn(),
findFirst: vi.fn(),
},
},
}));
// Mock cache module
vi.mock("@/lib/cache");
// Mock react cache
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn) => fn), // Mock react's cache to just return the function
};
});
const mockContactId = "test-contact-id";
const mockEnvironmentId = "test-env-id";
const mockUserId = "test-user-id";
describe("Contact API Lib", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
afterEach(() => {
vi.resetAllMocks();
});
describe("getContact", () => {
test("should return contact if found", async () => {
const mockContactData = { id: mockContactId };
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContactData);
const contact = await getContact(mockContactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: mockContactId },
select: { id: true },
});
expect(contact).toEqual(mockContactData);
});
test("should return null if contact not found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
const contact = await getContact(mockContactId);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: mockContactId },
select: { id: true },
});
expect(contact).toBeNull();
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2025",
clientVersion: "5.0.0",
});
vi.mocked(prisma.contact.findUnique).mockRejectedValue(prismaError);
await expect(getContact(mockContactId)).rejects.toThrow(DatabaseError);
expect(prisma.contact.findUnique).toHaveBeenCalledWith({
where: { id: mockContactId },
select: { id: true },
});
});
});
describe("getContactByUserId", () => {
test("should return contact with formatted attributes if found", async () => {
const mockContactData = {
id: mockContactId,
attributes: [
{ attributeKey: { key: "userId" }, value: mockUserId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
],
};
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactData);
const contact = await getContactByUserId(mockEnvironmentId, mockUserId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId: mockEnvironmentId,
},
value: mockUserId,
},
},
},
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(contact).toEqual({
id: mockContactId,
attributes: {
userId: mockUserId,
email: "test@example.com",
},
});
});
test("should return null if contact not found by userId", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const contact = await getContactByUserId(mockEnvironmentId, mockUserId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId: mockEnvironmentId,
},
value: mockUserId,
},
},
},
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(contact).toBeNull();
});
});
});

View File

@@ -0,0 +1,201 @@
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { calculateTtcTotal } from "@/lib/response/utils";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseInput } from "@formbricks/types/responses";
import { createResponse } from "./response";
let mockIsFormbricksCloud = false;
vi.mock("@/lib/constants", () => ({
get IS_FORMBRICKS_CLOUD() {
return mockIsFormbricksCloud;
},
}));
vi.mock("@/lib/organization/service", () => ({
getMonthlyOrganizationResponseCount: vi.fn(),
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/posthogServer", () => ({
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn(),
}));
vi.mock("@/lib/response/cache", () => ({
responseCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/response/utils", () => ({
calculateTtcTotal: vi.fn((ttc) => ttc),
}));
vi.mock("@/lib/responseNote/cache", () => ({
responseNoteCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/telemetry", () => ({
captureTelemetry: vi.fn(),
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
create: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("./contact", () => ({
getContactByUserId: vi.fn(),
}));
const environmentId = "test-environment-id";
const surveyId = "test-survey-id";
const organizationId = "test-organization-id";
const responseId = "test-response-id";
const mockOrganization = {
id: organizationId,
name: "Test Org",
billing: {
limits: { monthly: { responses: 100 } },
plan: "free",
},
};
const mockResponseInput: TResponseInput = {
environmentId,
surveyId,
userId: null,
finished: false,
data: { question1: "answer1" },
meta: { source: "web" },
ttc: { question1: 1000 },
};
const mockResponsePrisma = {
id: responseId,
createdAt: new Date(),
updatedAt: new Date(),
surveyId,
finished: false,
data: { question1: "answer1" },
meta: { source: "web" },
ttc: { question1: 1000 },
variables: {},
contactAttributes: {},
singleUseId: null,
language: null,
displayId: null,
tags: [],
notes: [],
};
describe("createResponse", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization as any);
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma as any);
vi.mocked(calculateTtcTotal).mockImplementation((ttc) => ttc);
});
afterEach(() => {
mockIsFormbricksCloud = false;
});
test("should handle finished response and calculate TTC", async () => {
const finishedInput = { ...mockResponseInput, finished: true };
await createResponse(finishedInput);
expect(calculateTtcTotal).toHaveBeenCalledWith(mockResponseInput.ttc);
expect(prisma.response.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ finished: true }),
})
);
});
test("should check response limits if IS_FORMBRICKS_CLOUD is true", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should send limit reached event if IS_FORMBRICKS_CLOUD is true and limit reached", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalledWith(environmentId, {
plan: "free",
limits: {
projects: null,
monthly: {
responses: 100,
miu: null,
},
},
});
});
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError on Prisma known request error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
code: "P2002",
clientVersion: "test",
});
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
});
test("should throw original error on other Prisma errors", async () => {
const genericError = new Error("Generic database error");
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
});
test("should log error but not throw if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
mockIsFormbricksCloud = true;
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(100);
const posthogError = new Error("PostHog error");
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
await createResponse(mockResponseInput);
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
});
});

View File

@@ -0,0 +1,103 @@
import { getUploadSignedUrl } from "@/lib/storage/service";
import { afterEach, describe, expect, test, vi } from "vitest";
import { uploadPrivateFile } from "./uploadPrivateFile";
vi.mock("@/lib/storage/service", () => ({
getUploadSignedUrl: vi.fn(),
}));
describe("uploadPrivateFile", () => {
afterEach(() => {
vi.resetAllMocks();
});
test("should return a success response with signed URL details when getUploadSignedUrl successfully generates a signed URL", async () => {
const mockSignedUrlResponse = {
signedUrl: "mocked-signed-url",
presignedFields: { field1: "value1" },
fileUrl: "mocked-file-url",
};
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
const fileName = "test-file.txt";
const environmentId = "test-env-id";
const fileType = "text/plain";
const result = await uploadPrivateFile(fileName, environmentId, fileType);
const resultData = await result.json();
expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false);
expect(resultData).toEqual({
data: mockSignedUrlResponse,
});
});
test("should return a success response when isBiggerFileUploadAllowed is true and getUploadSignedUrl successfully generates a signed URL", async () => {
const mockSignedUrlResponse = {
signedUrl: "mocked-signed-url",
presignedFields: { field1: "value1" },
fileUrl: "mocked-file-url",
};
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
const fileName = "test-file.txt";
const environmentId = "test-env-id";
const fileType = "text/plain";
const isBiggerFileUploadAllowed = true;
const result = await uploadPrivateFile(fileName, environmentId, fileType, isBiggerFileUploadAllowed);
const resultData = await result.json();
expect(getUploadSignedUrl).toHaveBeenCalledWith(
fileName,
environmentId,
fileType,
"private",
isBiggerFileUploadAllowed
);
expect(resultData).toEqual({
data: mockSignedUrlResponse,
});
});
test("should return an internal server error response when getUploadSignedUrl throws an error", async () => {
vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("S3 unavailable"));
const fileName = "test-file.txt";
const environmentId = "test-env-id";
const fileType = "text/plain";
const result = await uploadPrivateFile(fileName, environmentId, fileType);
expect(result.status).toBe(500);
const resultData = await result.json();
expect(resultData).toEqual({
code: "internal_server_error",
details: {},
message: "Internal server error",
});
});
test("should return an internal server error response when fileName has no extension", async () => {
vi.mocked(getUploadSignedUrl).mockRejectedValue(new Error("File extension not found"));
const fileName = "test-file";
const environmentId = "test-env-id";
const fileType = "text/plain";
const result = await uploadPrivateFile(fileName, environmentId, fileType);
const resultData = await result.json();
expect(getUploadSignedUrl).toHaveBeenCalledWith(fileName, environmentId, fileType, "private", false);
expect(result.status).toBe(500);
expect(resultData).toEqual({
code: "internal_server_error",
details: {},
message: "Internal server error",
});
});
});

View File

@@ -0,0 +1,62 @@
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { mockUser } from "@/modules/auth/lib/mock-data";
import { cleanup } from "@testing-library/react";
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { afterEach, describe, expect, test, vi } from "vitest";
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
describe("getSessionUser", () => {
afterEach(() => {
cleanup();
});
test("should return the user object when valid req and res are provided", async () => {
const mockReq = {} as NextApiRequest;
const mockRes = {} as NextApiResponse;
vi.mocked(getServerSession).mockResolvedValue({ user: mockUser });
const user = await getSessionUser(mockReq, mockRes);
expect(user).toEqual(mockUser);
expect(getServerSession).toHaveBeenCalledWith(mockReq, mockRes, authOptions);
});
test("should return the user object when neither req nor res are provided", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: mockUser });
const user = await getSessionUser();
expect(user).toEqual(mockUser);
expect(getServerSession).toHaveBeenCalledWith(authOptions);
});
test("should return undefined if no session exists", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const user = await getSessionUser();
expect(user).toBeUndefined();
});
test("should return null when session exists and user property is null", async () => {
const mockReq = {} as NextApiRequest;
const mockRes = {} as NextApiResponse;
vi.mocked(getServerSession).mockResolvedValue({ user: null });
const user = await getSessionUser(mockReq, mockRes);
expect(user).toBeNull();
expect(getServerSession).toHaveBeenCalledWith(mockReq, mockRes, authOptions);
});
});

View File

@@ -0,0 +1,121 @@
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { getContactByUserId } from "./contact";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@/lib/cache");
const environmentId = "test-env-id";
const userId = "test-user-id";
const contactId = "test-contact-id";
const mockContactDbData = {
id: contactId,
attributes: [
{ attributeKey: { key: "userId" }, value: userId },
{ attributeKey: { key: "email" }, value: "test@example.com" },
{ attributeKey: { key: "plan" }, value: "premium" },
],
};
const expectedContactAttributes: TContactAttributes = {
userId: userId,
email: "test@example.com",
plan: "premium",
};
describe("getContactByUserId", () => {
beforeEach(() => {
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
test("should return contact with attributes when found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(mockContactDbData);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(contact).toEqual({
id: contactId,
attributes: expectedContactAttributes,
});
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getContactByUserIdForResponsesApi-${environmentId}-${userId}`],
{
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
}
);
});
test("should return null when contact is not found", async () => {
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const contact = await getContactByUserId(environmentId, userId);
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
where: {
attributes: {
some: {
attributeKey: {
key: "userId",
environmentId,
},
value: userId,
},
},
},
select: {
id: true,
attributes: {
select: {
attributeKey: { select: { key: true } },
value: true,
},
},
},
});
expect(contact).toBeNull();
expect(cache).toHaveBeenCalledWith(
expect.any(Function),
[`getContactByUserIdForResponsesApi-${environmentId}-${userId}`],
{
tags: [contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId)],
}
);
});
});

View File

@@ -0,0 +1,347 @@
import { cache } from "@/lib/cache";
import {
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
} from "@/lib/organization/service";
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@/lib/posthogServer";
import { responseCache } from "@/lib/response/cache";
import { getResponseContact } from "@/lib/response/service";
import { calculateTtcTotal } from "@/lib/response/utils";
import { responseNoteCache } from "@/lib/responseNote/cache";
import { validateInputs } from "@/lib/utils/validate";
import { Organization, Prisma, Response as ResponsePrisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseInput } from "@formbricks/types/responses";
import { getContactByUserId } from "./contact";
import { createResponse, getResponsesByEnvironmentIds } from "./response";
// Mock Data
const environmentId = "test-environment-id";
const organizationId = "test-organization-id";
const mockUserId = "test-user-id";
const surveyId = "test-survey-id";
const displayId = "test-display-id";
const responseId = "test-response-id";
const mockOrganization = {
id: organizationId,
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: { plan: "free", limits: { monthly: { responses: null } } } as any, // Default no limit
} as unknown as Organization;
const mockResponseInput: TResponseInput = {
environmentId,
surveyId,
displayId,
finished: true,
data: { q1: "answer1" },
meta: { userAgent: { browser: "test-browser" } },
ttc: { q1: 5 },
language: "en",
};
const mockResponseInputWithUserId: TResponseInput = {
...mockResponseInput,
userId: mockUserId,
};
// Prisma response structure (simplified)
const mockResponsePrisma = {
id: responseId,
createdAt: new Date(),
updatedAt: new Date(),
surveyId,
finished: true,
endingId: null,
data: { q1: "answer1" },
meta: { userAgent: { browser: "test-browser" } },
ttc: { q1: 5, total: 10 }, // Assume calculateTtcTotal adds 'total'
variables: {},
contactAttributes: {},
singleUseId: null,
language: "en",
displayId,
contact: null, // Prisma relation
tags: [], // Prisma relation
notes: [], // Prisma relation
} as unknown as ResponsePrisma & { contact: any; tags: any[]; notes: any[] }; // Adjust type as needed
const mockResponse: TResponse = {
id: responseId,
createdAt: mockResponsePrisma.createdAt,
updatedAt: mockResponsePrisma.updatedAt,
surveyId,
finished: true,
endingId: null,
data: { q1: "answer1" },
meta: { userAgent: { browser: "test-browser" } },
ttc: { q1: 5, total: 10 },
variables: {},
contactAttributes: {},
singleUseId: null,
language: "en",
displayId,
contact: null, // Transformed structure
tags: [], // Transformed structure
notes: [], // Transformed structure
};
const mockEnvironmentIds = [environmentId, "env-2"];
const mockLimit = 10;
const mockOffset = 5;
const mockResponsesPrisma = [mockResponsePrisma, { ...mockResponsePrisma, id: "response-2" }];
const mockTransformedResponses = [mockResponse, { ...mockResponse, id: "response-2" }];
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
}));
vi.mock("@/lib/organization/service");
vi.mock("@/lib/posthogServer");
vi.mock("@/lib/response/cache");
vi.mock("@/lib/response/service");
vi.mock("@/lib/response/utils");
vi.mock("@/lib/responseNote/cache");
vi.mock("@/lib/telemetry");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
create: vi.fn(),
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger");
vi.mock("./contact");
describe("Response Lib Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
// No need to mock IS_FORMBRICKS_CLOUD here anymore unless specifically changing it from the default
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
});
describe("createResponse", () => {
test("should create a response successfully with userId", async () => {
const mockContact = { id: "contact1", attributes: { userId: mockUserId } };
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue({
...mockResponsePrisma,
});
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50);
const response = await createResponse(mockResponseInputWithUserId);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(getContactByUserId).toHaveBeenCalledWith(environmentId, mockUserId);
expect(prisma.response.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
contact: { connect: { id: mockContact.id } },
contactAttributes: mockContact.attributes,
}),
})
);
expect(responseCache.revalidate).toHaveBeenCalledWith(
expect.objectContaining({
contactId: mockContact.id,
userId: mockUserId,
})
);
expect(responseNoteCache.revalidate).toHaveBeenCalled();
expect(response.contact).toEqual({ id: mockContact.id, userId: mockUserId });
});
test("should throw ResourceNotFoundError if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
await expect(createResponse(mockResponseInput)).rejects.toThrow(ResourceNotFoundError);
expect(getOrganizationByEnvironmentId).toHaveBeenCalledWith(environmentId);
expect(prisma.response.create).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
clientVersion: "2.0",
});
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(prisma.response.create).mockRejectedValue(prismaError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(DatabaseError);
expect(logger.error).not.toHaveBeenCalled(); // Should be caught and re-thrown as DatabaseError
});
test("should handle generic errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(prisma.response.create).mockRejectedValue(genericError);
await expect(createResponse(mockResponseInput)).rejects.toThrow(genericError);
});
describe("Cloud specific tests", () => {
test("should check response limit and send event if limit reached", async () => {
// IS_FORMBRICKS_CLOUD is true by default from the top-level mock
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
});
test("should check response limit and not send event if limit not reached", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit - 1); // Limit not reached
await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).not.toHaveBeenCalled();
});
test("should log error if sendPlanLimitsReachedEventToPosthogWeekly fails", async () => {
const limit = 100;
const mockOrgWithBilling = {
...mockOrganization,
billing: { limits: { monthly: { responses: limit } } },
} as any;
const posthogError = new Error("Posthog error");
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrgWithBilling);
vi.mocked(calculateTtcTotal).mockReturnValue({ total: 10 });
vi.mocked(prisma.response.create).mockResolvedValue(mockResponsePrisma);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(limit); // Limit reached
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(posthogError);
// Expecting successful response creation despite PostHog error
const response = await createResponse(mockResponseInput);
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalledWith(organizationId);
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(
posthogError,
"Error sending plan limits reached event to Posthog"
);
expect(response).toEqual(mockResponse); // Should still return the created response
});
});
});
describe("getResponsesByEnvironmentIds", () => {
test("should return responses successfully", async () => {
vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponsesPrisma);
vi.mocked(getResponseContact).mockReturnValue(null); // Assume no contact for simplicity
const responses = await getResponsesByEnvironmentIds(mockEnvironmentIds);
expect(validateInputs).toHaveBeenCalledTimes(1);
expect(prisma.response.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
survey: {
environmentId: { in: mockEnvironmentIds },
},
},
orderBy: [{ createdAt: "desc" }],
take: undefined,
skip: undefined,
})
);
expect(getResponseContact).toHaveBeenCalledTimes(mockResponsesPrisma.length);
expect(responses).toEqual(mockTransformedResponses);
expect(cache).toHaveBeenCalled();
});
test("should return responses with limit and offset", async () => {
vi.mocked(prisma.response.findMany).mockResolvedValue(mockResponsesPrisma);
vi.mocked(getResponseContact).mockReturnValue(null);
await getResponsesByEnvironmentIds(mockEnvironmentIds, mockLimit, mockOffset);
expect(prisma.response.findMany).toHaveBeenCalledWith(
expect.objectContaining({
take: mockLimit,
skip: mockOffset,
})
);
expect(cache).toHaveBeenCalled();
});
test("should return empty array if no responses found", async () => {
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
const responses = await getResponsesByEnvironmentIds(mockEnvironmentIds);
expect(responses).toEqual([]);
expect(prisma.response.findMany).toHaveBeenCalled();
expect(getResponseContact).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
clientVersion: "2.0",
});
vi.mocked(prisma.response.findMany).mockRejectedValue(prismaError);
await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
expect(cache).toHaveBeenCalled();
});
test("should handle generic errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.response.findMany).mockRejectedValue(genericError);
await expect(getResponsesByEnvironmentIds(mockEnvironmentIds)).rejects.toThrow(genericError);
expect(cache).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,58 @@
import { responses } from "@/app/lib/api/response";
import { getUploadSignedUrl } from "@/lib/storage/service";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { getSignedUrlForPublicFile } from "./getSignedUrl";
vi.mock("@/app/lib/api/response", () => ({
responses: {
successResponse: vi.fn((data) => ({ data })),
internalServerErrorResponse: vi.fn((message) => ({ message })),
},
}));
vi.mock("@/lib/storage/service", () => ({
getUploadSignedUrl: vi.fn(),
}));
describe("getSignedUrlForPublicFile", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("should return success response with signed URL data", async () => {
const mockFileName = "test.jpg";
const mockEnvironmentId = "env123";
const mockFileType = "image/jpeg";
const mockSignedUrlResponse = {
signedUrl: "http://example.com/signed-url",
signingData: { signature: "sig", timestamp: 123, uuid: "uuid" },
updatedFileName: "test--fid--uuid.jpg",
fileUrl: "http://example.com/file-url",
};
vi.mocked(getUploadSignedUrl).mockResolvedValue(mockSignedUrlResponse);
const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType);
expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public");
expect(responses.successResponse).toHaveBeenCalledWith(mockSignedUrlResponse);
expect(result).toEqual({ data: mockSignedUrlResponse });
});
test("should return internal server error response when getUploadSignedUrl throws an error", async () => {
const mockFileName = "test.png";
const mockEnvironmentId = "env456";
const mockFileType = "image/png";
const mockError = new Error("Failed to get signed URL");
vi.mocked(getUploadSignedUrl).mockRejectedValue(mockError);
const result = await getSignedUrlForPublicFile(mockFileName, mockEnvironmentId, mockFileType);
expect(getUploadSignedUrl).toHaveBeenCalledWith(mockFileName, mockEnvironmentId, mockFileType, "public");
expect(responses.internalServerErrorResponse).toHaveBeenCalledWith("Internal server error");
expect(result).toEqual({ message: "Internal server error" });
});
});

View File

@@ -0,0 +1,153 @@
import { segmentCache } from "@/lib/cache/segment";
import { responseCache } from "@/lib/response/cache";
import { surveyCache } from "@/lib/survey/cache";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteSurvey } from "./surveys";
// Mock dependencies
vi.mock("@/lib/cache/segment", () => ({
segmentCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/response/cache", () => ({
responseCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/survey/cache", () => ({
surveyCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
delete: vi.fn(),
},
segment: {
delete: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
const environmentId = "clq5n7p1q0000m7z0h5p6g3r3";
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
const mockDeletedSurveyAppPrivateSegment = {
id: surveyId,
environmentId,
type: "app",
segment: { id: segmentId, isPrivate: true },
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
resultShareKey: "shareKey123",
};
const mockDeletedSurveyLink = {
id: surveyId,
environmentId,
type: "link",
segment: null,
triggers: [],
resultShareKey: null,
};
describe("deleteSurvey", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("should delete a link survey without a segment and revalidate caches", async () => {
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
const deletedSurvey = await deleteSurvey(surveyId);
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
expect(prisma.survey.delete).toHaveBeenCalledWith({
where: { id: surveyId },
include: {
segment: true,
triggers: { include: { actionClass: true } },
},
});
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(segmentCache.revalidate).not.toHaveBeenCalled(); // No segment to revalidate
expect(responseCache.revalidate).toHaveBeenCalledWith({ surveyId, environmentId });
expect(surveyCache.revalidate).toHaveBeenCalledTimes(1); // Only for surveyId
expect(surveyCache.revalidate).toHaveBeenCalledWith({
id: surveyId,
environmentId,
resultShareKey: undefined,
});
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
});
test("should handle PrismaClientKnownRequestError during survey deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
code: "P2025",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).not.toHaveBeenCalled();
expect(segmentCache.revalidate).not.toHaveBeenCalled();
expect(responseCache.revalidate).not.toHaveBeenCalled();
expect(surveyCache.revalidate).not.toHaveBeenCalled();
});
test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", {
code: "P2003",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any);
vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
// Caches might have been partially revalidated before the error
});
test("should handle generic errors during deletion", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled(); // Should not log generic errors here
expect(prisma.segment.delete).not.toHaveBeenCalled();
});
test("should throw validation error for invalid surveyId", async () => {
const invalidSurveyId = "invalid-id";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
expect(prisma.survey.delete).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,187 @@
import { cache } from "@/lib/cache";
import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { DatabaseError } from "@formbricks/types/errors";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getSurveys } from "./surveys";
// Mock dependencies
vi.mock("@/lib/cache");
vi.mock("@/lib/survey/cache");
vi.mock("@/lib/survey/utils");
vi.mock("@/lib/utils/validate");
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findMany: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger");
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: vi.fn((fn) => fn), // Mock reactCache to just execute the function
};
});
const environmentId1 = "env1";
const environmentId2 = "env2";
const surveyId1 = "survey1";
const surveyId2 = "survey2";
const surveyId3 = "survey3";
const mockSurveyPrisma1 = {
id: surveyId1,
environmentId: environmentId1,
name: "Survey 1",
updatedAt: new Date(),
};
const mockSurveyPrisma2 = {
id: surveyId2,
environmentId: environmentId1,
name: "Survey 2",
updatedAt: new Date(),
};
const mockSurveyPrisma3 = {
id: surveyId3,
environmentId: environmentId2,
name: "Survey 3",
updatedAt: new Date(),
};
const mockSurveyTransformed1: TSurvey = {
...mockSurveyPrisma1,
displayPercentage: null,
segment: null,
} as TSurvey;
const mockSurveyTransformed2: TSurvey = {
...mockSurveyPrisma2,
displayPercentage: null,
segment: null,
} as TSurvey;
const mockSurveyTransformed3: TSurvey = {
...mockSurveyPrisma3,
displayPercentage: null,
segment: null,
} as TSurvey;
describe("getSurveys (Management API)", () => {
beforeEach(() => {
vi.resetAllMocks();
// Mock the cache function to simply execute the underlying function
vi.mocked(cache).mockImplementation((fn) => async () => {
return fn();
});
vi.mocked(transformPrismaSurvey).mockImplementation((survey) => ({
...survey,
displayPercentage: null,
segment: null,
}));
});
afterEach(() => {
vi.resetAllMocks();
});
test("should return surveys for a single environment ID with limit and offset", async () => {
const limit = 1;
const offset = 1;
vi.mocked(prisma.survey.findMany).mockResolvedValue([mockSurveyPrisma2]);
const surveys = await getSurveys([environmentId1], limit, offset);
expect(validateInputs).toHaveBeenCalledWith(
[[environmentId1], expect.any(Object)],
[limit, expect.any(Object)],
[offset, expect.any(Object)]
);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId: { in: [environmentId1] } },
select: selectSurvey,
orderBy: { updatedAt: "desc" },
take: limit,
skip: offset,
});
expect(transformPrismaSurvey).toHaveBeenCalledTimes(1);
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockSurveyPrisma2);
expect(surveys).toEqual([mockSurveyTransformed2]);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should return surveys for multiple environment IDs without limit and offset", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([
mockSurveyPrisma1,
mockSurveyPrisma2,
mockSurveyPrisma3,
]);
const surveys = await getSurveys([environmentId1, environmentId2]);
expect(validateInputs).toHaveBeenCalledWith(
[[environmentId1, environmentId2], expect.any(Object)],
[undefined, expect.any(Object)],
[undefined, expect.any(Object)]
);
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId: { in: [environmentId1, environmentId2] } },
select: selectSurvey,
orderBy: { updatedAt: "desc" },
take: undefined,
skip: undefined,
});
expect(transformPrismaSurvey).toHaveBeenCalledTimes(3);
expect(surveys).toEqual([mockSurveyTransformed1, mockSurveyTransformed2, mockSurveyTransformed3]);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should return an empty array if no surveys are found", async () => {
vi.mocked(prisma.survey.findMany).mockResolvedValue([]);
const surveys = await getSurveys([environmentId1]);
expect(prisma.survey.findMany).toHaveBeenCalled();
expect(transformPrismaSurvey).not.toHaveBeenCalled();
expect(surveys).toEqual([]);
expect(cache).toHaveBeenCalledTimes(1);
});
test("should handle PrismaClientKnownRequestError", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2021",
clientVersion: "4.0.0",
});
vi.mocked(prisma.survey.findMany).mockRejectedValue(prismaError);
await expect(getSurveys([environmentId1])).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith(prismaError, "Error getting surveys");
expect(cache).toHaveBeenCalledTimes(1);
});
test("should handle generic errors", async () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.survey.findMany).mockRejectedValue(genericError);
await expect(getSurveys([environmentId1])).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalledTimes(1);
});
test("should throw validation error for invalid input", async () => {
const invalidEnvId = "invalid-env";
const validationError = new Error("Validation failed");
vi.mocked(validateInputs).mockImplementation(() => {
throw validationError;
});
await expect(getSurveys([invalidEnvId])).rejects.toThrow(validationError);
expect(prisma.survey.findMany).not.toHaveBeenCalled();
expect(cache).toHaveBeenCalledTimes(1); // Cache wrapper is still called
});
});

View File

@@ -0,0 +1,108 @@
import { webhookCache } from "@/lib/cache/webhook";
import { Webhook } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { ValidationError } from "@formbricks/types/errors";
import { deleteWebhook } from "./webhook";
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
tag: {
byId: () => "mockTag",
},
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
ValidationError: class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
},
}));
describe("deleteWebhook", () => {
afterEach(() => {
cleanup();
});
test("should delete the webhook and return the deleted webhook object when provided with a valid webhook ID", async () => {
const mockedWebhook: Webhook = {
id: "test-webhook-id",
url: "https://example.com",
name: "Test Webhook",
createdAt: new Date(),
updatedAt: new Date(),
source: "user",
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
};
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook);
const deletedWebhook = await deleteWebhook("test-webhook-id");
expect(deletedWebhook).toEqual(mockedWebhook);
expect(prisma.webhook.delete).toHaveBeenCalledWith({
where: {
id: "test-webhook-id",
},
});
expect(webhookCache.revalidate).toHaveBeenCalled();
});
test("should delete the webhook and call webhookCache.revalidate with correct parameters", async () => {
const mockedWebhook: Webhook = {
id: "test-webhook-id",
url: "https://example.com",
name: "Test Webhook",
createdAt: new Date(),
updatedAt: new Date(),
source: "user",
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
};
vi.mocked(prisma.webhook.delete).mockResolvedValueOnce(mockedWebhook);
const deletedWebhook = await deleteWebhook("test-webhook-id");
expect(deletedWebhook).toEqual(mockedWebhook);
expect(prisma.webhook.delete).toHaveBeenCalledWith({
where: {
id: "test-webhook-id",
},
});
expect(webhookCache.revalidate).toHaveBeenCalledWith({
id: mockedWebhook.id,
environmentId: mockedWebhook.environmentId,
source: mockedWebhook.source,
});
});
test("should throw an error when called with an invalid webhook ID format", async () => {
const { validateInputs } = await import("@/lib/utils/validate");
(validateInputs as any).mockImplementation(() => {
throw new ValidationError("Validation failed");
});
await expect(deleteWebhook("invalid-id")).rejects.toThrow(ValidationError);
expect(prisma.webhook.delete).not.toHaveBeenCalled();
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,203 @@
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { webhookCache } from "@/lib/cache/webhook";
import { validateInputs } from "@/lib/utils/validate";
import { Prisma, WebhookSource } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
create: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/webhook", () => ({
webhookCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
describe("createWebhook", () => {
afterEach(() => {
cleanup();
});
test("should create a webhook and revalidate the cache when provided with valid input data", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1", "survey2"],
};
const createdWebhook = {
id: "webhook-id",
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user" as WebhookSource,
triggers: ["responseCreated"],
surveyIds: ["survey1", "survey2"],
createdAt: new Date(),
updatedAt: new Date(),
} as any;
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
const result = await createWebhook(webhookInput);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.webhook.create).toHaveBeenCalledWith({
data: {
url: webhookInput.url,
name: webhookInput.name,
source: webhookInput.source,
surveyIds: webhookInput.surveyIds,
triggers: webhookInput.triggers,
environment: {
connect: {
id: webhookInput.environmentId,
},
},
},
});
expect(webhookCache.revalidate).toHaveBeenCalledWith({
id: createdWebhook.id,
environmentId: createdWebhook.environmentId,
source: createdWebhook.source,
});
expect(result).toEqual(createdWebhook);
});
test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => {
const invalidWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: 123, // Invalid URL
source: "user" as WebhookSource,
triggers: ["responseCreated"],
surveyIds: ["survey1", "survey2"],
};
vi.mocked(validateInputs).mockImplementation(() => {
throw new ValidationError("Validation failed");
});
await expect(createWebhook(invalidWebhookInput as any)).rejects.toThrowError(ValidationError);
});
test("should throw a DatabaseError if a PrismaClientKnownRequestError occurs", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1", "survey2"],
};
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2002",
clientVersion: "5.0.0",
})
);
await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError);
});
test("should call webhookCache.revalidate with the correct parameters after successfully creating a webhook", async () => {
const webhookInput: TWebhookInput = {
environmentId: "env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
const createdWebhook = {
id: "webhook123",
environmentId: "env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
createdAt: new Date(),
updatedAt: new Date(),
} as any;
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
await createWebhook(webhookInput);
expect(webhookCache.revalidate).toHaveBeenCalledWith({
id: createdWebhook.id,
environmentId: createdWebhook.environmentId,
source: createdWebhook.source,
});
});
test("should throw a DatabaseError when provided with invalid surveyIds", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["invalid-survey-id"],
};
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Foreign key constraint violation"));
await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError);
});
test("should handle edge case URLs that are technically valid but problematic", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "http://localhost:3000", // Example of a potentially problematic URL
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1", "survey2"],
};
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new DatabaseError("Invalid URL"));
await expect(createWebhook(webhookInput)).rejects.toThrowError(DatabaseError);
expect(validateInputs).toHaveBeenCalled();
expect(prisma.webhook.create).toHaveBeenCalledWith({
data: {
url: webhookInput.url,
name: webhookInput.name,
source: webhookInput.source,
surveyIds: webhookInput.surveyIds,
triggers: webhookInput.triggers,
environment: {
connect: {
id: webhookInput.environmentId,
},
},
},
});
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
{
"auth": {
"continue_with_azure": "Login mit Azure",
"continue_with_azure": "Weiter mit Microsoft",
"continue_with_email": "Login mit E-Mail",
"continue_with_github": "Login mit GitHub",
"continue_with_google": "Login mit Google",

View File

@@ -1,6 +1,6 @@
{
"auth": {
"continue_with_azure": "Continue with Azure",
"continue_with_azure": "Continue with Microsoft",
"continue_with_email": "Continue with Email",
"continue_with_github": "Continue with GitHub",
"continue_with_google": "Continue with Google",

View File

@@ -1,6 +1,6 @@
{
"auth": {
"continue_with_azure": "Continuer avec Azure",
"continue_with_azure": "Continuer avec Microsoft",
"continue_with_email": "Continuer avec l'e-mail",
"continue_with_github": "Continuer avec GitHub",
"continue_with_google": "Continuer avec Google",

View File

@@ -1,6 +1,6 @@
{
"auth": {
"continue_with_azure": "Continuar com Azure",
"continue_with_azure": "Continuar com Microsoft",
"continue_with_email": "Continuar com o Email",
"continue_with_github": "Continuar com o GitHub",
"continue_with_google": "Continuar com o Google",

View File

@@ -1,6 +1,6 @@
{
"auth": {
"continue_with_azure": "Continuar com Azure",
"continue_with_azure": "Continuar com Microsoft",
"continue_with_email": "Continuar com Email",
"continue_with_github": "Continuar com GitHub",
"continue_with_google": "Continuar com Google",

View File

@@ -1,6 +1,6 @@
{
"auth": {
"continue_with_azure": "使用 Azure 繼續",
"continue_with_azure": "繼續使用 Microsoft",
"continue_with_email": "使用電子郵件繼續",
"continue_with_github": "使用 GitHub 繼續",
"continue_with_google": "使用 Google 繼續",

View File

@@ -466,7 +466,6 @@ describe("SegmentSettings", () => {
expect(updatedSaveButton.getAttribute("data-loading")).not.toBe("true");
});
// [Tusk] FAILING TEST
test("should add a filter to the segment when a valid filter is selected in the filter modal", async () => {
// Render component
render(<SegmentSettings {...mockProps} />);

View File

@@ -4,7 +4,6 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware"
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import {
TUpdateInviteAction,
TUpdateMembershipAction,
checkRoleManagementPermission,
updateInviteAction,
updateMembershipAction,
@@ -215,7 +214,7 @@ describe("Role Management Actions", () => {
organizationId: "org-123",
data: { role: "member" },
},
} as unknown as TUpdateMembershipAction)
} as any)
).rejects.toThrow(new AuthenticationError("User not a member of this organization"));
});
@@ -231,7 +230,7 @@ describe("Role Management Actions", () => {
organizationId: "org-123",
data: { role: "member" },
},
} as unknown as TUpdateMembershipAction)
} as any)
).rejects.toThrow(new OperationNotAllowedError("User management is disabled"));
});
@@ -248,7 +247,7 @@ describe("Role Management Actions", () => {
organizationId: "org-123",
data: { role: "billing" },
},
} as unknown as TUpdateMembershipAction)
} as any)
).rejects.toThrow(new ValidationError("Billing role is not allowed"));
});
@@ -268,7 +267,7 @@ describe("Role Management Actions", () => {
organizationId: "org-123",
data: { role: "billing" },
},
} as unknown as TUpdateMembershipAction);
} as any);
expect(result).toEqual({ id: "membership-123", role: "billing" });
});
@@ -286,7 +285,7 @@ describe("Role Management Actions", () => {
organizationId: "org-123",
data: { role: "owner" },
},
} as unknown as TUpdateMembershipAction)
} as any)
).rejects.toThrow(new OperationNotAllowedError("Managers can only assign users to the member role"));
});
@@ -305,7 +304,7 @@ describe("Role Management Actions", () => {
organizationId: "org-123",
data: { role: "member" },
},
} as unknown as TUpdateMembershipAction);
} as any);
expect(result).toEqual({ id: "membership-123", role: "member" });
expect(updateMembership).toHaveBeenCalledWith("user-456", "org-123", { role: "member" });
@@ -326,7 +325,7 @@ describe("Role Management Actions", () => {
organizationId: "org-123",
data: { role: "member" },
},
} as unknown as TUpdateMembershipAction);
} as any);
expect(result).toEqual({ id: "membership-123", role: "member" });
expect(updateMembership).toHaveBeenCalledWith("user-456", "org-123", { role: "member" });

View File

@@ -11,8 +11,7 @@ import { updateMembership } from "@/modules/ee/role-management/lib/membership";
import { ZInviteUpdateInput } from "@/modules/ee/role-management/types/invites";
import { z } from "zod";
import { ZId, ZUuid } from "@formbricks/types/common";
import { OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { AuthenticationError } from "@formbricks/types/errors";
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
import { ZMembershipUpdateInput } from "@formbricks/types/memberships";
export const checkRoleManagementPermission = async (organizationId: string) => {

View File

@@ -0,0 +1,357 @@
import { createI18nString } from "@/lib/i18n/utils";
import { OpenQuestionForm } from "@/modules/survey/editor/components/open-question-form";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
// Import fireEvent, remove rtlRerender if not used elsewhere
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyOpenTextQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
// Mock dependencies
vi.mock("@/lib/i18n/utils", () => ({
createI18nString: vi.fn((text, languages) =>
languages.reduce((acc, lang) => ({ ...acc, [lang]: text }), {})
),
extractLanguageCodes: vi.fn((languages) => languages?.map((lang) => lang.code) ?? ["default"]),
}));
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: vi.fn(({ id, value, label, onChange }) => (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
data-testid={id}
value={JSON.stringify(value)} // Simplified representation for testing
onChange={(e) => onChange?.(JSON.parse(e.target.value))}
/>
</div>
)),
}));
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
AdvancedOptionToggle: vi.fn(({ isChecked, onToggle, htmlId, children }) => (
<div>
<input
type="checkbox"
id={htmlId}
data-testid={htmlId}
checked={isChecked}
onChange={(e) => onToggle(e.target.checked)}
/>
<label htmlFor={htmlId}>Toggle Advanced Options</label>
{isChecked && <div>{children}</div>}
</div>
)),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: vi.fn(({ children, onClick, ...props }) => (
<button onClick={onClick} {...props}>
{children}
</button>
)),
}));
vi.mock("@/modules/ui/components/input", () => ({
Input: vi.fn(({ id, value, onChange, ...props }) => (
<input id={id} data-testid={id} value={value} onChange={onChange} {...props} />
)),
}));
vi.mock("@/modules/ui/components/label", () => ({
Label: vi.fn(({ htmlFor, children }) => <label htmlFor={htmlFor}>{children}</label>),
}));
vi.mock("@/modules/ui/components/options-switch", () => ({
OptionsSwitch: vi.fn(({ options, currentOption, handleOptionChange }) => (
<div>
{options.map((option) => (
<button
key={option.value}
data-testid={`options-switch-${option.value}`}
onClick={() => handleOptionChange(option.value)}
disabled={option.value === currentOption}>
{option.label}
</button>
))}
</div>
)),
}));
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: vi.fn(() => [vi.fn()]), // Mock ref
}));
// Mock Lucide icons
vi.mock("lucide-react", async () => {
const original = await vi.importActual("lucide-react");
return {
...original,
MessageSquareTextIcon: () => <div>MessageSquareTextIcon</div>,
MailIcon: () => <div>MailIcon</div>,
LinkIcon: () => <div>LinkIcon</div>,
HashIcon: () => <div>HashIcon</div>,
PhoneIcon: () => <div>PhoneIcon</div>,
PlusIcon: () => <div>PlusIcon</div>,
};
});
const mockQuestion = {
id: "openText1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: createI18nString("What's your name?", ["en"]),
subheader: createI18nString("Please tell us.", ["en"]),
placeholder: createI18nString("Type here...", ["en"]),
longAnswer: false,
required: true,
inputType: "text",
buttonLabel: createI18nString("Next", ["en"]),
// Initialize charLimit as undefined or disabled
charLimit: { enabled: false, min: undefined, max: undefined },
} as TSurveyOpenTextQuestion;
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
status: "inProgress",
questions: [mockQuestion],
languages: [{ code: "en", default: true, enabled: true }],
thankYouCard: { enabled: true },
welcomeCard: { enabled: false },
autoClose: null,
triggers: [],
environmentId: "env1",
createdAt: new Date(),
updatedAt: new Date(),
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
attributeFilters: [],
endings: [],
hiddenFields: { enabled: false },
styling: {},
variables: [],
productOverwrites: null,
singleUse: null,
verifyEmail: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
inlineTriggers: null,
pin: null,
resultShareKey: null,
segment: null,
surveyClosedMessage: null,
redirectUrl: null,
createdBy: null,
autoComplete: null,
runOnDate: null,
displayProgressBar: true,
} as unknown as TSurvey;
const mockUpdateQuestion = vi.fn();
const mockSetSelectedLanguageCode = vi.fn();
describe("OpenQuestionForm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders the form correctly", () => {
render(
<OpenQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={"en" as TUserLocale}
lastQuestion={false}
/>
);
expect(screen.getByTestId("headline")).toBeInTheDocument();
expect(screen.getByTestId("subheader")).toBeInTheDocument();
expect(screen.getByTestId("placeholder")).toBeInTheDocument();
expect(screen.getByTestId("options-switch-text")).toBeDisabled();
expect(screen.getByTestId("options-switch-email")).not.toBeDisabled();
expect(screen.queryByTestId("charLimit")).toBeInTheDocument(); // AdvancedOptionToggle is rendered
expect(screen.queryByTestId("minLength")).not.toBeInTheDocument(); // Char limit inputs hidden initially
});
test("adds subheader when undefined", async () => {
const questionWithoutSubheader = { ...mockQuestion, subheader: undefined };
render(
<OpenQuestionForm
question={questionWithoutSubheader}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={"en" as TUserLocale}
lastQuestion={false}
/>
);
expect(screen.queryByTestId("subheader")).not.toBeInTheDocument();
const addButton = screen.getByText("environments.surveys.edit.add_description");
await userEvent.click(addButton);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
subheader: { en: "" },
});
});
test("changes input type and updates placeholder", async () => {
render(
<OpenQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={"en" as TUserLocale}
lastQuestion={false}
/>
);
const emailButton = screen.getByTestId("options-switch-email");
await userEvent.click(emailButton);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
inputType: "email",
placeholder: { en: "example@email.com" },
longAnswer: false,
charLimit: { min: undefined, max: undefined },
});
// Check if char limit section is hidden after switching to email
expect(screen.queryByTestId("charLimit")).toBeNull();
});
test("toggles and updates character limits", async () => {
// Initial render with charLimit disabled
const initialProps = {
question: { ...mockQuestion, charLimit: { enabled: false, min: undefined, max: undefined } },
questionIdx: 0,
updateQuestion: mockUpdateQuestion,
isInvalid: false,
localSurvey: mockSurvey,
selectedLanguageCode: "en",
setSelectedLanguageCode: mockSetSelectedLanguageCode,
locale: "en" as TUserLocale,
lastQuestion: false,
};
const { rerender } = render(<OpenQuestionForm {...initialProps} />);
const charLimitToggle = screen.getByTestId("charLimit");
expect(screen.queryByTestId("minLength")).not.toBeInTheDocument();
expect(screen.queryByTestId("maxLength")).not.toBeInTheDocument();
// Enable char limits via toggle click
await userEvent.click(charLimitToggle);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
charLimit: { enabled: true, min: undefined, max: undefined },
});
// Simulate parent component updating the prop
const updatedQuestionEnabled = {
...initialProps.question,
charLimit: { enabled: true, min: undefined, max: undefined },
};
rerender(<OpenQuestionForm {...initialProps} question={updatedQuestionEnabled} />);
// Inputs should now be visible
const minInput = screen.getByTestId("minLength");
const maxInput = screen.getByTestId("maxLength");
expect(minInput).toBeInTheDocument();
expect(maxInput).toBeInTheDocument();
// Test setting input values using fireEvent.change
fireEvent.change(minInput, { target: { value: "10" } });
// Check the last call after changing value to "10"
// Note: fireEvent.change might only trigger one call, so toHaveBeenCalledWith might be sufficient
// but toHaveBeenLastCalledWith is safer if previous calls occurred.
expect(mockUpdateQuestion).toHaveBeenLastCalledWith(0, {
charLimit: { enabled: true, min: 10, max: undefined },
});
// Simulate parent updating prop after min input change
const updatedQuestionMinSet = {
...updatedQuestionEnabled,
charLimit: { enabled: true, min: 10, max: undefined },
};
rerender(<OpenQuestionForm {...initialProps} question={updatedQuestionMinSet} />);
// Ensure maxInput is requeried if needed after rerender, though testId should persist
const maxInputAfterRerender = screen.getByTestId("maxLength");
fireEvent.change(maxInputAfterRerender, { target: { value: "100" } });
// Check the last call after changing value to "100"
expect(mockUpdateQuestion).toHaveBeenLastCalledWith(0, {
charLimit: { enabled: true, min: 10, max: 100 },
});
// Simulate parent updating prop after max input change
const updatedQuestionMaxSet = {
...updatedQuestionMinSet,
charLimit: { enabled: true, min: 10, max: 100 },
};
rerender(<OpenQuestionForm {...initialProps} question={updatedQuestionMaxSet} />);
// Disable char limits again via toggle click
const charLimitToggleAgain = screen.getByTestId("charLimit");
await userEvent.click(charLimitToggleAgain);
expect(mockUpdateQuestion).toHaveBeenLastCalledWith(0, {
charLimit: { enabled: false, min: undefined, max: undefined },
});
// Simulate parent updating prop after disabling
const updatedQuestionDisabled = {
...updatedQuestionMaxSet,
charLimit: { enabled: false, min: undefined, max: undefined },
};
rerender(<OpenQuestionForm {...initialProps} question={updatedQuestionDisabled} />);
// Inputs should be hidden again
expect(screen.queryByTestId("minLength")).not.toBeInTheDocument();
expect(screen.queryByTestId("maxLength")).not.toBeInTheDocument();
});
test("initializes char limit toggle correctly if limits are pre-set", () => {
const questionWithLimits = {
...mockQuestion,
charLimit: { enabled: true, min: 5, max: 50 },
};
render(
<OpenQuestionForm
question={questionWithLimits}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={"en" as TUserLocale}
lastQuestion={false}
/>
);
const charLimitToggle: HTMLInputElement = screen.getByTestId("charLimit");
expect(charLimitToggle.checked).toBe(true);
expect(screen.getByTestId("minLength")).toBeInTheDocument();
expect(screen.getByTestId("maxLength")).toBeInTheDocument();
expect(screen.getByTestId("minLength")).toHaveValue(5);
expect(screen.getByTestId("maxLength")).toHaveValue(50);
});
});

View File

@@ -0,0 +1,183 @@
import { createI18nString } from "@/lib/i18n/utils";
import { PictureSelectionForm } from "@/modules/survey/editor/components/picture-selection-form";
import { FileInput } from "@/modules/ui/components/file-input";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyPictureSelectionQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [vi.fn()],
}));
vi.mock("@/modules/survey/components/question-form-input", () => ({
// Mock as a simple component returning a div with a test ID
QuestionFormInput: ({ id }: { id: string }) => <div data-testid={`question-form-input-${id}`}></div>,
}));
vi.mock("@/modules/ui/components/file-input", () => ({
FileInput: vi.fn(),
}));
const mockUpdateQuestion = vi.fn();
const mockSetSelectedLanguageCode = vi.fn();
const baseQuestion: TSurveyPictureSelectionQuestion = {
id: "picture1",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: createI18nString("Picture Headline", ["default"]),
subheader: createI18nString("Picture Subheader", ["default"]),
required: true,
allowMulti: false,
choices: [
{ id: "choice1", imageUrl: "url1" },
{ id: "choice2", imageUrl: "url2" },
],
};
const baseSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
environmentId: "env1",
status: "draft",
questions: [baseQuestion],
languages: [{ language: { code: "default" } as unknown as TLanguage, default: true, enabled: true }],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
autoClose: null,
closeOnDate: null,
delay: 0,
autoComplete: null,
surveyClosedMessage: null,
singleUse: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
styling: null,
hiddenFields: { enabled: true },
variables: [],
pin: null,
resultShareKey: null,
displayPercentage: null,
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as TSurvey;
const defaultProps = {
localSurvey: baseSurvey,
question: baseQuestion,
questionIdx: 0,
updateQuestion: mockUpdateQuestion,
lastQuestion: false,
selectedLanguageCode: "default",
setSelectedLanguageCode: mockSetSelectedLanguageCode,
isInvalid: false,
locale: "en-US" as TUserLocale,
};
describe("PictureSelectionForm", () => {
beforeEach(() => {
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(() => {
cleanup();
});
test("renders headline and subheader inputs", () => {
render(<PictureSelectionForm {...defaultProps} />);
// Check if two instances of the mocked component are rendered
const headlineInput = screen.getByTestId("question-form-input-headline");
const subheaderInput = screen.getByTestId("question-form-input-subheader");
expect(headlineInput).toBeInTheDocument();
expect(subheaderInput).toBeInTheDocument();
});
test("renders 'Add Description' button when subheader is undefined", () => {
const questionWithoutSubheader = { ...baseQuestion, subheader: undefined };
render(<PictureSelectionForm {...defaultProps} question={questionWithoutSubheader} />);
const addButton = screen.getByText("environments.surveys.edit.add_description");
expect(addButton).toBeInTheDocument();
});
test("calls updateQuestion to add subheader when 'Add Description' is clicked", async () => {
const user = userEvent.setup();
const questionWithoutSubheader = { ...baseQuestion, subheader: undefined };
render(<PictureSelectionForm {...defaultProps} question={questionWithoutSubheader} />);
const addButton = screen.getByText("environments.surveys.edit.add_description");
await user.click(addButton);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
subheader: createI18nString("", ["default"]),
});
});
test("calls updateQuestion when files are uploaded via FileInput", () => {
render(<PictureSelectionForm {...defaultProps} />);
const fileInputProps = vi.mocked(FileInput).mock.calls[0][0];
fileInputProps.onFileUpload(["url1", "url2", "url3"], "image"); // Simulate adding a new file
expect(mockUpdateQuestion).toHaveBeenCalledWith(
0,
expect.objectContaining({
choices: expect.arrayContaining([
expect.objectContaining({ imageUrl: "url1" }),
expect.objectContaining({ imageUrl: "url2" }),
expect.objectContaining({ imageUrl: "url3", id: expect.any(String) }),
]),
})
);
});
test("calls updateQuestion when files are removed via FileInput", () => {
render(<PictureSelectionForm {...defaultProps} />);
const fileInputProps = vi.mocked(FileInput).mock.calls[0][0];
fileInputProps.onFileUpload(["url1"], "image"); // Simulate removing url2
expect(mockUpdateQuestion).toHaveBeenCalledWith(
0,
expect.objectContaining({
choices: [expect.objectContaining({ imageUrl: "url1" })],
})
);
});
test("renders multi-select toggle and calls updateQuestion on click", async () => {
const user = userEvent.setup();
render(<PictureSelectionForm {...defaultProps} />);
const toggle = screen.getByRole("switch");
expect(toggle).toBeInTheDocument();
expect(toggle).not.toBeChecked(); // Initial state based on baseQuestion
await user.click(toggle);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { allowMulti: true });
});
test("shows validation message when isInvalid is true and choices < 2", () => {
const invalidQuestion = { ...baseQuestion, choices: [{ id: "choice1", imageUrl: "url1" }] };
render(<PictureSelectionForm {...defaultProps} question={invalidQuestion} isInvalid={true} />);
const validationSpan = screen.getByText("(environments.surveys.edit.upload_at_least_2_images)");
expect(validationSpan).toHaveClass("text-red-600");
});
test("does not show validation message in red when isInvalid is false", () => {
const invalidQuestion = { ...baseQuestion, choices: [{ id: "choice1", imageUrl: "url1" }] };
render(<PictureSelectionForm {...defaultProps} question={invalidQuestion} isInvalid={false} />);
const validationSpan = screen.getByText("(environments.surveys.edit.upload_at_least_2_images)");
expect(validationSpan).not.toHaveClass("text-red-600");
expect(validationSpan).toHaveClass("text-slate-400");
});
});

View File

@@ -0,0 +1,121 @@
import { Placement } from "@/modules/survey/editor/components/placement";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TPlacement } from "@formbricks/types/common";
// Mock useTranslate
const mockSetCurrentPlacement = vi.fn();
const mockSetOverlay = vi.fn();
const mockSetClickOutsideClose = vi.fn();
const defaultProps = {
currentPlacement: "bottomRight" as TPlacement,
setCurrentPlacement: mockSetCurrentPlacement,
setOverlay: mockSetOverlay,
overlay: "light",
setClickOutsideClose: mockSetClickOutsideClose,
clickOutsideClose: false,
};
describe("Placement Component", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders placement options correctly", () => {
render(<Placement {...defaultProps} />);
expect(screen.getByLabelText("common.bottom_right")).toBeInTheDocument();
expect(screen.getByLabelText("common.top_right")).toBeInTheDocument();
expect(screen.getByLabelText("common.top_left")).toBeInTheDocument();
expect(screen.getByLabelText("common.bottom_left")).toBeInTheDocument();
expect(screen.getByLabelText("common.centered_modal")).toBeInTheDocument();
expect(screen.getByLabelText("common.bottom_right")).toBeChecked();
});
test("calls setCurrentPlacement when a placement option is clicked", async () => {
const user = userEvent.setup();
render(<Placement {...defaultProps} />);
const topLeftRadio = screen.getByLabelText("common.top_left");
await user.click(topLeftRadio);
expect(mockSetCurrentPlacement).toHaveBeenCalledWith("topLeft");
});
test("does not render overlay and click-outside options initially", () => {
render(<Placement {...defaultProps} />);
expect(
screen.queryByLabelText("environments.surveys.edit.centered_modal_overlay_color")
).not.toBeInTheDocument();
expect(
screen.queryByLabelText("common.allow_users_to_exit_by_clicking_outside_the_survey")
).not.toBeInTheDocument();
});
test("renders overlay and click-outside options when placement is 'center'", () => {
render(<Placement {...defaultProps} currentPlacement="center" />);
// Use getByText for the heading labels
expect(screen.getByText("environments.surveys.edit.centered_modal_overlay_color")).toBeInTheDocument();
expect(screen.getByText("common.allow_users_to_exit_by_clicking_outside_the_survey")).toBeInTheDocument();
// Keep getByLabelText for the actual radio button labels
expect(screen.getByLabelText("common.light_overlay")).toBeInTheDocument();
expect(screen.getByLabelText("common.dark_overlay")).toBeInTheDocument();
expect(screen.getByLabelText("common.allow")).toBeInTheDocument();
expect(screen.getByLabelText("common.disallow")).toBeInTheDocument();
});
test("calls setOverlay when overlay option is clicked", async () => {
const user = userEvent.setup();
render(<Placement {...defaultProps} currentPlacement="center" overlay="light" />);
const darkOverlayRadio = screen.getByLabelText("common.dark_overlay");
await user.click(darkOverlayRadio);
expect(mockSetOverlay).toHaveBeenCalledWith("dark");
});
// Test clicking 'allow' when starting with clickOutsideClose = false
test("calls setClickOutsideClose(true) when 'allow' is clicked", async () => {
const user = userEvent.setup();
render(<Placement {...defaultProps} currentPlacement="center" clickOutsideClose={false} />);
const allowRadio = screen.getByLabelText("common.allow");
await user.click(allowRadio);
expect(mockSetClickOutsideClose).toHaveBeenCalledTimes(1);
expect(mockSetClickOutsideClose).toHaveBeenCalledWith(true);
});
// Test clicking 'disallow' when starting with clickOutsideClose = true
test("calls setClickOutsideClose(false) when 'disallow' is clicked", async () => {
const user = userEvent.setup();
render(<Placement {...defaultProps} currentPlacement="center" clickOutsideClose={true} />);
const disallowRadio = screen.getByLabelText("common.disallow");
await user.click(disallowRadio);
expect(mockSetClickOutsideClose).toHaveBeenCalledTimes(1);
expect(mockSetClickOutsideClose).toHaveBeenCalledWith(false);
});
test("applies correct overlay style based on placement and overlay props", () => {
const { rerender } = render(<Placement {...defaultProps} />);
let previewDiv = screen.getByTestId("placement-preview");
expect(previewDiv).toHaveClass("bg-slate-200");
rerender(<Placement {...defaultProps} currentPlacement="center" overlay="light" />);
previewDiv = screen.getByTestId("placement-preview");
expect(previewDiv).toHaveClass("bg-slate-200");
rerender(<Placement {...defaultProps} currentPlacement="center" overlay="dark" />);
previewDiv = screen.getByTestId("placement-preview");
expect(previewDiv).toHaveClass("bg-slate-700/80");
});
test("applies cursor-not-allowed when clickOutsideClose is false", () => {
render(<Placement {...defaultProps} clickOutsideClose={false} />);
const previewDiv = screen.getByTestId("placement-preview");
expect(previewDiv).toHaveClass("cursor-not-allowed");
});
test("does not apply cursor-not-allowed when clickOutsideClose is true", () => {
render(<Placement {...defaultProps} clickOutsideClose={true} />);
const previewDiv = screen.getByTestId("placement-preview");
expect(previewDiv).not.toHaveClass("cursor-not-allowed");
});
});

View File

@@ -48,6 +48,7 @@ export const Placement = ({
))}
</RadioGroup>
<div
data-testid="placement-preview"
className={cn(
clickOutsideClose ? "" : "cursor-not-allowed",
"relative ml-8 h-40 w-full rounded",

View File

@@ -0,0 +1,393 @@
import { QuestionCard } from "@/modules/survey/editor/components/question-card";
import { Project } from "@prisma/client";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
// Import waitFor
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
TSurvey,
TSurveyAddressQuestion,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
// Mock child components
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: vi.fn(({ id, label, value, placeholder }) => (
<div>
<label htmlFor={id}>{label}</label>
<input
id={id}
data-testid={`question-form-input-${id}`}
defaultValue={value?.["default"] || ""}
placeholder={placeholder}
/>
</div>
)),
}));
vi.mock("@/modules/survey/editor/components/address-question-form", () => ({
AddressQuestionForm: vi.fn(() => <div data-testid="address-form">AddressQuestionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/advanced-settings", () => ({
AdvancedSettings: vi.fn(() => <div data-testid="advanced-settings">AdvancedSettings</div>),
}));
vi.mock("@/modules/survey/editor/components/cal-question-form", () => ({
CalQuestionForm: vi.fn(() => <div data-testid="cal-form">CalQuestionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/consent-question-form", () => ({
ConsentQuestionForm: vi.fn(() => <div data-testid="consent-form">ConsentQuestionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/contact-info-question-form", () => ({
ContactInfoQuestionForm: vi.fn(() => <div data-testid="contact-info-form">ContactInfoQuestionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/cta-question-form", () => ({
CTAQuestionForm: vi.fn(() => <div data-testid="cta-form">CTAQuestionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/date-question-form", () => ({
DateQuestionForm: vi.fn(() => <div data-testid="date-form">DateQuestionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/editor-card-menu", () => ({
EditorCardMenu: vi.fn(() => <div data-testid="editor-card-menu">EditorCardMenu</div>),
}));
vi.mock("@/modules/survey/editor/components/file-upload-question-form", () => ({
FileUploadQuestionForm: vi.fn(() => <div data-testid="file-upload-form">FileUploadQuestionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/matrix-question-form", () => ({
MatrixQuestionForm: vi.fn(() => <div data-testid="matrix-form">MatrixQuestionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/multiple-choice-question-form", () => ({
MultipleChoiceQuestionForm: vi.fn(() => <div data-testid="multiple-choice-form">MultipleChoiceForm</div>),
}));
vi.mock("@/modules/survey/editor/components/nps-question-form", () => ({
NPSQuestionForm: vi.fn(() => <div data-testid="nps-form">NPSQuestionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/open-question-form", () => ({
OpenQuestionForm: vi.fn(() => <div data-testid="open-text-form">OpenQuestionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/picture-selection-form", () => ({
PictureSelectionForm: vi.fn(() => <div data-testid="picture-selection-form">PictureSelectionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/ranking-question-form", () => ({
RankingQuestionForm: vi.fn(() => <div data-testid="ranking-form">RankingQuestionForm</div>),
}));
vi.mock("@/modules/survey/editor/components/rating-question-form", () => ({
RatingQuestionForm: vi.fn(() => <div data-testid="rating-form">RatingQuestionForm</div>),
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: vi.fn(({ children }) => <div data-testid="alert">{children}</div>),
AlertTitle: vi.fn(({ children }) => <div data-testid="alert-title">{children}</div>),
AlertButton: vi.fn(({ children, onClick }) => (
<button data-testid="alert-button" onClick={onClick}>
{children}
</button>
)),
}));
vi.mock("@dnd-kit/sortable", () => ({
useSortable: vi.fn(() => ({
attributes: {},
listeners: {},
setNodeRef: vi.fn(),
transform: null,
transition: null,
isDragging: false,
})),
}));
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: vi.fn(() => [vi.fn()]), // Mock useAutoAnimate to return a ref
}));
// Mock utility functions
vi.mock("@/lib/utils/recall", async () => {
const original = await vi.importActual("@/lib/utils/recall");
return {
...original,
recallToHeadline: vi.fn((headline) => headline), // Ensure this mock returns the headline object directly
};
});
vi.mock("@/modules/survey/editor/lib/utils", async () => {
const original = await vi.importActual("@/modules/survey/editor/lib/utils");
return {
...original,
formatTextWithSlashes: vi.fn((text) => text), // Mock formatTextWithSlashes to return text as is
};
});
const mockMoveQuestion = vi.fn();
const mockUpdateQuestion = vi.fn();
const mockDeleteQuestion = vi.fn();
const mockDuplicateQuestion = vi.fn();
const mockSetActiveQuestionId = vi.fn();
const mockSetSelectedLanguageCode = vi.fn();
const mockAddQuestion = vi.fn();
const mockOnAlertTrigger = vi.fn();
const mockProject = { id: "project1", name: "Test Project" } as Project;
const baseSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
environmentId: "env1",
status: "draft",
questions: [],
endings: [],
languages: [{ language: { code: "en" }, default: true, enabled: true }],
triggers: [],
recontactDays: null,
displayOption: "displayOnce",
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
styling: {},
variables: [],
createdAt: new Date(),
updatedAt: new Date(),
autoClose: null,
delay: 0,
displayLimit: null,
resultShareKey: null,
inlineTriggers: null,
pinResponses: false,
productOverwrites: null,
singleUse: null,
surveyClosedMessage: null,
verifyEmail: null,
closeOnDate: null,
projectOverwrites: null,
hiddenFields: { enabled: false },
} as unknown as TSurvey;
const baseQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question Headline", en: "Question Headline" },
subheader: { default: "Optional Subheader", en: "Optional Subheader" },
required: true,
buttonLabel: { default: "Next", en: "Next" },
backButtonLabel: { default: "Back", en: "Back" },
inputType: "text",
longAnswer: false,
placeholder: { default: "Type your answer here...", en: "Type your answer here..." },
logic: [],
charLimit: { enabled: false },
} as TSurveyQuestion;
const defaultProps = {
localSurvey: { ...baseSurvey, questions: [baseQuestion] } as TSurvey,
project: mockProject,
question: baseQuestion,
questionIdx: 0,
moveQuestion: mockMoveQuestion,
updateQuestion: mockUpdateQuestion,
deleteQuestion: mockDeleteQuestion,
duplicateQuestion: mockDuplicateQuestion,
activeQuestionId: null,
setActiveQuestionId: mockSetActiveQuestionId,
lastQuestion: true,
selectedLanguageCode: "en",
setSelectedLanguageCode: mockSetSelectedLanguageCode,
isInvalid: false,
addQuestion: mockAddQuestion,
isFormbricksCloud: true,
isCxMode: false,
locale: "en-US" as const,
responseCount: 0,
onAlertTrigger: mockOnAlertTrigger,
};
describe("QuestionCard Component", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks(); // Clear mocks after each test
});
test("renders basic structure and headline", () => {
render(<QuestionCard {...defaultProps} />);
expect(screen.getByText("Question Headline")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.required")).toBeInTheDocument(); // Collapsed state
expect(screen.getByText("EditorCardMenu")).toBeInTheDocument();
});
test("renders optional subheader when collapsed", () => {
const props = { ...defaultProps, question: { ...baseQuestion, required: false } };
render(<QuestionCard {...props} />);
expect(screen.getByText("environments.surveys.edit.optional")).toBeInTheDocument();
});
test("renders correct question form based on type (OpenText)", () => {
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
expect(screen.getByTestId("open-text-form")).toBeInTheDocument();
});
test("renders correct question form based on type (MultipleChoiceSingle)", () => {
const mcQuestion = {
...baseQuestion,
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [],
} as TSurveyQuestion;
render(<QuestionCard {...defaultProps} question={mcQuestion} activeQuestionId="q1" />);
expect(screen.getByTestId("multiple-choice-form")).toBeInTheDocument();
});
// Add similar tests for other question types...
test("calls setActiveQuestionId when card is clicked", async () => {
const user = userEvent.setup();
// Initial render with activeQuestionId: null
const { rerender: rerenderCard } = render(<QuestionCard {...defaultProps} activeQuestionId={null} />);
const trigger = screen
.getByText("Question Headline")
.closest("div[role='button'], div[type='button'], button");
expect(trigger).toBeInTheDocument();
// First click: should call setActiveQuestionId with "q1"
await user.click(trigger!);
expect(mockSetActiveQuestionId).toHaveBeenCalledWith("q1");
// Re-render with activeQuestionId: "q1" to simulate state update
rerenderCard(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
// Second click: should call setActiveQuestionId with null
await user.click(trigger!);
expect(mockSetActiveQuestionId).toHaveBeenCalledWith(null);
});
test("renders 'Long Answer' toggle for OpenText question when open", () => {
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
expect(screen.getByLabelText("environments.surveys.edit.long_answer")).toBeInTheDocument();
});
test("does not render 'Long Answer' toggle for non-OpenText question", () => {
const mcQuestion = {
...baseQuestion,
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [],
} as TSurveyQuestion;
render(<QuestionCard {...defaultProps} question={mcQuestion} activeQuestionId="q1" />);
expect(screen.queryByLabelText("environments.surveys.edit.long_answer")).not.toBeInTheDocument();
});
test("calls updateQuestion when 'Long Answer' toggle is clicked", async () => {
const user = userEvent.setup();
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
const toggle = screen.getByRole("switch", { name: "environments.surveys.edit.long_answer" });
await user.click(toggle);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { longAnswer: true }); // Assuming initial is false
});
test("calls updateQuestion when 'Required' toggle is clicked", async () => {
const user = userEvent.setup();
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
const toggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
await user.click(toggle);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { required: false }); // Assuming initial is true
});
test("handles required toggle special case for NPS/Rating", async () => {
const user = userEvent.setup();
const npsQuestion = {
...baseQuestion,
type: TSurveyQuestionTypeEnum.NPS,
required: false,
} as TSurveyQuestion;
render(<QuestionCard {...defaultProps} question={npsQuestion} activeQuestionId="q1" />);
const toggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
await user.click(toggle);
// Expect buttonLabel to be undefined when toggling to required for NPS/Rating
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, { required: true, buttonLabel: undefined });
});
test("renders advanced settings trigger and content", async () => {
const user = userEvent.setup();
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
const trigger = screen.getByText("environments.surveys.edit.show_advanced_settings");
expect(screen.queryByTestId("advanced-settings")).not.toBeInTheDocument(); // Initially hidden
await user.click(trigger);
expect(screen.getByText("environments.surveys.edit.hide_advanced_settings")).toBeInTheDocument();
expect(screen.getByTestId("advanced-settings")).toBeInTheDocument(); // Now visible
});
test("renders button label inputs in advanced settings when applicable", () => {
render(<QuestionCard {...defaultProps} activeQuestionId="q1" />);
// Need to open advanced settings first
fireEvent.click(screen.getByText("environments.surveys.edit.show_advanced_settings"));
expect(screen.getByTestId("question-form-input-buttonLabel")).toBeInTheDocument();
// Back button shouldn't render for the first question (index 0)
expect(screen.queryByTestId("question-form-input-backButtonLabel")).not.toBeInTheDocument();
});
test("renders back button label input for non-first questions", () => {
render(<QuestionCard {...defaultProps} questionIdx={1} activeQuestionId="q1" />);
fireEvent.click(screen.getByText("environments.surveys.edit.show_advanced_settings"));
expect(screen.getByTestId("question-form-input-buttonLabel")).toBeInTheDocument();
expect(screen.getByTestId("question-form-input-backButtonLabel")).toBeInTheDocument();
});
test("does not render button labels for NPS/Rating/CTA in advanced settings", () => {
const npsQuestion = { ...baseQuestion, type: TSurveyQuestionTypeEnum.NPS } as TSurveyQuestion;
render(<QuestionCard {...defaultProps} question={npsQuestion} activeQuestionId="q1" />);
fireEvent.click(screen.getByText("environments.surveys.edit.show_advanced_settings"));
expect(screen.queryByTestId("question-form-input-buttonLabel")).not.toBeInTheDocument();
});
test("renders warning alert when responseCount > 0 for specific types", () => {
const mcQuestion = {
...baseQuestion,
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [],
} as TSurveyQuestion;
render(<QuestionCard {...defaultProps} question={mcQuestion} responseCount={1} activeQuestionId="q1" />);
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(screen.getByTestId("alert-title")).toHaveTextContent("environments.surveys.edit.caution_text");
expect(screen.getByTestId("alert-button")).toHaveTextContent("common.learn_more");
});
test("does not render warning alert when responseCount is 0", () => {
const mcQuestion = {
...baseQuestion,
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [],
} as TSurveyQuestion;
render(<QuestionCard {...defaultProps} question={mcQuestion} responseCount={0} activeQuestionId="q1" />);
expect(screen.queryByTestId("alert")).not.toBeInTheDocument();
});
test("does not render warning alert for non-applicable question types", () => {
render(<QuestionCard {...defaultProps} responseCount={1} activeQuestionId="q1" />); // OpenText
expect(screen.queryByTestId("alert")).not.toBeInTheDocument();
});
test("calls onAlertTrigger when alert button is clicked", async () => {
const user = userEvent.setup();
const mcQuestion = {
...baseQuestion,
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [],
} as TSurveyQuestion;
render(<QuestionCard {...defaultProps} question={mcQuestion} responseCount={1} activeQuestionId="q1" />);
const alertButton = screen.getByTestId("alert-button");
await user.click(alertButton);
expect(mockOnAlertTrigger).toHaveBeenCalledTimes(1);
});
test("applies invalid styling when isInvalid is true", () => {
render(<QuestionCard {...defaultProps} isInvalid={true} />);
const dragHandle = screen.getByRole("button", { name: "" }).parentElement; // Get the div containing the GripIcon
expect(dragHandle).toHaveClass("bg-red-400");
});
test("disables required toggle for Address question if all fields are optional", () => {
const addressQuestion = {
...baseQuestion,
type: TSurveyQuestionTypeEnum.Address,
addressLine1: { show: true, required: false },
addressLine2: { show: false, required: false },
city: { show: true, required: false },
state: { show: false, required: false },
zip: { show: true, required: false },
country: { show: false, required: false },
} as TSurveyQuestion;
render(<QuestionCard {...defaultProps} question={addressQuestion} activeQuestionId="q1" />);
const toggle = screen.getByRole("switch", { name: "environments.surveys.edit.required" });
expect(toggle).toBeDisabled();
});
});

View File

@@ -0,0 +1,357 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import { TSurvey, TSurveyLanguage, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { QuestionOptionChoice } from "./question-option-choice";
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: (props: any) => (
<div data-testid="question-form-input" className={props.className}></div>
),
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ children }: any) => <div data-testid="tooltip-renderer">{children}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button data-testid="button" onClick={onClick} {...props}>
{children}
</button>
),
}));
describe("QuestionOptionChoice", () => {
afterEach(() => {
cleanup();
});
test("should render correctly for a standard choice", () => {
const choice = { id: "choice1", label: { default: "Choice 1" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [choice],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={0}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={vi.fn()}
isInvalid={false}
localSurvey={
{
languages: [{ code: "default", name: "Default", enabled: true, default: true }],
} as unknown as TSurvey
}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[
{ language: { code: "default" } as unknown as TLanguage, enabled: true, default: true },
]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
/>
);
expect(screen.getByTestId("tooltip-renderer")).toBeDefined();
expect(screen.getByTestId("question-form-input")).toBeDefined();
const addButton = screen.getByTestId("button");
expect(addButton).toBeDefined();
});
test("should call deleteChoice when the 'Delete choice' button is clicked for a standard choice", async () => {
const choice = { id: "choice1", label: { default: "Choice 1" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [
choice,
{ id: "choice2", label: { default: "Choice 2" } },
{ id: "choice3", label: { default: "Choice 3" } },
],
} as any;
const deleteChoice = vi.fn();
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={0}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={deleteChoice}
addChoice={vi.fn()}
isInvalid={false}
localSurvey={
{
languages: [
{
language: { code: "default" } as unknown as TLanguage,
enabled: true,
default: true,
} as unknown as TSurveyLanguage,
],
} as unknown as TSurvey
}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[
{ language: { code: "default" } as unknown as TLanguage, enabled: true, default: true },
]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
/>
);
const deleteButtons = screen.getAllByTestId("button");
const deleteButton = deleteButtons[0]; // The first button should be the delete button based on the rendered output
await userEvent.click(deleteButton);
expect(deleteChoice).toHaveBeenCalledWith(0);
});
test("should call addChoice when the 'Add choice below' button is clicked for a standard choice", async () => {
const addChoice = vi.fn();
const choice = { id: "choice1", label: { default: "Choice 1" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [choice],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={0}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={addChoice}
isInvalid={false}
localSurvey={
{
languages: [{ code: "default", name: "Default", enabled: true, default: true }],
} as unknown as TSurvey
}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[
{ language: { code: "default" } as unknown as TLanguage, enabled: true, default: true },
]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
/>
);
const addButton = screen.getByTestId("button");
expect(addButton).toBeDefined();
await userEvent.click(addButton);
expect(addChoice).toHaveBeenCalledWith(0);
});
test("should render QuestionFormInput with correct props for a standard choice", () => {
const choice = { id: "choice1", label: { default: "Choice 1" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [choice],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={0}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={vi.fn()}
isInvalid={false}
localSurvey={
{
languages: [{ code: "default", name: "Default", enabled: true, default: true }],
} as unknown as TSurvey
}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[
{ language: { code: "default" } as unknown as TLanguage, enabled: true, default: true },
]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
/>
);
expect(screen.getByTestId("question-form-input")).toBeDefined();
});
test("should handle malformed choice object gracefully when id is missing", () => {
const choice = { label: { default: "Choice without ID" } } as any;
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [choice],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={0}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={vi.fn()}
isInvalid={false}
localSurvey={
{
languages: [{ code: "default", name: "Default", enabled: true, default: true }],
} as unknown as TSurvey
}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[
{ language: { code: "default" } as unknown as TLanguage, enabled: true, default: true },
]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
/>
);
const questionFormInput = screen.getByTestId("question-form-input");
expect(questionFormInput).toBeDefined();
expect(questionFormInput).toBeInTheDocument();
});
test("should not throw an error when question.choices is undefined", () => {
const choice = { id: "choice1", label: { default: "Choice 1" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: undefined,
} as any;
const renderComponent = () =>
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={0}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={vi.fn()}
isInvalid={false}
localSurvey={
{
languages: [{ code: "default", name: "Default", enabled: true, default: true }],
} as unknown as TSurvey
}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[
{ language: { code: "default" } as unknown as TLanguage, enabled: true, default: true },
]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
/>
);
expect(renderComponent).not.toThrow();
});
test("should render correctly for the 'other' choice with drag functionality disabled", () => {
const choice = { id: "other", label: { default: "Other" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [choice],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={0}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={vi.fn()}
isInvalid={false}
localSurvey={
{
languages: [{ code: "default", name: "Default", enabled: true, default: true }],
} as unknown as TSurvey
}
selectedLanguageCode="default"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[
{ language: { code: "default" } as unknown as TLanguage, enabled: true, default: true },
]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={["default"]}
locale="en-US"
/>
);
const dragHandle = screen.getByRole("button", {
name: "",
hidden: true,
});
expect(dragHandle).toHaveClass("invisible");
});
test("should handle missing language code gracefully", () => {
const choice = { id: "choice1", label: { en: "Choice 1" } };
const question = {
id: "question1",
headline: { default: "Question 1" },
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
choices: [choice],
} as any;
render(
<QuestionOptionChoice
choice={choice}
choiceIdx={0}
questionIdx={0}
updateChoice={vi.fn()}
deleteChoice={vi.fn()}
addChoice={vi.fn()}
isInvalid={false}
localSurvey={{ languages: [] } as any}
selectedLanguageCode="fr"
setSelectedLanguageCode={vi.fn()}
surveyLanguages={[]}
question={question}
updateQuestion={vi.fn()}
surveyLanguageCodes={[]}
locale="en-US"
/>
);
expect(screen.getByTestId("question-form-input")).toBeDefined();
});
});

View File

@@ -0,0 +1,376 @@
import { QuestionsDroppable } from "@/modules/survey/editor/components/questions-droppable";
import { Project } from "@prisma/client";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock the QuestionCard component
vi.mock("@/modules/survey/editor/components/question-card", () => ({
QuestionCard: vi.fn(({ isInvalid }) => (
<div data-testid={isInvalid !== undefined ? `question-card-${isInvalid}` : "question-card"}></div>
)),
}));
// Mock window.matchMedia
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
});
// Mock SortableContext for testing strategy
vi.mock("@dnd-kit/sortable", async () => {
const actual = await vi.importActual("@dnd-kit/sortable");
return {
...actual,
SortableContext: vi.fn(({ children, strategy }) => {
const strategyName =
strategy === actual.verticalListSortingStrategy ? "verticalListSortingStrategy" : "other";
return (
<div data-testid="sortable-context" data-strategy={strategyName}>
{children}
</div>
);
}),
};
});
describe("QuestionsDroppable", () => {
afterEach(() => {
cleanup();
});
test("should render a QuestionCard for each question in localSurvey.questions", () => {
const mockQuestions: TSurveyQuestion[] = [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
} as any,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2" },
} as any,
{
id: "q3",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Question 3" },
} as any,
];
const mockLocalSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
environmentId: "env123",
status: "draft",
questions: mockQuestions,
endings: [],
languages: [],
triggers: [],
createdAt: new Date(),
updatedAt: new Date(),
welcomeCard: { enabled: false } as any,
styling: {},
variables: [],
} as any;
const mockProject: Project = {
id: "project1",
name: "Test Project",
} as any;
render(
<QuestionsDroppable
localSurvey={mockLocalSurvey}
project={mockProject}
moveQuestion={vi.fn()}
updateQuestion={vi.fn()}
deleteQuestion={vi.fn()}
duplicateQuestion={vi.fn()}
activeQuestionId={null}
setActiveQuestionId={vi.fn()}
selectedLanguageCode="en"
setSelectedLanguageCode={vi.fn()}
invalidQuestions={null}
addQuestion={vi.fn()}
isFormbricksCloud={false}
isCxMode={false}
locale="en-US"
responseCount={0}
onAlertTrigger={vi.fn()}
/>
);
// Since we're using SortableContext mock, we need to check for question-card-false
// as the default when invalidQuestions is null
const questionCards = screen.getAllByTestId("question-card-false");
expect(questionCards.length).toBe(mockQuestions.length);
});
test("should use verticalListSortingStrategy in SortableContext", () => {
const mockQuestions: TSurveyQuestion[] = [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
} as any,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2" },
} as any,
{
id: "q3",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Question 3" },
} as any,
];
const mockLocalSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
environmentId: "env123",
status: "draft",
questions: mockQuestions,
endings: [],
languages: [],
triggers: [],
createdAt: new Date(),
updatedAt: new Date(),
welcomeCard: { enabled: false } as any,
styling: {},
variables: [],
} as any;
const mockProject: Project = {
id: "project1",
name: "Test Project",
} as any;
render(
<QuestionsDroppable
localSurvey={mockLocalSurvey}
project={mockProject}
moveQuestion={vi.fn()}
updateQuestion={vi.fn()}
deleteQuestion={vi.fn()}
duplicateQuestion={vi.fn()}
activeQuestionId={null}
setActiveQuestionId={vi.fn()}
selectedLanguageCode="en"
setSelectedLanguageCode={vi.fn()}
invalidQuestions={null}
addQuestion={vi.fn()}
isFormbricksCloud={false}
isCxMode={false}
locale="en-US"
responseCount={0}
onAlertTrigger={vi.fn()}
/>
);
const sortableContext = screen.getByTestId("sortable-context");
expect(sortableContext).toHaveAttribute("data-strategy", "verticalListSortingStrategy");
});
test("should pass the isInvalid prop to each QuestionCard based on the invalidQuestions array", () => {
const mockQuestions: TSurveyQuestion[] = [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
} as any,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 2" },
} as any,
{
id: "q3",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Question 3" },
} as any,
];
const mockLocalSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
environmentId: "env123",
status: "draft",
questions: mockQuestions,
endings: [],
languages: [],
triggers: [],
createdAt: new Date(),
updatedAt: new Date(),
welcomeCard: { enabled: false } as any,
styling: {},
variables: [],
} as any;
const mockProject: Project = {
id: "project1",
name: "Test Project",
} as any;
const invalidQuestions = ["q1", "q3"];
render(
<QuestionsDroppable
localSurvey={mockLocalSurvey}
project={mockProject}
moveQuestion={vi.fn()}
updateQuestion={vi.fn()}
deleteQuestion={vi.fn()}
duplicateQuestion={vi.fn()}
activeQuestionId={null}
setActiveQuestionId={vi.fn()}
selectedLanguageCode="en"
setSelectedLanguageCode={vi.fn()}
invalidQuestions={invalidQuestions}
addQuestion={vi.fn()}
isFormbricksCloud={false}
isCxMode={false}
locale="en-US"
responseCount={0}
onAlertTrigger={vi.fn()}
/>
);
expect(screen.getAllByTestId("question-card-true")).toHaveLength(2);
expect(screen.getAllByTestId("question-card-false")).toHaveLength(1);
});
test("should handle null invalidQuestions without errors", () => {
const mockQuestions: TSurveyQuestion[] = [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
} as any,
];
const mockLocalSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
environmentId: "env123",
status: "draft",
questions: mockQuestions,
endings: [],
languages: [],
triggers: [],
createdAt: new Date(),
updatedAt: new Date(),
welcomeCard: { enabled: false } as any,
styling: {},
variables: [],
} as any;
const mockProject: Project = {
id: "project1",
name: "Test Project",
} as any;
render(
<QuestionsDroppable
localSurvey={mockLocalSurvey}
project={mockProject}
moveQuestion={vi.fn()}
updateQuestion={vi.fn()}
deleteQuestion={vi.fn()}
duplicateQuestion={vi.fn()}
activeQuestionId={null}
setActiveQuestionId={vi.fn()}
selectedLanguageCode="en"
setSelectedLanguageCode={vi.fn()}
invalidQuestions={null}
addQuestion={vi.fn()}
isFormbricksCloud={false}
isCxMode={false}
locale="en-US"
responseCount={0}
onAlertTrigger={vi.fn()}
/>
);
// With our updated mock, we should look for question-card-false when invalidQuestions is null
const questionCard = screen.getByTestId("question-card-false");
expect(questionCard).toBeInTheDocument();
});
test("should render without errors when activeQuestionId is null", () => {
const mockQuestions: TSurveyQuestion[] = [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
} as any,
];
const mockLocalSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
environmentId: "env123",
status: "draft",
questions: mockQuestions,
endings: [],
languages: [],
triggers: [],
createdAt: new Date(),
updatedAt: new Date(),
welcomeCard: { enabled: false } as any,
styling: {},
variables: [],
} as any;
const mockProject: Project = {
id: "project1",
name: "Test Project",
} as any;
render(
<QuestionsDroppable
localSurvey={mockLocalSurvey}
project={mockProject}
moveQuestion={vi.fn()}
updateQuestion={vi.fn()}
deleteQuestion={vi.fn()}
duplicateQuestion={vi.fn()}
activeQuestionId={null}
setActiveQuestionId={vi.fn()}
selectedLanguageCode="en"
setSelectedLanguageCode={vi.fn()}
invalidQuestions={null}
addQuestion={vi.fn()}
isFormbricksCloud={false}
isCxMode={false}
locale="en-US"
responseCount={0}
onAlertTrigger={vi.fn()}
/>
);
// With our updated mock, we should look for question-card-false when invalidQuestions is null
const questionCards = screen.getAllByTestId("question-card-false");
expect(questionCards.length).toBe(mockQuestions.length);
});
});

View File

@@ -0,0 +1,205 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyLanguage,
TSurveyQuestionTypeEnum,
TSurveyRankingQuestion,
} from "@formbricks/types/surveys/types";
import { RankingQuestionForm } from "./ranking-question-form";
// Place all mocks at the top of the file
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: ({ value }: { value: any }) => (
<input
data-testid="headline-input"
value={value?.en || value?.de || value?.default || ""}
onChange={() => {}}
/>
),
}));
vi.mock("@/modules/survey/editor/components/question-option-choice", () => ({
QuestionOptionChoice: () => <div data-testid="question-option-choice" />,
}));
describe("RankingQuestionForm", () => {
beforeEach(() => {
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(() => {
cleanup();
});
test("should render the headline input field with the provided question headline", () => {
const mockQuestion: TSurveyRankingQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Ranking,
headline: { default: "Test Headline" },
choices: [],
required: false,
};
const mockLocalSurvey = {
id: "123",
name: "Test Survey",
type: "link",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [],
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env123",
} as unknown as TSurvey;
const mockUpdateQuestion = vi.fn();
const mockSetSelectedLanguageCode = vi.fn();
const mockLocale = "en-US";
render(
<RankingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
localSurvey={mockLocalSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
const headlineInput = screen.getByTestId("headline-input");
expect(headlineInput).toHaveValue("Test Headline");
});
test("should add a new choice when the 'Add Option' button is clicked", async () => {
const mockQuestion: TSurveyRankingQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Ranking,
headline: { default: "Test Headline" },
choices: [],
required: false,
};
const mockLocalSurvey = {
id: "123",
name: "Test Survey",
type: "link",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [],
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env123",
} as unknown as TSurvey;
const mockUpdateQuestion = vi.fn();
const mockSetSelectedLanguageCode = vi.fn();
const mockLocale = "en-US";
render(
<RankingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
localSurvey={mockLocalSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
const addButton = screen.getByText("environments.surveys.edit.add_option");
await userEvent.click(addButton);
expect(mockUpdateQuestion).toHaveBeenCalledTimes(1);
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
choices: expect.arrayContaining([
expect.objectContaining({
id: expect.any(String),
label: expect.any(Object),
}),
]),
});
});
test("should initialize new choices with empty strings for all configured survey languages", async () => {
const mockQuestion: TSurveyRankingQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Ranking,
headline: { default: "Test Headline" },
choices: [],
required: false,
};
const mockLocalSurvey = {
id: "123",
name: "Test Survey",
type: "link",
languages: [
{ language: { code: "en" } as unknown as TLanguage, default: true } as unknown as TSurveyLanguage,
{ language: { code: "de" } as unknown as TLanguage, default: false } as unknown as TSurveyLanguage,
],
questions: [],
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env123",
} as unknown as TSurvey;
const mockUpdateQuestion = vi.fn();
const mockSetSelectedLanguageCode = vi.fn();
const mockLocale = "en-US";
render(
<RankingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={mockUpdateQuestion}
isInvalid={false}
localSurvey={mockLocalSurvey}
selectedLanguageCode="en"
setSelectedLanguageCode={mockSetSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
// Simulate adding a new choice
const addOptionButton = screen.getByText("environments.surveys.edit.add_option");
await userEvent.click(addOptionButton);
// Assert that updateQuestion is called with the new choice and that the new choice has empty strings for all languages
expect(mockUpdateQuestion).toHaveBeenCalledTimes(1);
const updatedQuestion = mockUpdateQuestion.mock.calls[0][1];
expect(updatedQuestion.choices).toHaveLength(1);
expect(updatedQuestion.choices[0].label).toEqual({ default: "", de: "" });
});
});

View File

@@ -0,0 +1,521 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyLanguage,
TSurveyQuestionTypeEnum,
TSurveyRatingQuestion,
} from "@formbricks/types/surveys/types";
import { RatingQuestionForm } from "./rating-question-form";
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock @formkit/auto-animate
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
vi.mock("@/modules/survey/components/question-form-input", () => ({
QuestionFormInput: ({
value,
id,
selectedLanguageCode,
}: {
value: any;
id: string;
selectedLanguageCode?: string;
}) => {
if (id === "buttonLabel") {
return <input data-testid="buttonLabel-input" value={value?.default ?? value} readOnly />;
}
const displayValue = selectedLanguageCode
? (value?.[selectedLanguageCode] ?? value?.default ?? value)
: (value?.default ?? value);
return <input data-testid={`headline-input-${id}`} value={displayValue} readOnly />;
},
}));
vi.mock("@/modules/survey/editor/components/rating-type-dropdown", () => ({
Dropdown: ({ options, defaultValue, onSelect }: any) => {
// Determine if this is a scale dropdown or range dropdown based on options
const isScaleDropdown = options.some(
(option: any) => typeof option.value === "string" && ["number", "star", "smiley"].includes(option.value)
);
const testId = isScaleDropdown ? "scale-type-dropdown" : "range-dropdown";
return (
<div data-testid={testId} data-defaultvalue={defaultValue}>
{isScaleDropdown ? "Scale Dropdown" : "Range Dropdown"}
<select
value={defaultValue}
onChange={(e) => {
const value = isScaleDropdown ? e.target.value : parseInt(e.target.value);
const selectedOption = options.find((option: any) => option.value === value);
onSelect(selectedOption);
}}>
{options.map((option: any) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
);
},
}));
describe("RatingQuestionForm", () => {
afterEach(() => {
cleanup();
});
test("should render the headline input field with the provided question headline value", () => {
const mockQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Rating,
headline: {
default: "Test Headline",
},
scale: "number",
range: 5,
required: false,
} as unknown as TSurveyRatingQuestion;
const mockSurvey = {
id: "123",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [],
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env-id",
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
welcomeCard: {
headline: { default: "Welcome" },
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const mockLocale = "en-US";
render(
<RatingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
const headlineInput = screen.getByTestId("headline-input-headline");
expect(headlineInput).toBeDefined();
expect(headlineInput).toHaveAttribute("value", "Test Headline");
});
test("should render the scale dropdown with the correct default value and options", () => {
const mockQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Rating,
headline: {
default: "Test Headline",
},
scale: "smiley",
range: 5,
required: false,
} as unknown as TSurveyRatingQuestion;
const mockSurvey = {
id: "123",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [],
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env-id",
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
welcomeCard: {
headline: { default: "Welcome" },
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const mockLocale = "en-US";
render(
<RatingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
expect(screen.getByText("environments.surveys.edit.scale")).toBeDefined();
const scaleTypeDropdown = screen.getByTestId("scale-type-dropdown");
expect(scaleTypeDropdown).toBeDefined();
expect(scaleTypeDropdown).toHaveAttribute("data-defaultvalue", "smiley");
});
test("should render the range dropdown with the correct default value and options", () => {
const mockQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Rating,
headline: {
default: "Test Headline",
},
scale: "number",
range: 3,
required: false,
} as unknown as TSurveyRatingQuestion;
const mockSurvey = {
id: "123",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [],
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env-id",
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
welcomeCard: {
headline: { default: "Welcome" },
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const mockLocale = "en-US";
render(
<RatingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
const dropdown = screen.getByTestId("range-dropdown");
expect(dropdown).toBeDefined();
expect(dropdown).toHaveAttribute("data-defaultvalue", "3");
});
test("should call updateQuestion with scale: 'star' and isColorCodingEnabled: false when star scale is selected", async () => {
const mockQuestion: TSurveyRatingQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Rating,
headline: {
default: "Test Headline",
},
scale: "number",
range: 5,
required: false,
isColorCodingEnabled: true, // Initial value
};
const mockSurvey = {
id: "123",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [],
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env-id",
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
welcomeCard: {
headline: { default: "Welcome" },
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const mockLocale = "en-US";
render(
<RatingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
const scaleTypeDropdown = screen.getByTestId("scale-type-dropdown");
expect(scaleTypeDropdown).toBeDefined();
// Simulate selecting the 'star' option
await userEvent.selectOptions(scaleTypeDropdown.querySelector("select")!, ["star"]);
expect(updateQuestion).toHaveBeenCalledWith(0, { scale: "star", isColorCodingEnabled: false });
});
test("should render buttonLabel input when question.required changes from true to false", () => {
const mockQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Rating,
headline: {
default: "Test Headline",
},
scale: "number",
range: 5,
required: true,
} as unknown as TSurveyRatingQuestion;
const mockSurvey = {
id: "123",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [],
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env-id",
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
welcomeCard: {
headline: { default: "Welcome" },
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const mockLocale = "en-US";
// Initial render with required: true
render(
<RatingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
// Assert that buttonLabel input is NOT present
let buttonLabelInput = screen.queryByTestId("buttonLabel-input");
expect(buttonLabelInput).toBeNull();
// Update question to required: false
mockQuestion.required = false;
// Re-render with required: false
render(
<RatingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
// Assert that buttonLabel input is now present
buttonLabelInput = screen.getByTestId("buttonLabel-input");
expect(buttonLabelInput).toBeDefined();
});
test("should preserve and display content for each language code when selectedLanguageCode prop changes", () => {
const mockQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Rating,
headline: {
default: "Test Headline Default",
fr: "Test Headline French",
},
scale: "number",
range: 5,
required: false,
} as unknown as TSurveyRatingQuestion;
const mockSurvey = {
id: "123",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
{ language: { code: "fr" } as unknown as TLanguage, default: false } as unknown as TSurveyLanguage,
],
questions: [],
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env-id",
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
welcomeCard: {
headline: { default: "Welcome" },
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const mockLocale = "en-US";
const { rerender } = render(
<RatingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
// Check default language content
const headlineInput = screen.getByTestId("headline-input-headline");
expect(headlineInput).toBeDefined();
expect(headlineInput).toHaveAttribute("value", "Test Headline Default");
// Re-render with French language code
rerender(
<RatingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="fr"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
// Check French language content
expect(screen.getByTestId("headline-input-headline")).toHaveAttribute("value", "Test Headline French");
});
test("should handle and display extremely long lowerLabel and upperLabel values", () => {
const longLabel =
"This is an extremely long label to test how the component handles text overflow. ".repeat(10);
const mockQuestion = {
id: "1",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Test Headline" },
scale: "number",
range: 5,
required: false,
lowerLabel: { default: longLabel },
upperLabel: { default: longLabel },
} as unknown as TSurveyRatingQuestion;
const mockSurvey = {
id: "123",
name: "Test Survey",
languages: [
{
language: { code: "default" } as unknown as TLanguage,
default: true,
} as unknown as TSurveyLanguage,
],
questions: [],
createdAt: new Date("2024-01-01T00:00:00.000Z"),
environmentId: "env-id",
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
welcomeCard: {
headline: { default: "Welcome" },
} as unknown as TSurvey["welcomeCard"],
endings: [],
} as unknown as TSurvey;
const updateQuestion = vi.fn();
const setSelectedLanguageCode = vi.fn();
const mockLocale = "en-US";
render(
<RatingQuestionForm
question={mockQuestion}
questionIdx={0}
updateQuestion={updateQuestion}
isInvalid={false}
localSurvey={mockSurvey}
selectedLanguageCode="default"
setSelectedLanguageCode={setSelectedLanguageCode}
locale={mockLocale}
lastQuestion={false}
/>
);
const lowerLabelInput = screen.getByTestId("headline-input-lowerLabel");
expect(lowerLabelInput).toBeDefined();
expect(lowerLabelInput).toHaveAttribute("value", longLabel);
const upperLabelInput = screen.getByTestId("headline-input-upperLabel");
expect(upperLabelInput).toBeDefined();
expect(upperLabelInput).toHaveAttribute("value", longLabel);
});
});

View File

@@ -0,0 +1,168 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HashIcon } from "lucide-react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { Dropdown } from "./rating-type-dropdown";
describe("Dropdown", () => {
afterEach(() => {
cleanup();
});
test("should initialize with the correct default option when defaultValue matches an option's value", () => {
const options = [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
{ label: "Option 3", value: "option3" },
];
const defaultValue = "option2";
const onSelect = vi.fn();
render(<Dropdown options={options} defaultValue={defaultValue} onSelect={onSelect} />);
// Assert that the displayed label matches the expected default option's label.
expect(screen.getByText("Option 2")).toBeInTheDocument();
});
test("should update the selected option when a new option is clicked", async () => {
const options = [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
{ label: "Option 3", value: "option3" },
];
const defaultValue = "option1";
const onSelect = vi.fn();
render(<Dropdown options={options} defaultValue={defaultValue} onSelect={onSelect} />);
// Open the dropdown. We don't have a specific test id, so we'll grab the button by its text content.
await userEvent.click(screen.getByText("Option 1"));
// Click on "Option 2"
await userEvent.click(screen.getByText("Option 2"));
// Assert that the displayed label has been updated to "Option 2".
expect(screen.getByText("Option 2")).toBeInTheDocument();
});
test("should call onSelect with the correct option when an option is selected", async () => {
const options = [
{ label: "Option A", value: "a" },
{ label: "Option B", value: "b" },
{ label: "Option C", value: "c" },
];
const defaultValue = "a";
const onSelect = vi.fn();
render(<Dropdown options={options} defaultValue={defaultValue} onSelect={onSelect} />);
// Open the dropdown by clicking the trigger button (the currently selected option).
await userEvent.click(screen.getByText("Option A"));
// Select "Option B"
await userEvent.click(screen.getByText("Option B"));
// Assert that onSelect is called with the correct option
expect(onSelect).toHaveBeenCalledWith(options[1]);
});
test("should display the correct label and icon for the selected option", () => {
const options = [
{ label: "Number", value: "number", icon: HashIcon },
{ label: "Star", value: "star" },
];
const defaultValue = "number";
const onSelect = vi.fn();
render(<Dropdown options={options} defaultValue={defaultValue} onSelect={onSelect} />);
// Assert that the displayed label matches the expected default option's label.
expect(screen.getByText("Number")).toBeInTheDocument();
// Assert that the icon is present
expect(screen.getByText("Number").previousSibling).toHaveClass("lucide-hash");
});
test("should disable all options when the disabled prop is true", async () => {
const options = [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
];
const defaultValue = "option1";
const onSelect = vi.fn();
render(<Dropdown options={options} defaultValue={defaultValue} disabled={true} onSelect={onSelect} />);
// Open the dropdown
const button = screen.getByRole("button");
await userEvent.click(button);
const menuItems = screen.getAllByRole("menuitem");
const option1MenuItem = menuItems.find((item) => item.textContent === "Option 1");
const option2MenuItem = menuItems.find((item) => item.textContent === "Option 2");
expect(option1MenuItem).toHaveAttribute("data-disabled", "");
expect(option2MenuItem).toHaveAttribute("data-disabled", "");
});
test("should disable individual options when the option's disabled property is true", async () => {
const options = [
{ label: "Option 1", value: "option1", disabled: true },
{ label: "Option 2", value: "option2" },
];
const defaultValue = "option2";
const onSelect = vi.fn();
render(<Dropdown options={options} defaultValue={defaultValue} onSelect={onSelect} />);
// Open the dropdown
const button = screen.getByRole("button");
await userEvent.click(button);
const menuItems = screen.getAllByRole("menuitem");
const option1MenuItem = menuItems.find((item) => item.textContent === "Option 1");
const option2MenuItem = menuItems.find((item) => item.textContent === "Option 2");
expect(option1MenuItem).toHaveAttribute("data-disabled", "");
expect(option2MenuItem).not.toHaveAttribute("data-disabled", "");
});
test("should fall back to the first option when defaultValue does not match any option", () => {
const options = [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
{ label: "Option 3", value: "option3" },
];
const defaultValue = "nonexistent";
const onSelect = vi.fn();
render(<Dropdown options={options} defaultValue={defaultValue} onSelect={onSelect} />);
// Assert that the displayed label matches the first option's label.
expect(screen.getByText("Option 1")).toBeInTheDocument();
});
test("should handle dynamic updates to options prop and maintain a valid selection", () => {
const initialOptions = [
{ label: "Option A", value: "a" },
{ label: "Option B", value: "b" },
];
const defaultValue = "a";
const onSelect = vi.fn();
const { rerender } = render(
<Dropdown options={initialOptions} defaultValue={defaultValue} onSelect={onSelect} />
);
expect(screen.getByText("Option A")).toBeInTheDocument();
const updatedOptions = [
{ label: "Option C", value: "c" },
{ label: "Option A", value: "a" },
];
rerender(<Dropdown options={updatedOptions} defaultValue={defaultValue} onSelect={onSelect} />);
expect(screen.getByText("Option A")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,360 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { RecontactOptionsCard } from "./recontact-options-card";
// Mock window.matchMedia
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
// Mock @formkit/auto-animate - simplify implementation
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null, {}],
}));
describe("RecontactOptionsCard", () => {
afterEach(() => {
cleanup();
});
test("should render correctly when localSurvey.type is not 'link'", () => {
const mockSurvey = {
id: "test-survey",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "test-env",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
runOnDate: null,
questions: [],
endings: [],
hiddenFields: {
enabled: false,
fieldIds: [],
},
} as unknown as TSurvey;
const setLocalSurvey = vi.fn();
const environmentId = "test-env";
render(
<RecontactOptionsCard
localSurvey={mockSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
);
expect(screen.getByText("environments.surveys.edit.recontact_options")).toBeVisible();
});
test("should not render when localSurvey.type is 'link'", () => {
const mockSurvey = {
id: "test-survey",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "test-env",
type: "link",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
runOnDate: null,
questions: [],
endings: [],
hiddenFields: {
enabled: false,
fieldIds: [],
},
} as unknown as TSurvey;
const setLocalSurvey = vi.fn();
const environmentId = "test-env";
const { container } = render(
<RecontactOptionsCard
localSurvey={mockSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
);
expect(container.firstChild).toBeNull();
});
test("should update recontactDays in localSurvey when handleRecontactDaysChange is called with a valid input", async () => {
const mockSurvey = {
id: "test-survey",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "test-env",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: 1,
displayLimit: null,
runOnDate: null,
questions: [],
endings: [],
hiddenFields: {
enabled: false,
fieldIds: [],
},
} as unknown as TSurvey;
const setLocalSurvey = vi.fn();
const environmentId = "test-env";
render(
<RecontactOptionsCard
localSurvey={mockSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
);
const trigger = screen.getByText("environments.surveys.edit.recontact_options");
await userEvent.click(trigger);
const inputElement = screen.getByRole("spinbutton") as HTMLInputElement;
fireEvent.change(inputElement, { target: { value: "5" } });
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
expect(setLocalSurvey).toHaveBeenCalledWith({
...mockSurvey,
recontactDays: 5,
});
});
test("should update displayLimit in localSurvey when handleRecontactSessionDaysChange is called with a valid input", async () => {
const mockSurvey = {
id: "test-survey",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "test-env",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displaySome",
recontactDays: null,
displayLimit: 1,
runOnDate: null,
questions: [],
endings: [],
hiddenFields: {
enabled: false,
fieldIds: [],
},
} as unknown as TSurvey;
const setLocalSurvey = vi.fn();
const environmentId = "test-env";
render(
<RecontactOptionsCard
localSurvey={mockSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
);
const cardTrigger = screen.getByText("environments.surveys.edit.recontact_options");
await userEvent.click(cardTrigger);
const inputElement = screen.getByRole("spinbutton");
await userEvent.clear(inputElement);
await userEvent.type(inputElement, "5");
expect(setLocalSurvey).toHaveBeenCalledTimes(2);
expect(vi.mocked(setLocalSurvey).mock.calls[1][0]).toEqual({
...mockSurvey,
displayLimit: 5,
});
});
test("should update displayOption in localSurvey when a RadioGroup option is selected", async () => {
const mockSurvey = {
id: "test-survey",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "test-env",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
runOnDate: null,
questions: [],
endings: [],
hiddenFields: {
enabled: false,
fieldIds: [],
},
} as unknown as TSurvey;
const setLocalSurvey = vi.fn();
const environmentId = "test-env";
const user = userEvent.setup();
render(
<RecontactOptionsCard
localSurvey={mockSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
);
// Click on the accordion trigger to open it
const accordionTrigger = screen.getByText("environments.surveys.edit.recontact_options");
await user.click(accordionTrigger);
// Find the radio button for "displayMultiple" and click it
const displayMultipleRadioButton = document.querySelector('button[value="displayMultiple"]');
if (!displayMultipleRadioButton) {
throw new Error("Radio button with value 'displayMultiple' not found");
}
await user.click(displayMultipleRadioButton);
// Assert that setLocalSurvey is called with the updated displayOption
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
expect(setLocalSurvey).toHaveBeenCalledWith({
...mockSurvey,
displayOption: "displayMultiple",
});
});
test("should initialize displayLimit when switching to 'displaySome' with undefined initial value", async () => {
const mockSurvey = {
id: "test-survey",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
status: "draft",
environmentId: "test-env",
type: "app",
welcomeCard: {
enabled: true,
timeToFinish: false,
headline: { default: "Welcome" },
buttonLabel: { default: "Start" },
showResponseCount: false,
},
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
recontactDays: null,
displayLimit: undefined, // Initial displayLimit is undefined
runOnDate: null,
questions: [],
endings: [],
hiddenFields: {
enabled: false,
fieldIds: [],
},
} as unknown as TSurvey;
const setLocalSurvey = vi.fn();
const environmentId = "test-env";
render(
<RecontactOptionsCard
localSurvey={mockSurvey}
setLocalSurvey={setLocalSurvey}
environmentId={environmentId}
/>
);
// First click the card trigger to expand the content
const cardTrigger = document.getElementById("recontactOptionsCardTrigger");
if (!cardTrigger) {
throw new Error("Card trigger not found");
}
await userEvent.click(cardTrigger);
const displaySomeRadio = screen.getByText("environments.surveys.edit.show_multiple_times"); // Find the 'displaySome' radio button
await userEvent.click(displaySomeRadio);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
expect(setLocalSurvey).toHaveBeenCalledWith(
expect.objectContaining({
displayOption: "displaySome",
displayLimit: 1,
})
);
});
});

View File

@@ -121,7 +121,7 @@ export const RecontactOptionsCard = ({
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50"
id="recontactOptionsCardTrigger">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<div className="flex items-center pr-5 pl-2">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
@@ -256,7 +256,7 @@ export const RecontactOptionsCard = ({
id="inputDays"
value={inputDays === 0 ? 1 : inputDays}
onChange={handleRecontactDaysChange}
className="ml-2 mr-2 inline w-16 bg-white text-center text-sm"
className="mr-2 ml-2 inline w-16 bg-white text-center text-sm"
/>
{t("environments.surveys.edit.days_before_showing_this_survey_again")}.
</p>

View File

@@ -0,0 +1,246 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
import { RedirectUrlForm } from "./redirect-url-form";
describe("RedirectUrlForm", () => {
afterEach(() => {
cleanup();
});
test("should render the URL input field with the placeholder 'https://formbricks.com' and the value derived from `endingCard.url`", () => {
const mockLocalSurvey = {
id: "survey1",
name: "Test Survey",
questions: [],
endings: [],
type: "nps",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
followUps: [],
} as unknown as TSurvey;
const mockEndingCard: TSurveyRedirectUrlCard = {
id: "ending1",
type: "redirectToUrl",
url: "https://example.com",
label: "Example",
};
const mockUpdateSurvey = vi.fn();
render(
<RedirectUrlForm
localSurvey={mockLocalSurvey}
endingCard={mockEndingCard}
updateSurvey={mockUpdateSurvey}
/>
);
const urlInput = screen.getByPlaceholderText("https://formbricks.com");
expect(urlInput).toBeInTheDocument();
expect((urlInput as HTMLInputElement).value).toBe("https://example.com");
});
test("should render the label input field with the placeholder 'Formbricks App' and the value derived from `endingCard.label`", () => {
const mockLocalSurvey = {
id: "survey1",
name: "Test Survey",
questions: [],
endings: [],
type: "nps",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
followUps: [],
} as unknown as TSurvey;
const mockEndingCard: TSurveyRedirectUrlCard = {
id: "ending1",
type: "redirectToUrl",
url: "https://example.com",
label: "Example Label",
};
const mockUpdateSurvey = vi.fn();
render(
<RedirectUrlForm
localSurvey={mockLocalSurvey}
endingCard={mockEndingCard}
updateSurvey={mockUpdateSurvey}
/>
);
const labelInput = screen.getByPlaceholderText("Formbricks App");
expect(labelInput).toBeInTheDocument();
expect((labelInput as HTMLInputElement).value).toBe("Example Label");
});
test("should call `updateSurvey` with the updated URL value when the URL input field value changes", async () => {
const mockLocalSurvey = {
id: "survey1",
name: "Test Survey",
questions: [],
endings: [],
type: "nps",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
followUps: [],
} as unknown as TSurvey;
const mockEndingCard: TSurveyRedirectUrlCard = {
id: "ending1",
type: "redirectToUrl",
url: "https://example.com",
label: "Example",
};
const mockUpdateSurvey = vi.fn();
render(
<RedirectUrlForm
localSurvey={mockLocalSurvey}
endingCard={mockEndingCard}
updateSurvey={mockUpdateSurvey}
/>
);
const urlInput = screen.getByPlaceholderText("https://formbricks.com");
expect(urlInput).toBeInTheDocument();
const newUrl = "https://new-example.com";
await userEvent.clear(urlInput);
await userEvent.type(urlInput, newUrl);
await vi.waitFor(() => {
expect(mockUpdateSurvey).toHaveBeenCalledWith(
expect.objectContaining({
url: newUrl,
})
);
});
});
test("should handle gracefully when endingCard.url contains recall syntax referencing a question ID that doesn't exist in localSurvey", () => {
const mockLocalSurvey = {
id: "survey1",
name: "Test Survey",
questions: [],
endings: [],
type: "app",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
followUps: [],
hiddenFields: { fieldIds: [], enabled: false },
variables: [],
} as unknown as TSurvey;
const mockEndingCard: TSurveyRedirectUrlCard = {
id: "ending1",
type: "redirectToUrl",
url: "#recall:nonexistent-question-id/fallback:default#",
label: "Example",
};
const mockUpdateSurvey = vi.fn();
render(
<RedirectUrlForm
localSurvey={mockLocalSurvey}
endingCard={mockEndingCard}
updateSurvey={mockUpdateSurvey}
/>
);
const urlInput = screen.getByPlaceholderText("https://formbricks.com");
expect(urlInput).toBeInTheDocument();
expect((urlInput as HTMLInputElement).value).toBe("@nonexistent-question-id");
});
test("should handle malformed recall syntax in endingCard.url without breaking the UI", () => {
const mockLocalSurvey = {
id: "survey1",
name: "Test Survey",
questions: [],
endings: [],
variables: [],
type: "nps",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
followUps: [],
hiddenFields: [],
} as unknown as TSurvey;
const mockEndingCard: TSurveyRedirectUrlCard = {
id: "ending1",
type: "redirectToUrl",
url: "#recall:invalid_id", // Malformed recall syntax
label: "Example",
};
const mockUpdateSurvey = vi.fn();
render(
<RedirectUrlForm
localSurvey={mockLocalSurvey}
endingCard={mockEndingCard}
updateSurvey={mockUpdateSurvey}
/>
);
const urlInput = screen.getByPlaceholderText("https://formbricks.com");
expect(urlInput).toBeInTheDocument();
expect((urlInput as HTMLInputElement).value).toBe("#recall:invalid_id");
});
test("should handle gracefully when inputRef.current is null in onAddFallback", () => {
const mockLocalSurvey = {
id: "survey1",
name: "Test Survey",
questions: [],
endings: [],
type: "nps",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
followUps: [],
} as unknown as TSurvey;
const mockEndingCard: TSurveyRedirectUrlCard = {
id: "ending1",
type: "redirectToUrl",
url: "https://example.com",
label: "Example",
};
const mockUpdateSurvey = vi.fn();
const mockInputRef = { current: null };
vi.spyOn(React, "useRef").mockReturnValue(mockInputRef);
render(
<RedirectUrlForm
localSurvey={mockLocalSurvey}
endingCard={mockEndingCard}
updateSurvey={mockUpdateSurvey}
/>
);
// We can't directly access the onAddFallback function, so we'll simulate the scenario
// where inputRef.current is null and verify that no error is thrown.
// This is achieved by mocking useRef to return an object with current: null.
// No need to simulate a button click. The component should handle the null ref internally.
// We can assert that the component renders without throwing an error in this state.
expect(() => {
screen.getByText("common.url"); // Just check if the component renders without error
}).not.toThrow();
});
});

View File

@@ -0,0 +1,174 @@
import * as Collapsible from "@radix-ui/react-collapsible";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ResponseOptionsCard } from "./response-options-card";
vi.mock("react-hot-toast");
describe("ResponseOptionsCard", () => {
beforeEach(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
});
afterEach(() => {
cleanup();
});
test("should initialize open state to true when localSurvey.type is 'link'", () => {
const localSurvey: TSurvey = {
id: "1",
name: "Test Survey",
type: "link",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TSurvey;
const MockResponseOptionsCard = () => {
const [open, setOpen] = useState(localSurvey.type === "link");
return (
<Collapsible.Root open={open} onOpenChange={setOpen} data-testid="response-options-collapsible">
<div>Response Options</div>
</Collapsible.Root>
);
};
render(<MockResponseOptionsCard />);
const collapsibleRoot = screen.getByTestId("response-options-collapsible");
expect(collapsibleRoot).toHaveAttribute("data-state", "open");
});
test("should set runOnDateToggle to true when handleRunOnDateToggle is called and runOnDateToggle is false", async () => {
const localSurvey: TSurvey = {
id: "1",
name: "Test Survey",
type: "link",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
runOnDate: null,
} as unknown as TSurvey;
const setLocalSurvey = vi.fn();
render(
<ResponseOptionsCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} responseCount={0} />
);
const runOnDateToggle = screen.getByText("environments.surveys.edit.release_survey_on_date");
await userEvent.click(runOnDateToggle);
// Check if the switch element is checked after clicking
const runOnDateSwitch = screen.getByRole("switch", { name: /release_survey_on_date/i });
expect(runOnDateSwitch).toHaveAttribute("data-state", "checked");
});
test("should not correct the invalid autoComplete value when it is less than or equal to responseCount after blur", async () => {
const localSurvey: TSurvey = {
id: "1",
name: "Test Survey",
type: "link",
autoComplete: 3,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TSurvey;
const setLocalSurvey = vi.fn();
const responseCount = 5;
render(
<ResponseOptionsCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
responseCount={responseCount}
/>
);
const inputElement = screen.getByRole("spinbutton");
expect(inputElement).toBeInTheDocument();
await userEvent.clear(inputElement);
await userEvent.type(inputElement, "3");
fireEvent.blur(inputElement);
expect(toast.error).toHaveBeenCalled();
expect((inputElement as HTMLInputElement).value).toBe("3");
});
test("should reset surveyClosedMessage to null when toggled off and on", async () => {
const initialSurvey: TSurvey = {
id: "1",
name: "Test Survey",
type: "link",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
surveyClosedMessage: {
heading: "Custom Heading",
subheading: "Custom Subheading",
},
} as unknown as TSurvey;
let updatedSurvey: TSurvey | null = null;
const setLocalSurveyMock = (survey: TSurvey | ((TSurvey) => TSurvey)) => {
if (typeof survey === "function") {
updatedSurvey = survey(initialSurvey);
} else {
updatedSurvey = survey;
}
};
const MockResponseOptionsCard = () => {
const [localSurvey, _] = useState(initialSurvey); // NOSONAR // It's fine for the test
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(
!!localSurvey.surveyClosedMessage
);
const handleCloseSurveyMessageToggle = () => {
setSurveyClosedMessageToggle((prev) => !prev); // NOSONAR // It's fine for the test
if (surveyClosedMessageToggle && localSurvey.surveyClosedMessage) {
setLocalSurveyMock((prevSurvey: TSurvey) => ({ ...prevSurvey, surveyClosedMessage: null })); // NOSONAR // It's fine for the test
}
};
return (
<div>
<button data-testid="toggle-button" onClick={handleCloseSurveyMessageToggle}>
Toggle Survey Closed Message
</button>
</div>
);
};
render(<MockResponseOptionsCard />);
const toggleButton = screen.getByTestId("toggle-button");
// Toggle off
await userEvent.click(toggleButton);
// Toggle on
await userEvent.click(toggleButton);
if (updatedSurvey) {
expect((updatedSurvey as TSurvey).surveyClosedMessage).toBeNull();
}
});
});

View File

@@ -0,0 +1,313 @@
import { ActionClass } from "@prisma/client";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { SavedActionsTab } from "./saved-actions-tab";
describe("SavedActionsTab", () => {
afterEach(() => {
cleanup();
});
test("categorizes actionClasses into codeActions and noCodeActions and displays them under the correct headings", () => {
const actionClasses: ActionClass[] = [
{
id: "1",
createdAt: new Date(),
updatedAt: new Date(),
name: "No Code Action",
description: "Description for No Code Action",
type: "noCode",
environmentId: "env1",
} as unknown as ActionClass,
{
id: "2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Code Action",
description: "Description for Code Action",
type: "code",
environmentId: "env1",
} as unknown as ActionClass,
];
const localSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
questions: [],
triggers: [],
environmentId: "env1",
status: "draft",
} as any;
const setLocalSurvey = vi.fn();
const setOpen = vi.fn();
render(
<SavedActionsTab
actionClasses={actionClasses}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setOpen={setOpen}
/>
);
// Check if the headings are present
expect(screen.getByText("common.no_code")).toBeInTheDocument();
expect(screen.getByText("common.code")).toBeInTheDocument();
// Check if the actions are rendered under the correct headings
expect(screen.getByText("No Code Action")).toBeInTheDocument();
expect(screen.getByText("Code Action")).toBeInTheDocument();
});
test("updates localSurvey and closes the modal when an action is clicked", async () => {
const actionClasses: ActionClass[] = [
{
id: "1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action One",
description: "Description for Action One",
type: "noCode",
environmentId: "env1",
} as unknown as ActionClass,
];
const initialSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
questions: [],
triggers: [],
environmentId: "env1",
status: "draft",
} as any;
const setLocalSurvey = vi.fn();
const setOpen = vi.fn();
render(
<SavedActionsTab
actionClasses={actionClasses}
localSurvey={initialSurvey}
setLocalSurvey={setLocalSurvey}
setOpen={setOpen}
/>
);
const actionElement = screen.getByText("Action One");
await userEvent.click(actionElement);
expect(setLocalSurvey).toHaveBeenCalledTimes(1);
// Check that the function passed to setLocalSurvey returns the expected object
const updateFunction = setLocalSurvey.mock.calls[0][0];
const result = updateFunction(initialSurvey);
expect(result).toEqual(
expect.objectContaining({
...initialSurvey,
triggers: expect.arrayContaining([
expect.objectContaining({
actionClass: actionClasses[0],
}),
]),
})
);
expect(setOpen).toHaveBeenCalledTimes(1);
expect(setOpen).toHaveBeenCalledWith(false);
});
test("displays 'No saved actions found' message when no actions are available", () => {
const actionClasses: ActionClass[] = [];
const localSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
questions: [],
triggers: [],
environmentId: "env1",
status: "draft",
} as any;
const setLocalSurvey = vi.fn();
const setOpen = vi.fn();
render(
<SavedActionsTab
actionClasses={actionClasses}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setOpen={setOpen}
/>
);
const noActionsMessage = screen.getByText("No saved actions found");
expect(noActionsMessage).toBeInTheDocument();
});
test("filters actionClasses correctly with special characters, diacritics, and non-Latin scripts", async () => {
const user = userEvent.setup();
const actionClasses: ActionClass[] = [
{
id: "1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action with éàçüö",
description: "Description for Action One",
type: "noCode",
environmentId: "env1",
} as unknown as ActionClass,
{
id: "2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Действие Два",
description: "Description for Action Two",
type: "code",
environmentId: "env1",
} as unknown as ActionClass,
{
id: "3",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action with !@#$",
description: "Description for Another Action",
type: "noCode",
environmentId: "env1",
} as unknown as ActionClass,
];
const localSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
questions: [],
triggers: [],
environmentId: "env1",
status: "draft",
} as any;
const setLocalSurvey = vi.fn();
const setOpen = vi.fn();
render(
<SavedActionsTab
actionClasses={actionClasses}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setOpen={setOpen}
/>
);
const searchInput = screen.getByPlaceholderText("Search actions");
// Simulate user typing "éàçüö" in the search field
await user.type(searchInput, "éàçüö");
// Check if "Action with éàçüö" is present
expect(screen.getByText("Action with éàçüö")).toBeInTheDocument();
// Check if other actions are not present
expect(screen.queryByText("Действие Два")).toBeNull();
expect(screen.queryByText("Action with !@#$")).toBeNull();
});
test("filters actionClasses based on user input in the search field and updates the displayed actions", async () => {
const user = userEvent.setup();
const actionClasses: ActionClass[] = [
{
id: "1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action One",
description: "Description for Action One",
type: "noCode",
environmentId: "env1",
} as unknown as ActionClass,
{
id: "2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action Two",
description: "Description for Action Two",
type: "code",
environmentId: "env1",
} as unknown as ActionClass,
{
id: "3",
createdAt: new Date(),
updatedAt: new Date(),
name: "Another Action",
description: "Description for Another Action",
type: "noCode",
environmentId: "env1",
} as unknown as ActionClass,
];
const localSurvey: TSurvey = {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
questions: [],
triggers: [],
environmentId: "env1",
status: "draft",
} as any;
const setLocalSurvey = vi.fn();
const setOpen = vi.fn();
render(
<SavedActionsTab
actionClasses={actionClasses}
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
setOpen={setOpen}
/>
);
const searchInput = screen.getByPlaceholderText("Search actions");
// Simulate user typing "One" in the search field
await user.type(searchInput, "One");
// Check if "Action One" is present
expect(screen.getByText("Action One")).toBeInTheDocument();
// Check if "Action Two" and "Another Action" are not present
expect(screen.queryByText("Action Two")).toBeNull();
expect(screen.queryByText("Another Action")).toBeNull();
// Clear the search input
await user.clear(searchInput);
// Simulate user typing "Two" in the search field
await user.type(searchInput, "Two");
// Check if "Action Two" is present
expect(screen.getByText("Action Two")).toBeInTheDocument();
// Check if "Action One" and "Another Action" are not present
expect(screen.queryByText("Action One")).toBeNull();
expect(screen.queryByText("Another Action")).toBeNull();
// Clear the search input
await user.clear(searchInput);
// Simulate user typing "Another" in the search field
await user.type(searchInput, "Another");
// Check if "Another Action" is present
expect(screen.getByText("Another Action")).toBeInTheDocument();
// Check if "Action One" and "Action Two" are not present
expect(screen.queryByText("Action One")).toBeNull();
expect(screen.queryByText("Action Two")).toBeNull();
});
});

View File

@@ -64,7 +64,7 @@ export const SavedActionsTab = ({
(actions, i) =>
actions.length > 0 && (
<div key={i} className="me-4">
<h2 className="mb-2 mt-4 font-semibold">
<h2 className="mt-4 mb-2 font-semibold">
{i === 0 ? t("common.no_code") : t("common.code")}
</h2>
<div className="flex flex-col gap-2">

View File

@@ -0,0 +1,128 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SurveyEditorTabs } from "./survey-editor-tabs";
describe("SurveyEditorTabs", () => {
afterEach(() => {
cleanup();
});
test("should exclude the settings tab when isCxMode is true", () => {
render(
<SurveyEditorTabs
activeId="questions"
setActiveId={vi.fn()}
isStylingTabVisible={true}
isCxMode={true}
isSurveyFollowUpsAllowed={true}
/>
);
expect(screen.getByText("common.questions")).toBeInTheDocument();
expect(screen.getByText("common.styling")).toBeInTheDocument();
expect(screen.queryByText("common.settings")).toBeNull();
expect(screen.getByText("environments.surveys.edit.follow_ups")).toBeInTheDocument();
});
test("should mark the follow-ups tab as a pro feature when isSurveyFollowUpsAllowed is false", () => {
render(
<SurveyEditorTabs
activeId="questions"
setActiveId={vi.fn()}
isStylingTabVisible={true}
isCxMode={false}
isSurveyFollowUpsAllowed={false}
/>
);
const followUpsTab = screen.getByText("environments.surveys.edit.follow_ups");
expect(followUpsTab.closest("button")).toHaveTextContent("PRO");
});
test("should render all tabs including the styling tab when isStylingTabVisible is true", () => {
render(
<SurveyEditorTabs
activeId="questions"
setActiveId={vi.fn()}
isStylingTabVisible={true}
isCxMode={false}
isSurveyFollowUpsAllowed={true}
/>
);
expect(screen.getByText("common.questions")).toBeInTheDocument();
expect(screen.getByText("common.styling")).toBeInTheDocument();
expect(screen.getByText("common.settings")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.follow_ups")).toBeInTheDocument();
});
test("should update the active tab ID when a tab is clicked", async () => {
const setActiveId = vi.fn();
render(
<SurveyEditorTabs
activeId="questions"
setActiveId={setActiveId}
isStylingTabVisible={true}
isCxMode={false}
isSurveyFollowUpsAllowed={true}
/>
);
const stylingTab = screen.getByText("common.styling");
await userEvent.click(stylingTab);
expect(setActiveId).toHaveBeenCalledWith("styling");
});
test("should handle activeId set to styling when isStylingTabVisible is false", () => {
render(
<SurveyEditorTabs
activeId="styling"
setActiveId={vi.fn()}
isStylingTabVisible={false}
isCxMode={false}
isSurveyFollowUpsAllowed={true}
/>
);
expect(screen.queryByText("common.styling")).toBeNull();
const questionsTab = screen.getByText("common.questions");
expect(questionsTab).toBeInTheDocument();
});
test("should handle activeId set to settings when isCxMode is true", () => {
render(
<SurveyEditorTabs
activeId="settings"
setActiveId={vi.fn()}
isStylingTabVisible={true}
isCxMode={true}
isSurveyFollowUpsAllowed={true}
/>
);
expect(screen.queryByText("common.settings")).toBeNull();
const questionsTab = screen.getByText("common.questions");
expect(questionsTab).toBeInTheDocument();
});
test("should render only the questions and followUps tabs when isStylingTabVisible is false, isCxMode is true and isSurveyFollowUpsAllowed is false", () => {
render(
<SurveyEditorTabs
activeId="questions"
setActiveId={vi.fn()}
isStylingTabVisible={false}
isCxMode={true}
isSurveyFollowUpsAllowed={false}
/>
);
expect(screen.getByText("common.questions")).toBeInTheDocument();
expect(screen.queryByText("common.styling")).toBeNull();
expect(screen.queryByText("common.settings")).toBeNull();
expect(screen.getByText("environments.surveys.edit.follow_ups")).toBeInTheDocument();
});
});

View File

@@ -88,6 +88,7 @@ export default defineConfig({
"modules/organization/settings/teams/actions.ts",
"modules/organization/settings/api-keys/lib/**/*.ts",
"app/api/v1/**/*.ts",
"app/api/cron/**/*.ts",
"modules/api/v2/management/auth/*.ts",
"modules/organization/settings/api-keys/components/*.tsx",
"modules/survey/hooks/*.tsx",
@@ -117,6 +118,19 @@ export default defineConfig({
"lib/surveyLogic/utils.ts",
"lib/utils/billing.ts",
"modules/ui/components/card/index.tsx",
"modules/survey/editor/components/open-question-form.tsx",
"modules/survey/editor/components/picture-selection-form.tsx",
"modules/survey/editor/components/placement.tsx",
"modules/survey/editor/components/question-card.tsx",
"modules/survey/editor/components/questions-droppable.tsx",
"modules/survey/editor/components/ranking-question-form.tsx",
"modules/survey/editor/components/rating-question-form.tsx",
"modules/survey/editor/components/rating-type-dropdown.tsx",
"modules/survey/editor/components/recontact-options-card.tsx",
"modules/survey/editor/components/redirect-url-form.tsx",
"modules/survey/editor/components/response-options-card.tsx",
"modules/survey/editor/components/saved-actions-tab.tsx",
"modules/survey/editor/components/survey-editor-tabs.tsx",
"modules/survey/editor/components/nps-question-form.tsx",
"modules/survey/editor/components/create-new-action-tab.tsx",
"modules/survey/editor/components/logic-editor-actions.tsx",
@@ -132,10 +146,6 @@ export default defineConfig({
"modules/survey/editor/components/logic-editor.tsx",
"modules/survey/editor/components/multiple-choice-question-form.tsx",
"lib/fileValidation.ts",
"survey/editor/lib/utils.tsx",
"modules/ui/components/card/index.tsx",
"modules/ui/components/card/index.tsx",
"modules/survey/editor/lib/utils.tsx",
"modules/survey/editor/components/add-action-modal.tsx",
"modules/survey/editor/components/add-ending-card-button.tsx",
"modules/survey/editor/components/add-question-button.tsx",

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -286,6 +286,19 @@
"pages": [
"self-hosting/advanced/migration",
"self-hosting/advanced/license",
{
"group": "Enterprise Features",
"icon": "building",
"pages": [
"self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
"self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
"self-hosting/advanced/enterprise-features/team-access",
"self-hosting/advanced/enterprise-features/contact-management-segments",
"self-hosting/advanced/enterprise-features/multi-language-surveys",
"self-hosting/advanced/enterprise-features/oidc-sso",
"self-hosting/advanced/enterprise-features/saml-sso"
]
},
"self-hosting/advanced/rate-limiting"
]
},

View File

@@ -0,0 +1,8 @@
---
title: "Contact management & segments"
description: "Create and manage contacts and attribute-based segments with Formbricks."
icon: "address-book"
sidebarTitle: "Contacts & Segments"
---
Contacts are helpful if you want to assign response to specific contacts or users of your mobile application. Also, if you'd like to do [attribute-based targeting](/xm-and-surveys/surveys/website-app-surveys/advanced-targeting) of specific cohorts or segments of your user base you need this feature.

View File

@@ -0,0 +1,8 @@
---
title: "Hide 'Powered by Formbricks' signature"
description: "Hide our brand signature for a more white-labeled experience."
sidebarTitle: "Hide Branding"
icon: "eye-slash"
---
Remove the 'Powered by Formbricks' signature in both Link Surveys and In-product surveys as well as on emails sent out via Formbricks.

View File

@@ -0,0 +1,7 @@
---
title: "Multi-language Surveys"
description: "Survey respondents in multiple-languages."
icon: "language"
---
If you'd like to survey users in multiple languages while keeping all results in the same survey, you can make use of [Multi-language Surveys](/xm-and-surveys/surveys/general-features/multi-language-surveys#multi-language-surveys)

View File

@@ -0,0 +1,7 @@
---
title: "OAuth & SSO"
description: "Configure Single Sign-On with your Formbricks instance."
icon: "key"
---
Implement enterprise-grade authentication for your survey platform with [Open ID connect, Azure AD OAuth, Google OAuth, and SAML](/self-hosting/configuration/auth-sso)

View File

@@ -0,0 +1,7 @@
---
title: "SAML SSO"
description: "Configure Single Sign-On with SAML for your Formbricks instance."
icon: "key"
---
Implement enterprise-grade authentication for your survey platform using [SAML SSO](/self-hosting/configuration/auth-sso/saml-sso#saml-sso).

View File

@@ -0,0 +1,15 @@
---
title: "Teams & Roles (RBAC)"
description: "Granularly control which users have access to specific projects and surveys."
icon: "user"
---
Each User can be a member of any number of Teams.
Each Team can have Read, Write or Manage access to any number of Projects.
In the Community Edition, each User has Admin rights.
In the Enterprise Edition, you can granularly assign Roles and Memberships to users.
Here are [all details of user management](https://formbricks.com/docs/xm-and-surveys/core-features/user-management#memberships)

View File

@@ -0,0 +1,9 @@
---
title: "White-label Follow-ups"
description: "Add you own logo to emails sent to respondents"
icon: "envelope"
---
With the Enterprise Edition, you can customize the [Follow-up Emails](/xm-and-surveys/core-features/email-customization) sent to respondents.
<Note>Emails sent to the coworkers of your organisation (e.g. invitations to join a Formbricks team) remain Formbricks branded.</Note>

View File

@@ -51,7 +51,7 @@ describe("delay", () => {
test("resolves after specified ms", async () => {
const start = Date.now();
await delay(50);
expect(Date.now() - start).toBeGreaterThanOrEqual(50);
expect(Date.now() - start).toBeGreaterThanOrEqual(49); // Using 49 to account for execution time
});
});