mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
8 Commits
response-q
...
investigat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bdb818995 | ||
|
|
20466c3800 | ||
|
|
faf6c2d062 | ||
|
|
a760a3c341 | ||
|
|
94e6d2f215 | ||
|
|
a6f1c0f63d | ||
|
|
c653996cbb | ||
|
|
da44fef89d |
@@ -218,3 +218,6 @@ UNKEY_ROOT_KEY=
|
||||
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
|
||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Disable the user management from UI
|
||||
# DISABLE_USER_MANAGEMENT
|
||||
3
.github/copilot-instructions.md
vendored
3
.github/copilot-instructions.md
vendored
@@ -10,8 +10,9 @@ When generating test files inside the "/app/web" path, follow these rules:
|
||||
- Follow the same test pattern used for other files in the package where the file is located
|
||||
- All imports should be at the top of the file, not inside individual tests
|
||||
- For mocking inside "test" blocks use "vi.mocked"
|
||||
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file
|
||||
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
|
||||
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
||||
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
|
||||
|
||||
If it's a test for a ".tsx" file, follow these extra instructions:
|
||||
|
||||
|
||||
@@ -46,6 +46,13 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
with:
|
||||
|
||||
@@ -33,6 +33,13 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
with:
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -3,7 +3,7 @@ Copyright (c) 2024 Formbricks GmbH
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under the "apps/web/modules/ee" directory of this repository, if these directories exist, is licensed under the license defined in "apps/web/modules/ee/LICENSE".
|
||||
- All content that resides under the "packages/js/", "packages/react-native/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All content that resides under the "packages/js/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
EXPO_PUBLIC_APP_URL=http://192.168.0.197:3000
|
||||
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cm5p0cs7r000819182b32j0a1
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/react.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
};
|
||||
35
apps/demo-react-native/.gitignore
vendored
35
apps/demo-react-native/.gitignore
vendored
@@ -1,35 +0,0 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#ffffff",
|
||||
"foregroundImage": "./assets/adaptive-icon.png"
|
||||
}
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"icon": "./assets/icon.png",
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Take pictures for certain activities.",
|
||||
"NSMicrophoneUsageDescription": "Need microphone access for recording videos.",
|
||||
"NSPhotoLibraryUsageDescription": "Select pictures for certain activities."
|
||||
},
|
||||
"supportsTablet": true
|
||||
},
|
||||
"jsEngine": "hermes",
|
||||
"name": "react-native-demo",
|
||||
"newArchEnabled": true,
|
||||
"orientation": "portrait",
|
||||
"slug": "react-native-demo",
|
||||
"splash": {
|
||||
"backgroundColor": "#ffffff",
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain"
|
||||
},
|
||||
"userInterfaceStyle": "light",
|
||||
"version": "1.0.0",
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
@@ -1,6 +0,0 @@
|
||||
module.exports = function babel(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { registerRootComponent } from "expo";
|
||||
import { LogBox } from "react-native";
|
||||
import App from "./src/app";
|
||||
|
||||
registerRootComponent(App);
|
||||
|
||||
LogBox.ignoreAllLogs();
|
||||
@@ -1,21 +0,0 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const path = require("node:path");
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
// Find the workspace root, this can be replaced with `find-yarn-workspace-root`
|
||||
const workspaceRoot = path.resolve(__dirname, "../..");
|
||||
const projectRoot = __dirname;
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// 1. Watch all files within the monorepo
|
||||
config.watchFolders = [workspaceRoot];
|
||||
// 2. Let Metro know where to resolve packages, and in what order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(workspaceRoot, "node_modules"),
|
||||
];
|
||||
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
|
||||
config.resolver.disableHierarchicalLookup = true;
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@formbricks/demo-react-native",
|
||||
"version": "1.0.0",
|
||||
"main": "./index.js",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"eject": "expo eject",
|
||||
"clean": "rimraf .turbo node_modules .expo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/react-native": "workspace:*",
|
||||
"@react-native-async-storage/async-storage": "2.1.0",
|
||||
"expo": "52.0.28",
|
||||
"expo-status-bar": "2.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.6",
|
||||
"react-native-webview": "13.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.0",
|
||||
"@types/react": "18.3.18",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import React, { type JSX } from "react";
|
||||
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
|
||||
import Formbricks, {
|
||||
logout,
|
||||
setAttribute,
|
||||
setAttributes,
|
||||
setLanguage,
|
||||
setUserId,
|
||||
track,
|
||||
} from "@formbricks/react-native";
|
||||
|
||||
LogBox.ignoreAllLogs();
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
if (!process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID) {
|
||||
throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required");
|
||||
}
|
||||
|
||||
if (!process.env.EXPO_PUBLIC_APP_URL) {
|
||||
throw new Error("EXPO_PUBLIC_APP_URL is required");
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Formbricks React Native SDK Demo</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}>
|
||||
<Button
|
||||
title="Trigger Code Action"
|
||||
onPress={() => {
|
||||
track("code").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error tracking event:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Id"
|
||||
onPress={() => {
|
||||
setUserId("random-user-id").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user id:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Attributess (multiple)"
|
||||
onPress={() => {
|
||||
setAttributes({
|
||||
testAttr: "attr-test",
|
||||
testAttr2: "attr-test-2",
|
||||
testAttr3: "attr-test-3",
|
||||
testAttr4: "attr-test-4",
|
||||
}).catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user attributes:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Attributes (single)"
|
||||
onPress={() => {
|
||||
setAttribute("testSingleAttr", "testSingleAttr").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user attributes:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Logout"
|
||||
onPress={() => {
|
||||
logout().catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error logging out:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set Language (de)"
|
||||
onPress={() => {
|
||||
setLanguage("de").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting language:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<StatusBar style="auto" />
|
||||
|
||||
<Formbricks
|
||||
appUrl={process.env.EXPO_PUBLIC_APP_URL as string}
|
||||
environmentId={process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fff",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
},
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
@@ -3,13 +3,13 @@ module.exports = {
|
||||
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["lib/messages/**/*.json"],
|
||||
files: ["locales/*.json"],
|
||||
plugins: ["i18n-json"],
|
||||
rules: {
|
||||
"i18n-json/identical-keys": [
|
||||
"error",
|
||||
{
|
||||
filePath: require("path").join(__dirname, "messages", "en-US.json"),
|
||||
filePath: require("path").join(__dirname, "locales", "en-US.json"),
|
||||
checkExtraKeys: false,
|
||||
checkMissingKeys: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { updateResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
@@ -11,6 +12,20 @@ export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse("Response", responseId, true);
|
||||
}
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message, undefined, true);
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error({ error, url }, `Error in ${endpoint}`);
|
||||
return responses.internalServerErrorResponse(error.message, true);
|
||||
}
|
||||
return responses.internalServerErrorResponse("Unknown error occurred", true);
|
||||
};
|
||||
|
||||
export const PUT = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ responseId: string }> }
|
||||
@@ -23,7 +38,6 @@ export const PUT = async (
|
||||
}
|
||||
|
||||
const responseUpdate = await request.json();
|
||||
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
@@ -39,19 +53,8 @@ export const PUT = async (
|
||||
try {
|
||||
response = await updateResponse(responseId, inputValidation.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse("Response", responseId, true);
|
||||
}
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error(
|
||||
{ error, url: request.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
// get survey to get environmentId
|
||||
@@ -59,16 +62,12 @@ export const PUT = async (
|
||||
try {
|
||||
survey = await getSurvey(response.surveyId);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
logger.error(
|
||||
{ error, url: request.url },
|
||||
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
|
||||
);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(response.data, survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response", undefined, true);
|
||||
}
|
||||
|
||||
// send response update to pipeline
|
||||
@@ -87,7 +86,7 @@ export const PUT = async (
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: survey.id,
|
||||
response: response,
|
||||
response,
|
||||
});
|
||||
}
|
||||
return responses.successResponse({}, true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -86,6 +87,10 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseInputData.data, survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response");
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInput["meta"] = {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
|
||||
import { validateLocalSignedUrl } from "@/lib/crypto";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { putFileToLocalStorage } from "@/lib/storage/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -86,8 +87,14 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
|
||||
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
|
||||
// validate signature
|
||||
// Perform server-side file validation again
|
||||
// This is crucial as attackers could bypass the initial validation and directly call this endpoint
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType });
|
||||
}
|
||||
|
||||
// validate signature
|
||||
const validated = validateLocalSignedUrl(
|
||||
signedUuid,
|
||||
fileName,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getBiggerUploadFileSizePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -28,7 +29,6 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
|
||||
const environmentId = params.environmentId;
|
||||
|
||||
const jsonInput = await req.json();
|
||||
|
||||
const inputValidation = ZUploadFileRequest.safeParse({
|
||||
...jsonInput,
|
||||
environmentId,
|
||||
@@ -44,6 +44,12 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
|
||||
|
||||
const { fileName, fileType, surveyId } = inputValidation.data;
|
||||
|
||||
// Perform server-side file validation
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file", { fileName, fileType }, true);
|
||||
}
|
||||
|
||||
const [survey, organization] = await Promise.all([
|
||||
getSurvey(surveyId),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { deleteResponse, getResponse, updateResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
@@ -26,7 +27,7 @@ async function fetchAndAuthorizeResponse(
|
||||
return { error: responses.unauthorizedResponse() };
|
||||
}
|
||||
|
||||
return { response };
|
||||
return { response, survey };
|
||||
}
|
||||
|
||||
export const GET = async (
|
||||
@@ -86,6 +87,10 @@ export const PUT = async (
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
}
|
||||
|
||||
if (!validateFileUploads(responseUpdate.data, result.survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response");
|
||||
}
|
||||
|
||||
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { getResponses } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
|
||||
|
||||
export const GET = async (request: NextRequest) => {
|
||||
@@ -47,72 +48,85 @@ export const GET = async (request: NextRequest) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = async (request: Request): Promise<Response> => {
|
||||
const validateInput = async (request: Request) => {
|
||||
let jsonInput;
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
jsonInput = await request.json();
|
||||
} catch (err) {
|
||||
logger.error({ error: err, url: request.url }, "Error parsing JSON input");
|
||||
return { error: responses.badRequestResponse("Malformed JSON input, please check your request body") };
|
||||
}
|
||||
|
||||
let jsonInput;
|
||||
|
||||
try {
|
||||
jsonInput = await request.json();
|
||||
} catch (err) {
|
||||
logger.error({ error: err, url: request.url }, "Error parsing JSON input");
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
}
|
||||
|
||||
const inputValidation = ZResponseInput.safeParse(jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
const inputValidation = ZResponseInput.safeParse(jsonInput);
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
error: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const responseInput = inputValidation.data;
|
||||
return { data: inputValidation.data };
|
||||
};
|
||||
|
||||
const environmentId = responseInput.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInput.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
|
||||
}
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return responses.badRequestResponse(
|
||||
const validateSurvey = async (responseInput: TResponseInput, environmentId: string) => {
|
||||
const survey = await getSurvey(responseInput.surveyId);
|
||||
if (!survey) {
|
||||
return { error: responses.notFoundResponse("Survey", responseInput.surveyId, true) };
|
||||
}
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return {
|
||||
error: responses.badRequestResponse(
|
||||
"Survey is part of another environment",
|
||||
{
|
||||
"survey.environmentId": survey.environmentId,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
);
|
||||
),
|
||||
};
|
||||
}
|
||||
return { survey };
|
||||
};
|
||||
|
||||
export const POST = async (request: Request): Promise<Response> => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
const inputResult = await validateInput(request);
|
||||
if (inputResult.error) return inputResult.error;
|
||||
|
||||
const responseInput = inputResult.data;
|
||||
const environmentId = responseInput.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const surveyResult = await validateSurvey(responseInput, environmentId);
|
||||
if (surveyResult.error) return surveyResult.error;
|
||||
|
||||
if (!validateFileUploads(responseInput.data, surveyResult.survey.questions)) {
|
||||
return responses.badRequestResponse("Invalid file upload response");
|
||||
}
|
||||
|
||||
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
|
||||
if (responseInput.createdAt && !responseInput.updatedAt) {
|
||||
responseInput.updatedAt = responseInput.createdAt;
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
response = await createResponse(inputValidation.data);
|
||||
const response = await createResponse(responseInput);
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
} else {
|
||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
logger.error({ error, url: request.url }, "Error in POST /api/v1/management/responses");
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY, UPLOADS_DIR } from "@/lib/constants";
|
||||
import { validateLocalSignedUrl } from "@/lib/crypto";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { putFileToLocalStorage } from "@/lib/storage/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
@@ -65,6 +66,12 @@ export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
|
||||
const fileName = decodeURIComponent(encodedFileName);
|
||||
|
||||
// Perform server-side file validation
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file");
|
||||
}
|
||||
|
||||
// validate signature
|
||||
|
||||
const validated = validateLocalSignedUrl(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { validateFile } from "@/lib/fileValidation";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
@@ -36,8 +37,15 @@ export const POST = async (req: NextRequest): Promise<Response> => {
|
||||
return responses.badRequestResponse("environmentId is required");
|
||||
}
|
||||
|
||||
// Perform server-side file validation first to block dangerous file types
|
||||
const fileValidation = validateFile(fileName, fileType);
|
||||
if (!fileValidation.valid) {
|
||||
return responses.badRequestResponse(fileValidation.error ?? "Invalid file type");
|
||||
}
|
||||
|
||||
// Also perform client-specified allowed file extensions validation if provided
|
||||
if (allowedFileExtensions?.length) {
|
||||
const fileExtension = fileName.split(".").pop();
|
||||
const fileExtension = fileName.split(".").pop()?.toLowerCase();
|
||||
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
|
||||
return responses.badRequestResponse(
|
||||
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
|
||||
|
||||
@@ -280,3 +280,5 @@ export const IS_DEVELOPMENT = env.NODE_ENV === "development";
|
||||
export const SENTRY_DSN = env.SENTRY_DSN;
|
||||
|
||||
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
|
||||
|
||||
export const DISABLE_USER_MANAGEMENT = env.DISABLE_USER_MANAGEMENT === "1";
|
||||
|
||||
@@ -110,6 +110,7 @@ export const env = createEnv({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
|
||||
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
|
||||
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
DISABLE_USER_MANAGEMENT: z.enum(["1", "0"]).optional(),
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -206,5 +207,6 @@ export const env = createEnv({
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
|
||||
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
|
||||
DISABLE_USER_MANAGEMENT: process.env.DISABLE_USER_MANAGEMENT,
|
||||
},
|
||||
});
|
||||
|
||||
316
apps/web/lib/fileValidation.test.ts
Normal file
316
apps/web/lib/fileValidation.test.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import * as storageUtils from "@/lib/storage/utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ZAllowedFileExtension } from "@formbricks/types/common";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
isAllowedFileExtension,
|
||||
isValidFileTypeForExtension,
|
||||
isValidImageFile,
|
||||
validateFile,
|
||||
validateFileUploads,
|
||||
validateSingleFile,
|
||||
} from "./fileValidation";
|
||||
|
||||
// Mock getOriginalFileNameFromUrl function
|
||||
vi.mock("@/lib/storage/utils", () => ({
|
||||
getOriginalFileNameFromUrl: vi.fn((url) => {
|
||||
// Extract filename from the URL for testing purposes
|
||||
const parts = url.split("/");
|
||||
return parts[parts.length - 1];
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("fileValidation", () => {
|
||||
describe("isAllowedFileExtension", () => {
|
||||
test("should return false for a file with no extension", () => {
|
||||
expect(isAllowedFileExtension("filename")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for a file with extension not in allowed list", () => {
|
||||
expect(isAllowedFileExtension("malicious.exe")).toBe(false);
|
||||
expect(isAllowedFileExtension("script.php")).toBe(false);
|
||||
expect(isAllowedFileExtension("config.js")).toBe(false);
|
||||
expect(isAllowedFileExtension("page.html")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for an allowed file extension", () => {
|
||||
Object.values(ZAllowedFileExtension.enum).forEach((ext) => {
|
||||
expect(isAllowedFileExtension(`file.${ext}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle case insensitivity correctly", () => {
|
||||
expect(isAllowedFileExtension("image.PNG")).toBe(true);
|
||||
expect(isAllowedFileExtension("document.PDF")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle filenames with multiple dots", () => {
|
||||
expect(isAllowedFileExtension("example.backup.pdf")).toBe(true);
|
||||
expect(isAllowedFileExtension("document.old.exe")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidFileTypeForExtension", () => {
|
||||
test("should return false for a file with no extension", () => {
|
||||
expect(isValidFileTypeForExtension("filename", "application/octet-stream")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for valid extension and MIME type combinations", () => {
|
||||
expect(isValidFileTypeForExtension("image.jpg", "image/jpeg")).toBe(true);
|
||||
expect(isValidFileTypeForExtension("image.png", "image/png")).toBe(true);
|
||||
expect(isValidFileTypeForExtension("document.pdf", "application/pdf")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for mismatched extension and MIME type", () => {
|
||||
expect(isValidFileTypeForExtension("image.jpg", "image/png")).toBe(false);
|
||||
expect(isValidFileTypeForExtension("document.pdf", "image/jpeg")).toBe(false);
|
||||
expect(isValidFileTypeForExtension("image.png", "application/pdf")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle case insensitivity correctly", () => {
|
||||
expect(isValidFileTypeForExtension("image.JPG", "image/jpeg")).toBe(true);
|
||||
expect(isValidFileTypeForExtension("image.jpg", "IMAGE/JPEG")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateFile", () => {
|
||||
test("should return valid: false when file extension is not allowed", () => {
|
||||
const result = validateFile("script.php", "application/php");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("File type not allowed");
|
||||
});
|
||||
|
||||
test("should return valid: false when file type does not match extension", () => {
|
||||
const result = validateFile("image.png", "application/pdf");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain("File type doesn't match");
|
||||
});
|
||||
|
||||
test("should return valid: true when file is allowed and type matches extension", () => {
|
||||
const result = validateFile("image.jpg", "image/jpeg");
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should return valid: true for allowed file types", () => {
|
||||
Object.values(ZAllowedFileExtension.enum).forEach((ext) => {
|
||||
// Skip testing extensions that don't have defined MIME types in the test
|
||||
if (["jpg", "png", "pdf"].includes(ext)) {
|
||||
const mimeType = ext === "jpg" ? "image/jpeg" : ext === "png" ? "image/png" : "application/pdf";
|
||||
const result = validateFile(`file.${ext}`, mimeType);
|
||||
expect(result.valid).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("should return valid: false for files with no extension", () => {
|
||||
const result = validateFile("noextension", "application/octet-stream");
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle attempts to bypass with double extension", () => {
|
||||
const result = validateFile("malicious.jpg.php", "image/jpeg");
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSingleFile", () => {
|
||||
test("should return true for allowed file extension", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg");
|
||||
expect(validateSingleFile("https://example.com/image.jpg", ["jpg", "png"])).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for disallowed file extension", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("malicious.exe");
|
||||
expect(validateSingleFile("https://example.com/malicious.exe", ["jpg", "png"])).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true when no allowed extensions are specified", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("image.jpg");
|
||||
expect(validateSingleFile("https://example.com/image.jpg")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false when file name cannot be extracted", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce(undefined);
|
||||
expect(validateSingleFile("https://example.com/unknown")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file has no extension", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockReturnValueOnce("filewithoutextension");
|
||||
expect(validateSingleFile("https://example.com/filewithoutextension", ["jpg"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateFileUploads", () => {
|
||||
test("should return true for valid file uploads in response data", () => {
|
||||
const responseData = {
|
||||
question1: ["https://example.com/storage/file1.jpg", "https://example.com/storage/file2.pdf"],
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg", "pdf"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false when file url is not a string", () => {
|
||||
const responseData = {
|
||||
question1: [123, "https://example.com/storage/file.jpg"],
|
||||
} as TResponseData;
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file urls are not in an array", () => {
|
||||
const responseData = {
|
||||
question1: "https://example.com/storage/file.jpg",
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file extension is not allowed", () => {
|
||||
const responseData = {
|
||||
question1: ["https://example.com/storage/file.exe"],
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg", "pdf"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file name cannot be extracted", () => {
|
||||
// Mock implementation to return null for this specific URL
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined);
|
||||
|
||||
const responseData = {
|
||||
question1: ["https://example.com/invalid-url"],
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file has no extension", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(
|
||||
() => "file-without-extension"
|
||||
);
|
||||
|
||||
const responseData = {
|
||||
question1: ["https://example.com/storage/file-without-extension"],
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg"],
|
||||
} as TSurveyQuestion,
|
||||
];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(false);
|
||||
});
|
||||
|
||||
test("should ignore non-fileUpload questions", () => {
|
||||
const responseData = {
|
||||
question1: ["https://example.com/storage/file.jpg"],
|
||||
question2: "Some text answer",
|
||||
};
|
||||
|
||||
const questions = [
|
||||
{
|
||||
id: "question1",
|
||||
type: "fileUpload" as const,
|
||||
allowedFileExtensions: ["jpg"],
|
||||
},
|
||||
{
|
||||
id: "question2",
|
||||
type: "text" as const,
|
||||
},
|
||||
] as TSurveyQuestion[];
|
||||
|
||||
expect(validateFileUploads(responseData, questions)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true when no questions are provided", () => {
|
||||
const responseData = {
|
||||
question1: ["https://example.com/storage/file.jpg"],
|
||||
};
|
||||
|
||||
expect(validateFileUploads(responseData)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidImageFile", () => {
|
||||
test("should return true for valid image file extensions", () => {
|
||||
expect(isValidImageFile("https://example.com/image.jpg")).toBe(true);
|
||||
expect(isValidImageFile("https://example.com/image.jpeg")).toBe(true);
|
||||
expect(isValidImageFile("https://example.com/image.png")).toBe(true);
|
||||
expect(isValidImageFile("https://example.com/image.webp")).toBe(true);
|
||||
expect(isValidImageFile("https://example.com/image.heic")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-image file extensions", () => {
|
||||
expect(isValidImageFile("https://example.com/document.pdf")).toBe(false);
|
||||
expect(isValidImageFile("https://example.com/document.docx")).toBe(false);
|
||||
expect(isValidImageFile("https://example.com/document.txt")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file name cannot be extracted", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => undefined);
|
||||
expect(isValidImageFile("https://example.com/invalid-url")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file has no extension", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(
|
||||
() => "image-without-extension"
|
||||
);
|
||||
expect(isValidImageFile("https://example.com/image-without-extension")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when file name ends with a dot", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image.");
|
||||
expect(isValidImageFile("https://example.com/image.")).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle case insensitivity correctly", () => {
|
||||
vi.mocked(storageUtils.getOriginalFileNameFromUrl).mockImplementationOnce(() => "image.JPG");
|
||||
expect(isValidImageFile("https://example.com/image.JPG")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
94
apps/web/lib/fileValidation.ts
Normal file
94
apps/web/lib/fileValidation.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { getOriginalFileNameFromUrl } from "@/lib/storage/utils";
|
||||
import { TAllowedFileExtension, ZAllowedFileExtension, mimeTypes } from "@formbricks/types/common";
|
||||
import { TResponseData } from "@formbricks/types/responses";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
/**
|
||||
* Validates if the file extension is allowed
|
||||
* @param fileName The name of the file to validate
|
||||
* @returns {boolean} True if the file extension is allowed, false otherwise
|
||||
*/
|
||||
export const isAllowedFileExtension = (fileName: string): boolean => {
|
||||
// Extract the file extension
|
||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||
if (!extension || extension === fileName.toLowerCase()) return false;
|
||||
|
||||
// Check if the extension is in the allowed list
|
||||
return Object.values(ZAllowedFileExtension.enum).includes(extension as TAllowedFileExtension);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates if the file type matches the extension
|
||||
* @param fileName The name of the file
|
||||
* @param mimeType The MIME type of the file
|
||||
* @returns {boolean} True if the file type matches the extension, false otherwise
|
||||
*/
|
||||
export const isValidFileTypeForExtension = (fileName: string, mimeType: string): boolean => {
|
||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||
if (!extension || extension === fileName.toLowerCase()) return false;
|
||||
|
||||
// Basic MIME type validation for common file types
|
||||
const mimeTypeLower = mimeType.toLowerCase();
|
||||
|
||||
// Check if the MIME type matches the expected type for this extension
|
||||
return mimeTypes[extension] === mimeTypeLower;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a file for security concerns
|
||||
* @param fileName The name of the file to validate
|
||||
* @param mimeType The MIME type of the file
|
||||
* @returns {object} An object with validation result and error message if any
|
||||
*/
|
||||
export const validateFile = (fileName: string, mimeType: string): { valid: boolean; error?: string } => {
|
||||
// Check for disallowed extensions
|
||||
if (!isAllowedFileExtension(fileName)) {
|
||||
return { valid: false, error: "File type not allowed for security reasons." };
|
||||
}
|
||||
|
||||
// Check if the file type matches the extension
|
||||
if (!isValidFileTypeForExtension(fileName, mimeType)) {
|
||||
return { valid: false, error: "File type doesn't match the file extension." };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
export const validateSingleFile = (
|
||||
fileUrl: string,
|
||||
allowedFileExtensions?: TAllowedFileExtension[]
|
||||
): boolean => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
if (!fileName) return false;
|
||||
const extension = fileName.split(".").pop();
|
||||
if (!extension) return false;
|
||||
return !allowedFileExtensions || allowedFileExtensions.includes(extension as TAllowedFileExtension);
|
||||
};
|
||||
|
||||
export const validateFileUploads = (data: TResponseData, questions?: TSurveyQuestion[]): boolean => {
|
||||
for (const key of Object.keys(data)) {
|
||||
const question = questions?.find((q) => q.id === key);
|
||||
if (!question || question.type !== TSurveyQuestionTypeEnum.FileUpload) continue;
|
||||
|
||||
const fileUrls = data[key];
|
||||
|
||||
if (!Array.isArray(fileUrls) || !fileUrls.every((url) => typeof url === "string")) return false;
|
||||
|
||||
for (const fileUrl of fileUrls) {
|
||||
if (!validateSingleFile(fileUrl, question.allowedFileExtensions)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const isValidImageFile = (fileUrl: string): boolean => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
if (!fileName || fileName.endsWith(".")) return false;
|
||||
|
||||
const extension = fileName.split(".").pop()?.toLowerCase();
|
||||
if (!extension) return false;
|
||||
|
||||
const imageExtensions = ["png", "jpeg", "jpg", "webp", "heic"];
|
||||
return imageExtensions.includes(extension);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { capturePosthogEnvironmentEvent } from "../posthogServer";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { surveyCache } from "./cache";
|
||||
import { transformPrismaSurvey } from "./utils";
|
||||
import { checkForInvalidImagesInQuestions, transformPrismaSurvey } from "./utils";
|
||||
|
||||
interface TriggerUpdate {
|
||||
create?: Array<{ actionClassId: string }>;
|
||||
@@ -337,6 +337,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
|
||||
updatedSurvey;
|
||||
|
||||
checkForInvalidImagesInQuestions(questions);
|
||||
|
||||
if (languages) {
|
||||
// Process languages update logic here
|
||||
// Extract currentLanguageIds and updatedLanguageIds
|
||||
@@ -678,6 +680,10 @@ export const createSurvey = async (
|
||||
delete data.followUps;
|
||||
}
|
||||
|
||||
if (data.questions) {
|
||||
checkForInvalidImagesInQuestions(data.questions);
|
||||
}
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
...data,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import "server-only";
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const transformPrismaSurvey = <T extends TSurvey | TJsEnvironmentStateSurvey>(
|
||||
surveyPrisma: any
|
||||
@@ -32,3 +34,25 @@ export const anySurveyHasFilters = (surveys: TSurvey[]): boolean => {
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
export const checkForInvalidImagesInQuestions = (questions: TSurveyQuestion[]) => {
|
||||
questions.forEach((question, qIndex) => {
|
||||
if (question.imageUrl && !isValidImageFile(question.imageUrl)) {
|
||||
throw new InvalidInputError(`Invalid image file in question ${String(qIndex + 1)}`);
|
||||
}
|
||||
|
||||
if (question.type === TSurveyQuestionTypeEnum.PictureSelection) {
|
||||
if (!Array.isArray(question.choices)) {
|
||||
throw new InvalidInputError(`Choices missing for question ${String(qIndex + 1)}`);
|
||||
}
|
||||
|
||||
question.choices.forEach((choice, cIndex) => {
|
||||
if (!isValidImageFile(choice.imageUrl)) {
|
||||
throw new InvalidInputError(
|
||||
`Invalid image file for choice ${String(cIndex + 1)} in question ${String(qIndex + 1)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
@@ -7,7 +8,7 @@ import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbricks/types/user";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { userCache } from "./cache";
|
||||
@@ -97,6 +98,7 @@ export const getUserByEmail = reactCache(
|
||||
// function to update a user's user
|
||||
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
|
||||
validateInputs([personId, ZId], [data, ZUserUpdateInput.partial()]);
|
||||
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
|
||||
|
||||
try {
|
||||
const updatedUser = await prisma.user.update({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
getResponse,
|
||||
updateResponse,
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
|
||||
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { z } from "zod";
|
||||
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
|
||||
@@ -115,6 +117,25 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
});
|
||||
}
|
||||
|
||||
const existingResponse = await getResponse(params.responseId);
|
||||
|
||||
if (!existingResponse.ok) {
|
||||
return handleApiError(request, existingResponse.error);
|
||||
}
|
||||
|
||||
const questionsResponse = await getSurveyQuestions(existingResponse.data.surveyId);
|
||||
|
||||
if (!questionsResponse.ok) {
|
||||
return handleApiError(request, questionsResponse.error);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(body.data, questionsResponse.data.questions)) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "response", issue: "Invalid file upload response" }],
|
||||
});
|
||||
}
|
||||
|
||||
const response = await updateResponse(params.responseId, body);
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { validateFileUploads } from "@/lib/fileValidation";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
||||
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { Response } from "@prisma/client";
|
||||
@@ -76,6 +78,18 @@ export const POST = async (request: Request) =>
|
||||
body.updatedAt = body.createdAt;
|
||||
}
|
||||
|
||||
const surveyQuestions = await getSurveyQuestions(body.surveyId);
|
||||
if (!surveyQuestions.ok) {
|
||||
return handleApiError(request, surveyQuestions.error);
|
||||
}
|
||||
|
||||
if (!validateFileUploads(body.data, surveyQuestions.data.questions)) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "response", issue: "Invalid file upload response" }],
|
||||
});
|
||||
}
|
||||
|
||||
const createResponseResult = await createResponse(environmentId, body);
|
||||
if (!createResponseResult.ok) {
|
||||
return handleApiError(request, createResponseResult.error);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { userCache } from "@/lib/user/cache";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
@@ -12,6 +13,10 @@ import { TUserCreateInput, TUserUpdateInput, ZUserEmail, ZUserUpdateInput } from
|
||||
export const updateUser = async (id: string, data: TUserUpdateInput) => {
|
||||
validateInputs([id, ZId], [data, ZUserUpdateInput.partial()]);
|
||||
|
||||
if (data.imageUrl && !isValidImageFile(data.imageUrl)) {
|
||||
throw new InvalidInputError("Invalid image file");
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import { AddFilterModal } from "@/modules/ee/contacts/segments/components/add-filter-modal";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
// Added waitFor
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
|
||||
// Mock the Modal component
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open }: { children: React.ReactNode; open: boolean }) => {
|
||||
return open ? <div>{children}</div> : null; // NOSONAR // This is a mock
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the TabBar component
|
||||
vi.mock("@/modules/ui/components/tab-bar", () => ({
|
||||
TabBar: ({
|
||||
tabs,
|
||||
activeId,
|
||||
setActiveId,
|
||||
}: {
|
||||
tabs: any[];
|
||||
activeId: string;
|
||||
setActiveId: (id: string) => void;
|
||||
}) => (
|
||||
<div>
|
||||
{tabs.map((tab) => (
|
||||
<button key={tab.id} data-testid={`tab-${tab.id}`} onClick={() => setActiveId(tab.id)}>
|
||||
{tab.label} {activeId === tab.id ? "(Active)" : ""}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock createId
|
||||
vi.mock("@paralleldrive/cuid2", () => ({
|
||||
createId: vi.fn(() => "mockCuid"),
|
||||
}));
|
||||
|
||||
const mockContactAttributeKeys: TContactAttributeKey[] = [
|
||||
{
|
||||
id: "attr1",
|
||||
key: "email",
|
||||
name: "Email Address",
|
||||
environmentId: "env1",
|
||||
} as unknown as TContactAttributeKey,
|
||||
{ id: "attr2", key: "plan", name: "Plan Type", environmentId: "env1" } as unknown as TContactAttributeKey,
|
||||
];
|
||||
|
||||
const mockSegments: TSegment[] = [
|
||||
{
|
||||
id: "seg1",
|
||||
title: "Active Users",
|
||||
description: "Users active in the last 7 days",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
environmentId: "env1",
|
||||
surveys: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "seg2",
|
||||
title: "Paying Customers",
|
||||
description: "Users with plan type 'paid'",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
environmentId: "env1",
|
||||
surveys: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "seg3",
|
||||
title: "Private Segment",
|
||||
description: "This is private",
|
||||
isPrivate: true,
|
||||
filters: [],
|
||||
environmentId: "env1",
|
||||
surveys: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
// Helper function to check filter payload
|
||||
const expectFilterPayload = (
|
||||
callArgs: any[],
|
||||
expectedType: string,
|
||||
expectedRoot: object,
|
||||
expectedQualifierOp: string,
|
||||
expectedValue: string | undefined
|
||||
) => {
|
||||
expect(callArgs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "mockCuid",
|
||||
connector: "and",
|
||||
resource: expect.objectContaining({
|
||||
id: "mockCuid",
|
||||
root: expect.objectContaining({ type: expectedType, ...expectedRoot }),
|
||||
qualifier: expect.objectContaining({ operator: expectedQualifierOp }),
|
||||
value: expectedValue,
|
||||
}),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
describe("AddFilterModal", () => {
|
||||
let onAddFilter: ReturnType<typeof vi.fn>;
|
||||
let setOpen: ReturnType<typeof vi.fn>;
|
||||
const user = userEvent.setup();
|
||||
|
||||
beforeEach(() => {
|
||||
onAddFilter = vi.fn();
|
||||
setOpen = vi.fn();
|
||||
vi.clearAllMocks(); // Clear mocks before each test
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// --- Existing Tests (Rendering, Search, Tab Switching) ---
|
||||
test("renders correctly when open", () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
// ... assertions ...
|
||||
expect(screen.getByPlaceholderText("Browse filters...")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tab-all")).toHaveTextContent("common.all (Active)");
|
||||
expect(screen.getByText("Email Address")).toBeInTheDocument();
|
||||
expect(screen.getByText("Plan Type")).toBeInTheDocument();
|
||||
expect(screen.getByText("userId")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active Users")).toBeInTheDocument();
|
||||
expect(screen.getByText("Paying Customers")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Private Segment")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("environments.segments.phone")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.segments.desktop")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render when closed", () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={false}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByPlaceholderText("Browse filters...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("filters items based on search input in 'All' tab", async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
const searchInput = screen.getByPlaceholderText("Browse filters...");
|
||||
await user.type(searchInput, "Email");
|
||||
// ... assertions ...
|
||||
expect(screen.getByText("Email Address")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Plan Type")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("switches tabs and displays correct content", async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
// Switch to Attributes tab
|
||||
const attributesTabButton = screen.getByTestId("tab-attributes");
|
||||
await user.click(attributesTabButton);
|
||||
// ... assertions ...
|
||||
expect(attributesTabButton).toHaveTextContent("environments.segments.person_and_attributes (Active)");
|
||||
expect(screen.getByText("common.user_id")).toBeInTheDocument();
|
||||
|
||||
// Switch to Segments tab
|
||||
const segmentsTabButton = screen.getByTestId("tab-segments");
|
||||
await user.click(segmentsTabButton);
|
||||
// ... assertions ...
|
||||
expect(segmentsTabButton).toHaveTextContent("common.segments (Active)");
|
||||
expect(screen.getByText("Active Users")).toBeInTheDocument();
|
||||
|
||||
// Switch to Devices tab
|
||||
const devicesTabButton = screen.getByTestId("tab-devices");
|
||||
await user.click(devicesTabButton);
|
||||
// ... assertions ...
|
||||
expect(devicesTabButton).toHaveTextContent("environments.segments.devices (Active)");
|
||||
expect(screen.getByText("environments.segments.phone")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// --- Click and Keydown Tests ---
|
||||
|
||||
const testFilterInteraction = async (
|
||||
elementFinder: () => HTMLElement,
|
||||
expectedType: string,
|
||||
expectedRoot: object,
|
||||
expectedQualifierOp: string,
|
||||
expectedValue: string | undefined
|
||||
) => {
|
||||
// Test Click
|
||||
const elementClick = elementFinder();
|
||||
await user.click(elementClick);
|
||||
expect(onAddFilter).toHaveBeenCalledTimes(1);
|
||||
expectFilterPayload(
|
||||
onAddFilter.mock.calls[0],
|
||||
expectedType,
|
||||
expectedRoot,
|
||||
expectedQualifierOp,
|
||||
expectedValue
|
||||
);
|
||||
expect(setOpen).toHaveBeenCalledWith(false);
|
||||
onAddFilter.mockClear();
|
||||
setOpen.mockClear();
|
||||
|
||||
// Test Enter Keydown
|
||||
const elementEnter = elementFinder();
|
||||
elementEnter.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onAddFilter).toHaveBeenCalledTimes(1);
|
||||
expectFilterPayload(
|
||||
onAddFilter.mock.calls[0],
|
||||
expectedType,
|
||||
expectedRoot,
|
||||
expectedQualifierOp,
|
||||
expectedValue
|
||||
);
|
||||
expect(setOpen).toHaveBeenCalledWith(false);
|
||||
onAddFilter.mockClear();
|
||||
setOpen.mockClear();
|
||||
|
||||
// Test Space Keydown
|
||||
const elementSpace = elementFinder();
|
||||
elementSpace.focus();
|
||||
await user.keyboard(" ");
|
||||
expect(onAddFilter).toHaveBeenCalledTimes(1);
|
||||
expectFilterPayload(
|
||||
onAddFilter.mock.calls[0],
|
||||
expectedType,
|
||||
expectedRoot,
|
||||
expectedQualifierOp,
|
||||
expectedValue
|
||||
);
|
||||
expect(setOpen).toHaveBeenCalledWith(false);
|
||||
onAddFilter.mockClear();
|
||||
setOpen.mockClear();
|
||||
};
|
||||
|
||||
describe("All Tab Interactions", () => {
|
||||
beforeEach(() => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Person (userId) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("userId"),
|
||||
"person",
|
||||
{ personIdentifier: "userId" },
|
||||
"equals",
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Attribute (Email Address) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Email Address"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "email" },
|
||||
"equals",
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Attribute (Plan Type) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Plan Type"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "plan" },
|
||||
"equals",
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Segment (Active Users) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Active Users"),
|
||||
"segment",
|
||||
{ segmentId: "seg1" },
|
||||
"userIsIn",
|
||||
"seg1"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Segment (Paying Customers) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Paying Customers"),
|
||||
"segment",
|
||||
{ segmentId: "seg2" },
|
||||
"userIsIn",
|
||||
"seg2"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Device (Phone) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.phone"),
|
||||
"device",
|
||||
{ deviceType: "phone" },
|
||||
"equals",
|
||||
"phone"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Device (Desktop) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.desktop"),
|
||||
"device",
|
||||
{ deviceType: "desktop" },
|
||||
"equals",
|
||||
"desktop"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Attributes Tab Interactions", () => {
|
||||
beforeEach(async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByTestId("tab-attributes"));
|
||||
await waitFor(() => expect(screen.getByTestId("tab-attributes")).toHaveTextContent("(Active)"));
|
||||
});
|
||||
|
||||
test("handles Person (userId) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByTestId("person-filter-item"), // Use testid from component
|
||||
"person",
|
||||
{ personIdentifier: "userId" },
|
||||
"equals",
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Attribute (Email Address) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Email Address"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "email" },
|
||||
"equals",
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Attribute (Plan Type) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Plan Type"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "plan" },
|
||||
"equals",
|
||||
""
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Segments Tab Interactions", () => {
|
||||
beforeEach(async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByTestId("tab-segments"));
|
||||
await waitFor(() => expect(screen.getByTestId("tab-segments")).toHaveTextContent("(Active)"));
|
||||
});
|
||||
|
||||
test("handles Segment (Active Users) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Active Users"),
|
||||
"segment",
|
||||
{ segmentId: "seg1" },
|
||||
"userIsIn",
|
||||
"seg1"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Segment (Paying Customers) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Paying Customers"),
|
||||
"segment",
|
||||
{ segmentId: "seg2" },
|
||||
"userIsIn",
|
||||
"seg2"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Devices Tab Interactions", () => {
|
||||
beforeEach(async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByTestId("tab-devices"));
|
||||
await waitFor(() => expect(screen.getByTestId("tab-devices")).toHaveTextContent("(Active)"));
|
||||
});
|
||||
|
||||
test("handles Device (Phone) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.phone"),
|
||||
"device",
|
||||
{ deviceType: "phone" },
|
||||
"equals",
|
||||
"phone"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles Device (Desktop) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.desktop"),
|
||||
"device",
|
||||
{ deviceType: "desktop" },
|
||||
"equals",
|
||||
"desktop"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Edge Case Tests ---
|
||||
test("displays 'no attributes yet' message", async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={[]} // Empty attributes
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByTestId("tab-attributes"));
|
||||
expect(await screen.findByText("environments.segments.no_attributes_yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays 'no segments yet' message", async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={[]} // Empty segments
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByTestId("tab-segments"));
|
||||
expect(await screen.findByText("environments.segments.no_segments_yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays 'no filters match' message when search yields no results", async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
const searchInput = screen.getByPlaceholderText("Browse filters...");
|
||||
await user.type(searchInput, "nonexistentfilter");
|
||||
expect(await screen.findByText("environments.segments.no_filters_yet")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -148,6 +148,8 @@ function AttributeTabContent({ contactAttributeKeys, onAddFilter, setOpen }: Att
|
||||
<div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
tabIndex={0}
|
||||
data-testid="person-filter-item"
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
@@ -186,13 +188,25 @@ function AttributeTabContent({ contactAttributeKeys, onAddFilter, setOpen }: Att
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={attributeKey.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.name ?? attributeKey.key,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<p>{attributeKey.name ?? attributeKey.key}</p>
|
||||
@@ -308,6 +322,8 @@ export function AddFilterModal({
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={attributeKey.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
@@ -315,6 +331,17 @@ export function AddFilterModal({
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<p>{attributeKey.name ?? attributeKey.key}</p>
|
||||
@@ -326,12 +353,24 @@ export function AddFilterModal({
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={personAttribute.name}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<FingerprintIcon className="h-4 w-4" />
|
||||
<p>{personAttribute.name}</p>
|
||||
@@ -343,6 +382,8 @@ export function AddFilterModal({
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={segment.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
@@ -350,6 +391,17 @@ export function AddFilterModal({
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<Users2Icon className="h-4 w-4" />
|
||||
<p>{segment.title}</p>
|
||||
@@ -361,6 +413,7 @@ export function AddFilterModal({
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={deviceType.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
@@ -368,6 +421,17 @@ export function AddFilterModal({
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<MonitorSmartphoneIcon className="h-4 w-4" />
|
||||
<span>{deviceType.name}</span>
|
||||
@@ -404,6 +468,8 @@ export function AddFilterModal({
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={segment.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
@@ -411,6 +477,17 @@ export function AddFilterModal({
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<Users2Icon className="h-4 w-4" />
|
||||
<p>{segment.title}</p>
|
||||
@@ -428,6 +505,7 @@ export function AddFilterModal({
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={deviceType.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
@@ -435,6 +513,17 @@ export function AddFilterModal({
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<MonitorSmartphoneIcon className="h-4 w-4" />
|
||||
<span>{deviceType.name}</span>
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
|
||||
import { CreateSegmentModal } from "@/modules/ee/contacts/segments/components/create-segment-modal";
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
// Import within
|
||||
import userEvent from "@testing-library/user-event";
|
||||
// Removed beforeEach
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn((_) => "Formatted error"),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/segments/actions", () => ({
|
||||
createSegmentAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock child components that are complex or have their own tests
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, setOpen, children, noPadding, closeOnOutsideClick, size, className }) =>
|
||||
open ? (
|
||||
<div data-testid="modal" className={className} data-size={size} data-nopadding={noPadding}>
|
||||
{children}
|
||||
<button data-testid="modal-close-outside" onClick={() => closeOnOutsideClick && setOpen(false)}>
|
||||
Close Outside
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("./add-filter-modal", () => ({
|
||||
AddFilterModal: ({ open, setOpen, onAddFilter }) =>
|
||||
open ? (
|
||||
<div data-testid="add-filter-modal">
|
||||
<button
|
||||
onClick={() => {
|
||||
onAddFilter({
|
||||
resource: { type: "attribute", contactAttributeKey: "userId" },
|
||||
condition: "equals",
|
||||
value: "test",
|
||||
});
|
||||
setOpen(false);
|
||||
}}>
|
||||
Add Mock Filter
|
||||
</button>
|
||||
<button onClick={() => setOpen(false)}>Close Add Filter Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("./segment-editor", () => ({
|
||||
SegmentEditor: ({ group }) => <div data-testid="segment-editor">Filters: {group.length}</div>,
|
||||
}));
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const contactAttributeKeys = [
|
||||
{ name: "userId", label: "User ID", type: "identifier" } as unknown as TContactAttributeKey,
|
||||
];
|
||||
const segments = [] as unknown as TSegment[];
|
||||
const defaultProps = {
|
||||
environmentId,
|
||||
contactAttributeKeys,
|
||||
segments,
|
||||
};
|
||||
|
||||
describe("CreateSegmentModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders create button and opens modal on click", async () => {
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const createButton = screen.getByText("common.create_segment");
|
||||
expect(createButton).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(createButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.create_segment", { selector: "h3" })).toBeInTheDocument(); // Modal title
|
||||
});
|
||||
|
||||
test("closes modal on cancel button click", async () => {
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const createButton = screen.getByText("common.create_segment");
|
||||
await userEvent.click(createButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
const cancelButton = screen.getByText("common.cancel");
|
||||
await userEvent.click(cancelButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("updates title and description state on input change", async () => {
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const createButton = screen.getByText("common.create_segment");
|
||||
await userEvent.click(createButton);
|
||||
|
||||
const titleInput = screen.getByPlaceholderText("environments.segments.ex_power_users");
|
||||
const descriptionInput = screen.getByPlaceholderText(
|
||||
"environments.segments.ex_fully_activated_recurring_users"
|
||||
);
|
||||
|
||||
await userEvent.type(titleInput, "My New Segment");
|
||||
await userEvent.type(descriptionInput, "Segment description");
|
||||
|
||||
expect(titleInput).toHaveValue("My New Segment");
|
||||
expect(descriptionInput).toHaveValue("Segment description");
|
||||
});
|
||||
|
||||
test("save button is disabled initially and when title is empty", async () => {
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const createButton = screen.getByText("common.create_segment");
|
||||
await userEvent.click(createButton);
|
||||
|
||||
const saveButton = screen.getByText("common.create_segment", { selector: "button[type='submit']" });
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const titleInput = screen.getByPlaceholderText("environments.segments.ex_power_users");
|
||||
await userEvent.type(titleInput, " "); // Empty title
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
await userEvent.clear(titleInput);
|
||||
await userEvent.type(titleInput, "Valid Title");
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("shows error toast if title is missing on save", async () => {
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const openModalButton = screen.getByRole("button", { name: "common.create_segment" });
|
||||
await userEvent.click(openModalButton);
|
||||
|
||||
// Get modal and scope queries
|
||||
const modal = await screen.findByTestId("modal");
|
||||
|
||||
// Find the save button using getByText with a specific selector within the modal
|
||||
const saveButton = within(modal).getByText("common.create_segment", {
|
||||
selector: "button[type='submit']",
|
||||
});
|
||||
|
||||
// Verify the button is disabled because the title is empty
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Attempt to click the disabled button (optional, confirms no unexpected action occurs)
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
// Ensure the action was not called, as the button click should be prevented or the handler check fails early
|
||||
expect(createSegmentAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls createSegmentAction on save with valid data", async () => {
|
||||
vi.mocked(createSegmentAction).mockResolvedValue({ data: { id: "new-segment-id" } as any });
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const createButton = screen.getByText("common.create_segment");
|
||||
await userEvent.click(createButton);
|
||||
|
||||
// Get modal and scope queries
|
||||
const modal = await screen.findByTestId("modal");
|
||||
|
||||
const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users");
|
||||
const descriptionInput = within(modal).getByPlaceholderText(
|
||||
"environments.segments.ex_fully_activated_recurring_users"
|
||||
);
|
||||
await userEvent.type(titleInput, "Power Users");
|
||||
await userEvent.type(descriptionInput, "Active users");
|
||||
|
||||
// Find the save button within the modal
|
||||
const saveButton = await within(modal).findByRole("button", {
|
||||
name: "common.create_segment",
|
||||
});
|
||||
// Button should be enabled: title is valid, filters=[] is valid.
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSegmentAction).toHaveBeenCalledWith({
|
||||
title: "Power Users",
|
||||
description: "Active users",
|
||||
isPrivate: false,
|
||||
filters: [], // Expect empty array as no filters were added
|
||||
environmentId,
|
||||
surveyId: "",
|
||||
});
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.segments.segment_saved_successfully");
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument(); // Modal should close on success
|
||||
});
|
||||
|
||||
test("shows error toast if createSegmentAction fails", async () => {
|
||||
const errorResponse = { error: { message: "API Error" } } as any; // Mock error response
|
||||
vi.mocked(createSegmentAction).mockResolvedValue(errorResponse);
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValue("Formatted API Error");
|
||||
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const createButton = screen.getByText("common.create_segment");
|
||||
await userEvent.click(createButton);
|
||||
|
||||
const titleInput = screen.getByPlaceholderText("environments.segments.ex_power_users");
|
||||
await userEvent.type(titleInput, "Fail Segment");
|
||||
|
||||
const saveButton = screen.getByText("common.create_segment", { selector: "button[type='submit']" });
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createSegmentAction).toHaveBeenCalled();
|
||||
});
|
||||
expect(getFormattedErrorMessage).toHaveBeenCalledWith(errorResponse);
|
||||
expect(toast.error).toHaveBeenCalledWith("Formatted API Error");
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument(); // Modal should stay open on error
|
||||
});
|
||||
|
||||
test("shows generic error toast if Zod parsing succeeds during save error handling", async () => {
|
||||
vi.mocked(createSegmentAction).mockRejectedValue(new Error("Network error")); // Simulate action throwing
|
||||
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const openModalButton = screen.getByRole("button", { name: "common.create_segment" }); // Get the button outside the modal first
|
||||
await userEvent.click(openModalButton);
|
||||
|
||||
// Get the modal element
|
||||
const modal = await screen.findByTestId("modal");
|
||||
|
||||
const titleInput = within(modal).getByPlaceholderText("environments.segments.ex_power_users");
|
||||
await userEvent.type(titleInput, "Generic Error Segment");
|
||||
|
||||
// DO NOT add any filters - segment.filters will remain []
|
||||
|
||||
// Use findByRole scoped within the modal to wait for the submit button to be enabled
|
||||
const saveButton = await within(modal).findByRole("button", {
|
||||
name: "common.create_segment", // Match the accessible name (text content)
|
||||
// Implicitly waits for the button to not have the 'disabled' attribute
|
||||
});
|
||||
|
||||
// Now click the enabled button
|
||||
await userEvent.click(saveButton);
|
||||
|
||||
// Wait for the expected toast message, implying the action failed and catch block ran
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
|
||||
});
|
||||
|
||||
// Now that we know the catch block ran, verify the action was called
|
||||
expect(createSegmentAction).toHaveBeenCalled();
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument(); // Modal should stay open
|
||||
});
|
||||
|
||||
test("opens AddFilterModal when 'Add Filter' button is clicked", async () => {
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const createButton = screen.getByText("common.create_segment");
|
||||
await userEvent.click(createButton);
|
||||
|
||||
expect(screen.queryByTestId("add-filter-modal")).not.toBeInTheDocument();
|
||||
const addFilterButton = screen.getByText("common.add_filter");
|
||||
await userEvent.click(addFilterButton);
|
||||
|
||||
expect(screen.getByTestId("add-filter-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("adds filter when onAddFilter is called from AddFilterModal", async () => {
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const createButton = screen.getByText("common.create_segment");
|
||||
await userEvent.click(createButton);
|
||||
|
||||
const segmentEditor = screen.getByTestId("segment-editor");
|
||||
expect(segmentEditor).toHaveTextContent("Filters: 0");
|
||||
|
||||
const addFilterButton = screen.getByText("common.add_filter");
|
||||
await userEvent.click(addFilterButton);
|
||||
|
||||
const addMockFilterButton = screen.getByText("Add Mock Filter");
|
||||
await userEvent.click(addMockFilterButton); // This calls onAddFilter in the mock
|
||||
|
||||
expect(screen.queryByTestId("add-filter-modal")).not.toBeInTheDocument(); // Modal should close
|
||||
expect(segmentEditor).toHaveTextContent("Filters: 1"); // Check if filter count increased
|
||||
});
|
||||
|
||||
test("adds second filter correctly with default connector", async () => {
|
||||
render(<CreateSegmentModal {...defaultProps} />);
|
||||
const createButton = screen.getByText("common.create_segment");
|
||||
await userEvent.click(createButton);
|
||||
|
||||
const segmentEditor = screen.getByTestId("segment-editor");
|
||||
const addFilterButton = screen.getByText("common.add_filter");
|
||||
|
||||
// Add first filter
|
||||
await userEvent.click(addFilterButton);
|
||||
await userEvent.click(screen.getByText("Add Mock Filter"));
|
||||
expect(segmentEditor).toHaveTextContent("Filters: 1");
|
||||
|
||||
// Add second filter
|
||||
await userEvent.click(addFilterButton);
|
||||
await userEvent.click(screen.getByText("Add Mock Filter"));
|
||||
expect(segmentEditor).toHaveTextContent("Filters: 2");
|
||||
});
|
||||
});
|
||||
@@ -84,12 +84,14 @@ export function CreateSegmentModal({
|
||||
|
||||
if (createSegmentResponse?.data) {
|
||||
toast.success(t("environments.segments.segment_saved_successfully"));
|
||||
handleResetState();
|
||||
router.refresh();
|
||||
setIsCreatingSegment(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createSegmentResponse);
|
||||
toast.error(errorMessage);
|
||||
setIsCreatingSegment(false);
|
||||
}
|
||||
|
||||
setIsCreatingSegment(false);
|
||||
} catch (err: any) {
|
||||
// parse the segment filters to check if they are valid
|
||||
const parsedFilters = ZSegmentFilters.safeParse(segment.filters);
|
||||
@@ -101,10 +103,6 @@ export function CreateSegmentModal({
|
||||
setIsCreatingSegment(false);
|
||||
return;
|
||||
}
|
||||
|
||||
handleResetState();
|
||||
setIsCreatingSegment(false);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const isSaveDisabled = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { EditSegmentModal } from "@/modules/ee/contacts/segments/components/edit-segment-modal";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ee/contacts/segments/components/segment-settings", () => ({
|
||||
SegmentSettings: vi.fn(() => <div>SegmentSettingsMock</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ee/contacts/segments/components/segment-activity-tab", () => ({
|
||||
SegmentActivityTab: vi.fn(() => <div>SegmentActivityTabMock</div>),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
|
||||
ModalWithTabs: vi.fn(({ open, label, description, tabs, icon }) =>
|
||||
open ? (
|
||||
<div>
|
||||
<h1>{label}</h1>
|
||||
<p>{description}</p>
|
||||
<div>{icon}</div>
|
||||
<ul>
|
||||
{tabs.map((tab) => (
|
||||
<li key={tab.title}>
|
||||
<h2>{tab.title}</h2>
|
||||
<div>{tab.children}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSegment = {
|
||||
id: "seg1",
|
||||
title: "Test Segment",
|
||||
description: "This is a test segment",
|
||||
environmentId: "env1",
|
||||
surveys: ["Survey 1", "Survey 2"],
|
||||
filters: [],
|
||||
isPrivate: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as TSegmentWithSurveyNames;
|
||||
|
||||
const defaultProps = {
|
||||
environmentId: "env1",
|
||||
open: true,
|
||||
setOpen: vi.fn(),
|
||||
currentSegment: mockSegment,
|
||||
segments: [],
|
||||
contactAttributeKeys: [],
|
||||
isContactsEnabled: true,
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
describe("EditSegmentModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly when open and contacts enabled", async () => {
|
||||
render(<EditSegmentModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Segment")).toBeInTheDocument();
|
||||
expect(screen.getByText("This is a test segment")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("SegmentActivityTabMock")).toBeInTheDocument();
|
||||
expect(screen.getByText("SegmentSettingsMock")).toBeInTheDocument();
|
||||
|
||||
const ModalWithTabsMock = vi.mocked(
|
||||
await import("@/modules/ui/components/modal-with-tabs")
|
||||
).ModalWithTabs;
|
||||
|
||||
// Check that the mock was called
|
||||
expect(ModalWithTabsMock).toHaveBeenCalled();
|
||||
|
||||
// Get the arguments of the first call
|
||||
const callArgs = ModalWithTabsMock.mock.calls[0];
|
||||
expect(callArgs).toBeDefined(); // Ensure the mock was called
|
||||
|
||||
const propsPassed = callArgs[0]; // The first argument is the props object
|
||||
|
||||
// Assert individual properties
|
||||
expect(propsPassed.open).toBe(true);
|
||||
expect(propsPassed.setOpen).toBe(defaultProps.setOpen);
|
||||
expect(propsPassed.label).toBe("Test Segment");
|
||||
expect(propsPassed.description).toBe("This is a test segment");
|
||||
expect(propsPassed.closeOnOutsideClick).toBe(false);
|
||||
expect(propsPassed.icon).toBeDefined(); // Check if icon exists
|
||||
expect(propsPassed.tabs).toHaveLength(2); // Check number of tabs
|
||||
|
||||
// Check properties of the first tab
|
||||
expect(propsPassed.tabs[0].title).toBe("common.activity");
|
||||
expect(propsPassed.tabs[0].children).toBeDefined();
|
||||
|
||||
// Check properties of the second tab
|
||||
expect(propsPassed.tabs[1].title).toBe("common.settings");
|
||||
expect(propsPassed.tabs[1].children).toBeDefined();
|
||||
});
|
||||
|
||||
test("renders correctly when open and contacts disabled", async () => {
|
||||
render(<EditSegmentModal {...defaultProps} isContactsEnabled={false} />);
|
||||
|
||||
expect(screen.getByText("Test Segment")).toBeInTheDocument();
|
||||
expect(screen.getByText("This is a test segment")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.settings")).toBeInTheDocument(); // Tab title still exists
|
||||
expect(screen.getByText("SegmentActivityTabMock")).toBeInTheDocument();
|
||||
// Check that the settings content is not rendered, which is the key behavior
|
||||
expect(screen.queryByText("SegmentSettingsMock")).not.toBeInTheDocument();
|
||||
|
||||
const ModalWithTabsMock = vi.mocked(
|
||||
await import("@/modules/ui/components/modal-with-tabs")
|
||||
).ModalWithTabs;
|
||||
const calls = ModalWithTabsMock.mock.calls;
|
||||
const lastCallArgs = calls[calls.length - 1][0]; // Get the props of the last call
|
||||
|
||||
// Check that the Settings tab was passed in props
|
||||
const settingsTab = lastCallArgs.tabs.find((tab) => tab.title === "common.settings");
|
||||
expect(settingsTab).toBeDefined();
|
||||
// The children prop will be <SettingsTab />, but its rendered output is null/empty.
|
||||
// The check above (queryByText("SegmentSettingsMock")) already confirms this.
|
||||
// No need to check settingsTab.children === null here.
|
||||
});
|
||||
|
||||
test("does not render when open is false", () => {
|
||||
render(<EditSegmentModal {...defaultProps} open={false} />);
|
||||
|
||||
expect(screen.queryByText("Test Segment")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("common.activity")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("common.settings")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { convertDateTimeStringShort } from "@/lib/time";
|
||||
import { SegmentActivityTab } from "@/modules/ee/contacts/segments/components/segment-activity-tab";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
|
||||
const mockSegmentBase: TSegment & { activeSurveys: string[]; inactiveSurveys: string[] } = {
|
||||
id: "seg123",
|
||||
title: "Test Segment",
|
||||
description: "A segment for testing",
|
||||
environmentId: "env456",
|
||||
filters: [],
|
||||
isPrivate: false,
|
||||
surveys: [],
|
||||
createdAt: new Date("2024-01-01T10:00:00.000Z"),
|
||||
updatedAt: new Date("2024-01-02T11:30:00.000Z"),
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: [],
|
||||
};
|
||||
|
||||
describe("SegmentActivityTab", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders correctly with active and inactive surveys", () => {
|
||||
const segmentWithSurveys = {
|
||||
...mockSegmentBase,
|
||||
activeSurveys: ["Active Survey 1", "Active Survey 2"],
|
||||
inactiveSurveys: ["Inactive Survey 1"],
|
||||
};
|
||||
render(<SegmentActivityTab environmentId="env456" currentSegment={segmentWithSurveys} />);
|
||||
|
||||
expect(screen.getByText("common.active_surveys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active Survey 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active Survey 2")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("common.inactive_surveys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inactive Survey 1")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("common.created_at")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(convertDateTimeStringShort(segmentWithSurveys.createdAt.toString()))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(convertDateTimeStringShort(segmentWithSurveys.updatedAt.toString()))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.segments.segment_id")).toBeInTheDocument();
|
||||
expect(screen.getByText(segmentWithSurveys.id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly with only active surveys", () => {
|
||||
const segmentOnlyActive = {
|
||||
...mockSegmentBase,
|
||||
activeSurveys: ["Active Survey Only"],
|
||||
inactiveSurveys: [],
|
||||
};
|
||||
render(<SegmentActivityTab environmentId="env456" currentSegment={segmentOnlyActive} />);
|
||||
|
||||
expect(screen.getByText("common.active_surveys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Active Survey Only")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("common.inactive_surveys")).toBeInTheDocument();
|
||||
// Check for the placeholder when no inactive surveys exist
|
||||
const inactiveSurveyElements = screen.queryAllByText("-");
|
||||
expect(inactiveSurveyElements.length).toBeGreaterThan(0); // Should find at least one '-'
|
||||
|
||||
expect(
|
||||
screen.getByText(convertDateTimeStringShort(segmentOnlyActive.createdAt.toString()))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(convertDateTimeStringShort(segmentOnlyActive.updatedAt.toString()))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(segmentOnlyActive.id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly with only inactive surveys", () => {
|
||||
const segmentOnlyInactive = {
|
||||
...mockSegmentBase,
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: ["Inactive Survey Only"],
|
||||
};
|
||||
render(<SegmentActivityTab environmentId="env456" currentSegment={segmentOnlyInactive} />);
|
||||
|
||||
expect(screen.getByText("common.active_surveys")).toBeInTheDocument();
|
||||
// Check for the placeholder when no active surveys exist
|
||||
const activeSurveyElements = screen.queryAllByText("-");
|
||||
expect(activeSurveyElements.length).toBeGreaterThan(0); // Should find at least one '-'
|
||||
|
||||
expect(screen.getByText("common.inactive_surveys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Inactive Survey Only")).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(convertDateTimeStringShort(segmentOnlyInactive.createdAt.toString()))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(convertDateTimeStringShort(segmentOnlyInactive.updatedAt.toString()))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(segmentOnlyInactive.id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly with no surveys", () => {
|
||||
const segmentNoSurveys = {
|
||||
...mockSegmentBase,
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: [],
|
||||
};
|
||||
render(<SegmentActivityTab environmentId="env456" currentSegment={segmentNoSurveys} />);
|
||||
|
||||
expect(screen.getByText("common.active_surveys")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.inactive_surveys")).toBeInTheDocument();
|
||||
|
||||
// Check for placeholders when no surveys exist
|
||||
const placeholders = screen.queryAllByText("-");
|
||||
expect(placeholders.length).toBe(2); // Should find two '-' placeholders
|
||||
|
||||
expect(
|
||||
screen.getByText(convertDateTimeStringShort(segmentNoSurveys.createdAt.toString()))
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(convertDateTimeStringShort(segmentNoSurveys.updatedAt.toString()))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(segmentNoSurveys.id)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,388 @@
|
||||
import * as segmentUtils from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TBaseFilter, TBaseFilters, TSegment } from "@formbricks/types/segment";
|
||||
import { SegmentEditor } from "./segment-editor";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("./segment-filter", () => ({
|
||||
SegmentFilter: vi.fn(({ resource }) => <div>SegmentFilter Mock: {resource.attributeKey}</div>),
|
||||
}));
|
||||
vi.mock("./add-filter-modal", () => ({
|
||||
AddFilterModal: vi.fn(({ open, setOpen }) => (
|
||||
<div>
|
||||
AddFilterModal Mock {open ? "Open" : "Closed"}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock utility functions
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof segmentUtils>();
|
||||
return {
|
||||
...actual,
|
||||
addFilterBelow: vi.fn(),
|
||||
addFilterInGroup: vi.fn(),
|
||||
createGroupFromResource: vi.fn(),
|
||||
deleteResource: vi.fn(),
|
||||
moveResource: vi.fn(),
|
||||
toggleGroupConnector: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockSetSegment = vi.fn();
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockContactAttributeKeys: TContactAttributeKey[] = [
|
||||
{ name: "email", type: "default" } as unknown as TContactAttributeKey,
|
||||
{ name: "userId", type: "default" } as unknown as TContactAttributeKey,
|
||||
];
|
||||
const mockSegments: TSegment[] = [];
|
||||
|
||||
const mockSegmentBase: TSegment = {
|
||||
id: "seg1",
|
||||
environmentId: mockEnvironmentId,
|
||||
title: "Test Segment",
|
||||
description: "A segment for testing",
|
||||
isPrivate: false,
|
||||
filters: [], // Will be populated in tests
|
||||
surveys: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const filterResource1 = {
|
||||
id: "filter1",
|
||||
attributeKey: "email",
|
||||
attributeValue: "test@example.com",
|
||||
condition: "equals",
|
||||
root: {
|
||||
connector: null,
|
||||
filterId: "filter1",
|
||||
},
|
||||
};
|
||||
|
||||
const filterResource2 = {
|
||||
id: "filter2",
|
||||
attributeKey: "userId",
|
||||
attributeValue: "user123",
|
||||
condition: "equals",
|
||||
root: {
|
||||
connector: "and",
|
||||
filterId: "filter2",
|
||||
},
|
||||
};
|
||||
|
||||
const groupResource1 = {
|
||||
id: "group1",
|
||||
connector: "and",
|
||||
resource: [
|
||||
{
|
||||
connector: null,
|
||||
resource: filterResource1,
|
||||
id: "filter1",
|
||||
},
|
||||
],
|
||||
} as unknown as TBaseFilter;
|
||||
|
||||
const groupResource2 = {
|
||||
id: "group2",
|
||||
connector: "or",
|
||||
resource: [
|
||||
{
|
||||
connector: null,
|
||||
resource: filterResource2,
|
||||
id: "filter2",
|
||||
},
|
||||
],
|
||||
} as unknown as TBaseFilter;
|
||||
|
||||
const mockGroupWithFilters = [
|
||||
{
|
||||
connector: null,
|
||||
resource: filterResource1,
|
||||
id: "filter1",
|
||||
} as unknown as TBaseFilter,
|
||||
{
|
||||
connector: "and",
|
||||
resource: filterResource2,
|
||||
id: "filter2",
|
||||
} as unknown as TBaseFilter,
|
||||
] as unknown as TBaseFilters;
|
||||
|
||||
const mockGroupWithNestedGroup = [
|
||||
{
|
||||
connector: null,
|
||||
resource: filterResource1,
|
||||
id: "filter1",
|
||||
},
|
||||
groupResource1,
|
||||
] as unknown as TBaseFilters;
|
||||
|
||||
describe("SegmentEditor", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders SegmentFilter for filter resources", () => {
|
||||
const segment = { ...mockSegmentBase, filters: mockGroupWithFilters };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={mockGroupWithFilters}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("SegmentFilter Mock: email")).toBeInTheDocument();
|
||||
expect(screen.getByText("SegmentFilter Mock: userId")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders nested SegmentEditor for group resources", () => {
|
||||
const segment = { ...mockSegmentBase, filters: mockGroupWithNestedGroup };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={mockGroupWithNestedGroup}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
// Check that both instances of the email filter are rendered
|
||||
expect(screen.getAllByText("SegmentFilter Mock: email")).toHaveLength(2);
|
||||
// Nested group rendering
|
||||
expect(screen.getByText("and")).toBeInTheDocument(); // Group connector
|
||||
expect(screen.getByText("common.add_filter")).toBeInTheDocument(); // Add filter button inside group
|
||||
});
|
||||
|
||||
test("handles connector click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectorElement = screen.getByText("and");
|
||||
await user.click(connectorElement);
|
||||
|
||||
expect(segmentUtils.toggleGroupConnector).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
groupResource1.id,
|
||||
"or"
|
||||
);
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles 'Add Filter' button click inside a group", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const addButton = screen.getByText("common.add_filter");
|
||||
await user.click(addButton);
|
||||
|
||||
expect(screen.getByText("AddFilterModal Mock Open")).toBeInTheDocument();
|
||||
// Further tests could simulate adding a filter via the modal mock if needed
|
||||
});
|
||||
|
||||
test("handles 'Add Filter Below' dropdown action", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuTrigger = screen.getByTestId("segment-editor-group-menu-trigger");
|
||||
await user.click(menuTrigger);
|
||||
const addBelowItem = await screen.findByText("environments.segments.add_filter_below"); // Changed to findByText
|
||||
await user.click(addBelowItem);
|
||||
|
||||
expect(screen.getByText("AddFilterModal Mock Open")).toBeInTheDocument();
|
||||
// Further tests could simulate adding a filter via the modal mock and check addFilterBelow call
|
||||
});
|
||||
|
||||
test("handles 'Create Group' dropdown action", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const menuTrigger = screen.getByTestId("segment-editor-group-menu-trigger"); // Use data-testid
|
||||
await user.click(menuTrigger);
|
||||
const createGroupItem = await screen.findByText("environments.segments.create_group"); // Use findByText for async rendering
|
||||
await user.click(createGroupItem);
|
||||
|
||||
expect(segmentUtils.createGroupFromResource).toHaveBeenCalledWith(expect.any(Array), groupResource1.id);
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles 'Move Up' dropdown action", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1, groupResource2] }; // Need at least two items
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1, groupResource2]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
// Target the second group's menu
|
||||
const menuTriggers = screen.getAllByTestId("segment-editor-group-menu-trigger");
|
||||
await user.click(menuTriggers[1]); // Click the second MoreVertical icon trigger
|
||||
const moveUpItem = await screen.findByText("common.move_up"); // Changed to findByText
|
||||
await user.click(moveUpItem);
|
||||
|
||||
expect(segmentUtils.moveResource).toHaveBeenCalledWith(expect.any(Array), groupResource2.id, "up");
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles 'Move Down' dropdown action", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1, groupResource2] }; // Need at least two items
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1, groupResource2]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
// Target the first group's menu
|
||||
const menuTriggers = screen.getAllByTestId("segment-editor-group-menu-trigger");
|
||||
await user.click(menuTriggers[0]); // Click the first MoreVertical icon trigger
|
||||
const moveDownItem = await screen.findByText("common.move_down"); // Changed to findByText
|
||||
await user.click(moveDownItem);
|
||||
|
||||
expect(segmentUtils.moveResource).toHaveBeenCalledWith(expect.any(Array), groupResource1.id, "down");
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles delete group button click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByTestId("delete-resource");
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(segmentUtils.deleteResource).toHaveBeenCalledWith(expect.any(Array), groupResource1.id);
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders correctly in viewOnly mode", () => {
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
viewOnly={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if interactive elements are disabled or have specific styles
|
||||
const connectorElement = screen.getByText("and");
|
||||
expect(connectorElement).toHaveClass("cursor-not-allowed");
|
||||
|
||||
const addButton = screen.getByText("common.add_filter");
|
||||
expect(addButton).toBeDisabled();
|
||||
|
||||
const menuTrigger = screen.getByTestId("segment-editor-group-menu-trigger"); // Updated selector
|
||||
expect(menuTrigger).toBeDisabled();
|
||||
|
||||
const deleteButton = screen.getByTestId("delete-resource");
|
||||
expect(deleteButton).toBeDisabled();
|
||||
expect(deleteButton.querySelector("svg")).toHaveClass("cursor-not-allowed"); // Check icon style
|
||||
});
|
||||
|
||||
test("does not call handlers in viewOnly mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
viewOnly={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Attempt to click connector
|
||||
const connectorElement = screen.getByText("and");
|
||||
await user.click(connectorElement);
|
||||
expect(segmentUtils.toggleGroupConnector).not.toHaveBeenCalled();
|
||||
|
||||
// Attempt to click add filter
|
||||
const addButton = screen.getByText("common.add_filter");
|
||||
await user.click(addButton);
|
||||
// Modal should not open
|
||||
expect(screen.queryByText("AddFilterModal Mock Open")).not.toBeInTheDocument();
|
||||
|
||||
// Attempt to click delete
|
||||
const deleteButton = screen.getByTestId("delete-resource");
|
||||
await user.click(deleteButton);
|
||||
expect(segmentUtils.deleteResource).not.toHaveBeenCalled();
|
||||
|
||||
// Dropdown menu trigger is disabled, so no need to test clicking items inside
|
||||
});
|
||||
});
|
||||
@@ -205,7 +205,7 @@ export function SegmentEditor({
|
||||
|
||||
<div className="flex items-center gap-2 p-4">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger disabled={viewOnly}>
|
||||
<DropdownMenuTrigger data-testid="segment-editor-group-menu-trigger" disabled={viewOnly}>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -246,6 +246,7 @@ export function SegmentEditor({
|
||||
|
||||
<Button
|
||||
className="p-0"
|
||||
data-testid="delete-resource"
|
||||
disabled={viewOnly}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
import { SegmentFilter } from "@/modules/ee/contacts/segments/components/segment-filter";
|
||||
import * as segmentUtils from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
// Added fireEvent
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import {
|
||||
TSegment,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
|
||||
// Mock ResizeObserver
|
||||
const ResizeObserverMock = vi.fn(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.stubGlobal("ResizeObserver", ResizeObserverMock);
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/utils/strings", () => ({
|
||||
isCapitalized: vi.fn((str) => str === "Email"),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/utils", () => ({
|
||||
convertOperatorToText: vi.fn((op) => op),
|
||||
convertOperatorToTitle: vi.fn((op) => op),
|
||||
toggleFilterConnector: vi.fn(),
|
||||
updateContactAttributeKeyInFilter: vi.fn(),
|
||||
updateDeviceTypeInFilter: vi.fn(),
|
||||
updateFilterValue: vi.fn(),
|
||||
updateOperatorInFilter: vi.fn(),
|
||||
updatePersonIdentifierInFilter: vi.fn(),
|
||||
updateSegmentIdInFilter: vi.fn(),
|
||||
getOperatorOptions: vi.fn(() => []),
|
||||
validateFilterValue: vi.fn(() => ({ isValid: true, message: "" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, disabled, ...props }: any) => (
|
||||
<button onClick={onClick} disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children, disabled }: { children: React.ReactNode; disabled?: boolean }) => (
|
||||
<button data-testid="dropdown-trigger" disabled={disabled}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dropdown-content">{children}</div>
|
||||
),
|
||||
DropdownMenuItem: ({ children, onClick, icon }: any) => (
|
||||
<button onClick={onClick}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Remove the mock for Input component
|
||||
|
||||
vi.mock("./add-filter-modal", () => ({
|
||||
AddFilterModal: ({ open, setOpen, onAddFilter }: any) =>
|
||||
open ? (
|
||||
<div data-testid="add-filter-modal">
|
||||
<span>Add Filter Modal</span>
|
||||
<button onClick={() => onAddFilter({})}>Add</button>
|
||||
<hr />
|
||||
<button onClick={() => setOpen(false)}>Close</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
ArrowDownIcon: () => <div data-testid="arrow-down-icon">ArrowDown</div>,
|
||||
ArrowUpIcon: () => <div data-testid="arrow-up-icon">ArrowUp</div>,
|
||||
FingerprintIcon: () => <div data-testid="fingerprint-icon">Fingerprint</div>,
|
||||
MonitorSmartphoneIcon: () => <div data-testid="monitor-icon">Monitor</div>,
|
||||
MoreVertical: () => <div data-testid="more-vertical-icon">MoreVertical</div>,
|
||||
TagIcon: () => <div data-testid="tag-icon">Tag</div>,
|
||||
Trash2: () => <div data-testid="trash-icon">Trash</div>,
|
||||
Users2Icon: () => <div data-testid="users-icon">Users</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSetSegment = vi.fn();
|
||||
const mockHandleAddFilterBelow = vi.fn();
|
||||
const mockOnCreateGroup = vi.fn();
|
||||
const mockOnDeleteFilter = vi.fn();
|
||||
const mockOnMoveFilter = vi.fn();
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const segment = {
|
||||
id: "seg1",
|
||||
environmentId,
|
||||
title: "Test Segment",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
surveys: ["survey1"],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as TSegment;
|
||||
const segments: TSegment[] = [
|
||||
segment,
|
||||
{
|
||||
id: "seg2",
|
||||
environmentId,
|
||||
title: "Another Segment",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
surveys: ["survey1"],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as TSegment,
|
||||
];
|
||||
const contactAttributeKeys: TContactAttributeKey[] = [
|
||||
{
|
||||
id: "attr1",
|
||||
key: "email",
|
||||
name: "Email",
|
||||
environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as TContactAttributeKey,
|
||||
{
|
||||
id: "attr2",
|
||||
key: "userId",
|
||||
name: "User ID",
|
||||
environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as TContactAttributeKey,
|
||||
{
|
||||
id: "attr3",
|
||||
key: "plan",
|
||||
name: "Plan",
|
||||
environmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as TContactAttributeKey,
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
environmentId,
|
||||
segment,
|
||||
segments,
|
||||
contactAttributeKeys,
|
||||
setSegment: mockSetSegment,
|
||||
handleAddFilterBelow: mockHandleAddFilterBelow,
|
||||
onCreateGroup: mockOnCreateGroup,
|
||||
onDeleteFilter: mockOnDeleteFilter,
|
||||
onMoveFilter: mockOnMoveFilter,
|
||||
};
|
||||
|
||||
describe("SegmentFilter", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Remove the implementation that modifies baseProps.segment during the test.
|
||||
// vi.clearAllMocks() in afterEach handles mock reset.
|
||||
});
|
||||
|
||||
describe("Attribute Filter", () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test("renders correctly", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
expect(screen.getByText("and")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText("Email").closest("button")).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText("equals").closest("button")).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue("test@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("trash-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders attribute key select correctly", async () => {
|
||||
const currentProps = { ...baseProps, segment: structuredClone(segmentWithAttributeFilter) };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Email").closest("button")).toBeInTheDocument());
|
||||
|
||||
expect(vi.mocked(segmentUtils.updateContactAttributeKeyInFilter)).not.toHaveBeenCalled();
|
||||
expect(mockSetSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders operator select correctly", async () => {
|
||||
const currentProps = { ...baseProps, segment: structuredClone(segmentWithAttributeFilter) };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("equals").closest("button")).toBeInTheDocument());
|
||||
|
||||
expect(vi.mocked(segmentUtils.updateOperatorInFilter)).not.toHaveBeenCalled();
|
||||
expect(mockSetSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles value change", async () => {
|
||||
const initialSegment = structuredClone(segmentWithAttributeFilter);
|
||||
const currentProps = { ...baseProps, segment: initialSegment, setSegment: mockSetSegment };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
const valueInput = screen.getByDisplayValue("test@example.com");
|
||||
|
||||
// Clear the input
|
||||
await userEvent.clear(valueInput);
|
||||
// Fire a single change event with the final value
|
||||
fireEvent.change(valueInput, { target: { value: "new@example.com" } });
|
||||
|
||||
// Check the call to the update function (might be called once or twice by checkValueAndUpdate)
|
||||
await waitFor(() => {
|
||||
// Check if it was called AT LEAST once with the correct final value
|
||||
expect(vi.mocked(segmentUtils.updateFilterValue)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
attributeFilterResource.id,
|
||||
"new@example.com"
|
||||
);
|
||||
});
|
||||
|
||||
// Ensure the state update function was called
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders viewOnly mode correctly", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
render(
|
||||
<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} viewOnly={true} />
|
||||
);
|
||||
expect(screen.getByText("and")).toHaveClass("cursor-not-allowed");
|
||||
await waitFor(() => expect(screen.getByText("Email").closest("button")).toBeDisabled());
|
||||
await waitFor(() => expect(screen.getByText("equals").closest("button")).toBeDisabled());
|
||||
expect(screen.getByDisplayValue("test@example.com")).toBeDisabled();
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeDisabled();
|
||||
expect(screen.getByTestId("trash-icon").closest("button")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Person Filter", () => {
|
||||
const personFilterResource: TSegmentPersonFilter = {
|
||||
id: "filter-person-1",
|
||||
root: { type: "person", personIdentifier: "userId" },
|
||||
qualifier: { operator: "equals" },
|
||||
value: "person123",
|
||||
};
|
||||
const segmentWithPersonFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-1", connector: "and", resource: personFilterResource }],
|
||||
};
|
||||
|
||||
test("renders correctly", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithPersonFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="or" resource={personFilterResource} />);
|
||||
expect(screen.getByText("or")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText("userId").closest("button")).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText("equals").closest("button")).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue("person123")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders operator select correctly", async () => {
|
||||
const currentProps = { ...baseProps, segment: structuredClone(segmentWithPersonFilter) };
|
||||
render(<SegmentFilter {...currentProps} connector="or" resource={personFilterResource} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("equals").closest("button")).toBeInTheDocument());
|
||||
|
||||
expect(vi.mocked(segmentUtils.updateOperatorInFilter)).not.toHaveBeenCalled();
|
||||
expect(mockSetSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles value change", async () => {
|
||||
const initialSegment = structuredClone(segmentWithPersonFilter);
|
||||
const currentProps = { ...baseProps, segment: initialSegment, setSegment: mockSetSegment };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="or" resource={personFilterResource} />);
|
||||
const valueInput = screen.getByDisplayValue("person123");
|
||||
|
||||
// Clear the input
|
||||
await userEvent.clear(valueInput);
|
||||
// Fire a single change event with the final value
|
||||
fireEvent.change(valueInput, { target: { value: "person456" } });
|
||||
|
||||
// Check the call to the update function (might be called once or twice by checkValueAndUpdate)
|
||||
await waitFor(() => {
|
||||
// Check if it was called AT LEAST once with the correct final value
|
||||
expect(vi.mocked(segmentUtils.updateFilterValue)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
personFilterResource.id,
|
||||
"person456"
|
||||
);
|
||||
});
|
||||
// Ensure the state update function was called
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Segment Filter", () => {
|
||||
const segmentFilterResource = {
|
||||
id: "filter-segment-1",
|
||||
root: { type: "segment", segmentId: "seg2" },
|
||||
qualifier: { operator: "userIsIn" },
|
||||
} as unknown as TSegmentSegmentFilter;
|
||||
const segmentWithSegmentFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-1", connector: "and", resource: segmentFilterResource }],
|
||||
};
|
||||
|
||||
test("renders correctly", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithSegmentFilter };
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
expect(screen.getByText("environments.segments.where")).toBeInTheDocument();
|
||||
expect(screen.getByText("userIsIn")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText("Another Segment").closest("button")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
test("renders segment select correctly", async () => {
|
||||
const currentProps = { ...baseProps, segment: structuredClone(segmentWithSegmentFilter) };
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Another Segment").closest("button")).toBeInTheDocument());
|
||||
|
||||
expect(vi.mocked(segmentUtils.updateSegmentIdInFilter)).not.toHaveBeenCalled();
|
||||
expect(mockSetSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Device Filter", () => {
|
||||
const deviceFilterResource: TSegmentDeviceFilter = {
|
||||
id: "filter-device-1",
|
||||
root: { type: "device", deviceType: "desktop" },
|
||||
qualifier: { operator: "equals" },
|
||||
value: "desktop",
|
||||
};
|
||||
const segmentWithDeviceFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-1", connector: "and", resource: deviceFilterResource }],
|
||||
};
|
||||
|
||||
test("renders correctly", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithDeviceFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={deviceFilterResource} />);
|
||||
expect(screen.getByText("and")).toBeInTheDocument();
|
||||
expect(screen.getByText("Device")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText("equals").closest("button")).toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("environments.segments.desktop").closest("button")).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test("renders operator select correctly", async () => {
|
||||
const currentProps = { ...baseProps, segment: structuredClone(segmentWithDeviceFilter) };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={deviceFilterResource} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("equals").closest("button")).toBeInTheDocument());
|
||||
|
||||
expect(vi.mocked(segmentUtils.updateOperatorInFilter)).not.toHaveBeenCalled();
|
||||
expect(mockSetSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders device type select correctly", async () => {
|
||||
const currentProps = { ...baseProps, segment: structuredClone(segmentWithDeviceFilter) };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={deviceFilterResource} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("environments.segments.desktop").closest("button")).toBeInTheDocument()
|
||||
);
|
||||
|
||||
expect(vi.mocked(segmentUtils.updateDeviceTypeInFilter)).not.toHaveBeenCalled();
|
||||
expect(mockSetSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("toggles connector on click", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: { type: "attribute", contactAttributeKey: "email" },
|
||||
qualifier: { operator: "equals" },
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: structuredClone(segmentWithAttributeFilter) };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
const connectorSpan = screen.getByText("and");
|
||||
await userEvent.click(connectorSpan);
|
||||
expect(vi.mocked(segmentUtils.toggleFilterConnector)).toHaveBeenCalledWith(
|
||||
currentProps.segment.filters,
|
||||
attributeFilterResource.id,
|
||||
"or"
|
||||
);
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not toggle connector in viewOnly mode", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: { type: "attribute", contactAttributeKey: "email" },
|
||||
qualifier: { operator: "equals" },
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
|
||||
render(
|
||||
<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} viewOnly={true} />
|
||||
);
|
||||
const connectorSpan = screen.getByText("and");
|
||||
await userEvent.click(connectorSpan);
|
||||
expect(vi.mocked(segmentUtils.toggleFilterConnector)).not.toHaveBeenCalled();
|
||||
expect(mockSetSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -525,7 +525,7 @@ function PersonSegmentFilter({
|
||||
|
||||
<SelectContent>
|
||||
{operatorArr.map((operator) => (
|
||||
<SelectItem title={convertOperatorToTitle(operator.id)} value={operator.id}>
|
||||
<SelectItem title={convertOperatorToTitle(operator.id)} value={operator.id} key={operator.id}>
|
||||
{operator.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { getSurveysBySegmentId } from "@/lib/survey/service";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { SegmentTableDataRow } from "./segment-table-data-row";
|
||||
import { SegmentTableDataRowContainer } from "./segment-table-data-row-container";
|
||||
|
||||
// Mock the child component
|
||||
vi.mock("./segment-table-data-row", () => ({
|
||||
SegmentTableDataRow: vi.fn(() => <div>Mocked SegmentTableDataRow</div>),
|
||||
}));
|
||||
|
||||
// Mock the service function
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurveysBySegmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSegment: TSegment = {
|
||||
id: "seg1",
|
||||
title: "Segment 1",
|
||||
description: "Description 1",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
environmentId: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveys: [],
|
||||
};
|
||||
|
||||
const mockSegments: TSegment[] = [
|
||||
mockSegment,
|
||||
{
|
||||
id: "seg2",
|
||||
title: "Segment 2",
|
||||
description: "Description 2",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
environmentId: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveys: [],
|
||||
},
|
||||
];
|
||||
|
||||
const mockContactAttributeKeys: TContactAttributeKey[] = [
|
||||
{ key: "email", label: "Email" } as unknown as TContactAttributeKey,
|
||||
{ key: "userId", label: "User ID" } as unknown as TContactAttributeKey,
|
||||
];
|
||||
|
||||
const mockSurveys: TSurvey[] = [
|
||||
{
|
||||
id: "survey1",
|
||||
name: "Active Survey 1",
|
||||
status: "inProgress",
|
||||
type: "link",
|
||||
environmentId: "env1",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
segment: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [],
|
||||
variables: [],
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: false },
|
||||
styling: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
surveyClosedMessage: null,
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
createdBy: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
id: "survey2",
|
||||
name: "Inactive Survey 1",
|
||||
status: "draft",
|
||||
type: "link",
|
||||
environmentId: "env1",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
segment: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [],
|
||||
variables: [],
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: false },
|
||||
styling: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
surveyClosedMessage: null,
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
createdBy: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
id: "survey3",
|
||||
name: "Inactive Survey 2",
|
||||
status: "paused",
|
||||
type: "link",
|
||||
environmentId: "env1",
|
||||
questions: [],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
segment: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: [],
|
||||
variables: [],
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: false },
|
||||
styling: null,
|
||||
productOverwrites: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
surveyClosedMessage: null,
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
createdBy: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
describe("SegmentTableDataRowContainer", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("fetches surveys, processes them, filters segments, and passes correct props", async () => {
|
||||
vi.mocked(getSurveysBySegmentId).mockResolvedValue(mockSurveys);
|
||||
|
||||
const result = await SegmentTableDataRowContainer({
|
||||
currentSegment: mockSegment,
|
||||
segments: mockSegments,
|
||||
contactAttributeKeys: mockContactAttributeKeys,
|
||||
isContactsEnabled: true,
|
||||
isReadOnly: false,
|
||||
});
|
||||
|
||||
expect(getSurveysBySegmentId).toHaveBeenCalledWith(mockSegment.id);
|
||||
|
||||
expect(result.type).toBe(SegmentTableDataRow);
|
||||
expect(result.props).toEqual({
|
||||
currentSegment: {
|
||||
...mockSegment,
|
||||
activeSurveys: ["Active Survey 1"],
|
||||
inactiveSurveys: ["Inactive Survey 1", "Inactive Survey 2"],
|
||||
},
|
||||
segments: mockSegments.filter((s) => s.id !== mockSegment.id),
|
||||
contactAttributeKeys: mockContactAttributeKeys,
|
||||
isContactsEnabled: true,
|
||||
isReadOnly: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("handles case with no surveys found", async () => {
|
||||
vi.mocked(getSurveysBySegmentId).mockResolvedValue([]);
|
||||
|
||||
const result = await SegmentTableDataRowContainer({
|
||||
currentSegment: mockSegment,
|
||||
segments: mockSegments,
|
||||
contactAttributeKeys: mockContactAttributeKeys,
|
||||
isContactsEnabled: false,
|
||||
isReadOnly: true,
|
||||
});
|
||||
|
||||
expect(getSurveysBySegmentId).toHaveBeenCalledWith(mockSegment.id);
|
||||
|
||||
expect(result.type).toBe(SegmentTableDataRow);
|
||||
expect(result.props).toEqual({
|
||||
currentSegment: {
|
||||
...mockSegment,
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: [],
|
||||
},
|
||||
segments: mockSegments.filter((s) => s.id !== mockSegment.id),
|
||||
contactAttributeKeys: mockContactAttributeKeys,
|
||||
isContactsEnabled: false,
|
||||
isReadOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("handles case where getSurveysBySegmentId returns null", async () => {
|
||||
vi.mocked(getSurveysBySegmentId).mockResolvedValue(null as any);
|
||||
|
||||
const result = await SegmentTableDataRowContainer({
|
||||
currentSegment: mockSegment,
|
||||
segments: mockSegments,
|
||||
contactAttributeKeys: mockContactAttributeKeys,
|
||||
isContactsEnabled: true,
|
||||
isReadOnly: false,
|
||||
});
|
||||
|
||||
expect(getSurveysBySegmentId).toHaveBeenCalledWith(mockSegment.id);
|
||||
|
||||
expect(result.type).toBe(SegmentTableDataRow);
|
||||
expect(result.props).toEqual({
|
||||
currentSegment: {
|
||||
...mockSegment,
|
||||
activeSurveys: [],
|
||||
inactiveSurveys: [],
|
||||
},
|
||||
segments: mockSegments.filter((s) => s.id !== mockSegment.id),
|
||||
contactAttributeKeys: mockContactAttributeKeys,
|
||||
isContactsEnabled: true,
|
||||
isReadOnly: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegmentWithSurveyNames } from "@formbricks/types/segment";
|
||||
import { EditSegmentModal } from "./edit-segment-modal";
|
||||
import { SegmentTableDataRow } from "./segment-table-data-row";
|
||||
|
||||
vi.mock("./edit-segment-modal", () => ({
|
||||
EditSegmentModal: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
const mockCurrentSegment = {
|
||||
id: "seg1",
|
||||
title: "Test Segment",
|
||||
description: "This is a test segment",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
environmentId: "env1",
|
||||
surveys: ["survey1", "survey2"],
|
||||
createdAt: new Date("2023-01-15T10:00:00.000Z"),
|
||||
updatedAt: new Date("2023-01-20T12:00:00.000Z"),
|
||||
} as unknown as TSegmentWithSurveyNames;
|
||||
|
||||
const mockSegments = [mockCurrentSegment];
|
||||
const mockContactAttributeKeys = [{ key: "email", label: "Email" } as unknown as TContactAttributeKey];
|
||||
const mockIsContactsEnabled = true;
|
||||
const mockIsReadOnly = false;
|
||||
|
||||
describe("SegmentTableDataRow", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders segment data correctly", () => {
|
||||
render(
|
||||
<SegmentTableDataRow
|
||||
currentSegment={mockCurrentSegment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
isContactsEnabled={mockIsContactsEnabled}
|
||||
isReadOnly={mockIsReadOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(mockCurrentSegment.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCurrentSegment.description!)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCurrentSegment.surveys.length.toString())).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
formatDistanceToNow(mockCurrentSegment.updatedAt, {
|
||||
addSuffix: true,
|
||||
}).replace("about", "")
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(format(mockCurrentSegment.createdAt, "do 'of' MMMM, yyyy"))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens EditSegmentModal when row is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SegmentTableDataRow
|
||||
currentSegment={mockCurrentSegment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
isContactsEnabled={mockIsContactsEnabled}
|
||||
isReadOnly={mockIsReadOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("div.grid");
|
||||
expect(row).toBeInTheDocument();
|
||||
|
||||
// Initially modal should not be called with open: true
|
||||
expect(vi.mocked(EditSegmentModal)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ open: false }),
|
||||
undefined // Expect undefined as the second argument
|
||||
);
|
||||
|
||||
await user.click(row!);
|
||||
|
||||
// After click, modal should be called with open: true
|
||||
expect(vi.mocked(EditSegmentModal)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
open: true,
|
||||
currentSegment: mockCurrentSegment,
|
||||
environmentId: mockCurrentSegment.environmentId,
|
||||
segments: mockSegments,
|
||||
contactAttributeKeys: mockContactAttributeKeys,
|
||||
isContactsEnabled: mockIsContactsEnabled,
|
||||
isReadOnly: mockIsReadOnly,
|
||||
}),
|
||||
undefined // Expect undefined as the second argument
|
||||
);
|
||||
});
|
||||
|
||||
test("passes isReadOnly prop correctly to EditSegmentModal", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SegmentTableDataRow
|
||||
currentSegment={mockCurrentSegment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
isContactsEnabled={mockIsContactsEnabled}
|
||||
isReadOnly={true} // Set isReadOnly to true
|
||||
/>
|
||||
);
|
||||
|
||||
// Check initial call (open: false)
|
||||
expect(vi.mocked(EditSegmentModal)).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
open: false,
|
||||
isReadOnly: true,
|
||||
}),
|
||||
undefined // Expect undefined as the second argument
|
||||
);
|
||||
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("div.grid");
|
||||
await user.click(row!);
|
||||
|
||||
// Check second call (open: true)
|
||||
expect(vi.mocked(EditSegmentModal)).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
open: true,
|
||||
isReadOnly: true,
|
||||
}),
|
||||
undefined // Expect undefined as the second argument
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { SegmentTable } from "./segment-table";
|
||||
import { SegmentTableDataRowContainer } from "./segment-table-data-row-container";
|
||||
|
||||
// Mock the getTranslate function
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// Mock the SegmentTableDataRowContainer component
|
||||
vi.mock("./segment-table-data-row-container", () => ({
|
||||
SegmentTableDataRowContainer: vi.fn(({ currentSegment }) => (
|
||||
<div data-testid={`segment-row-${currentSegment.id}`}>{currentSegment.title}</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
const mockSegments = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Segment 1",
|
||||
description: "Description 1",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
surveyIds: ["survey1", "survey2"],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Segment 2",
|
||||
description: "Description 2",
|
||||
isPrivate: true,
|
||||
filters: [],
|
||||
surveyIds: ["survey3"],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
},
|
||||
] as unknown as TSegment[];
|
||||
|
||||
const mockContactAttributeKeys = [
|
||||
{ key: "email", label: "Email" } as unknown as TContactAttributeKey,
|
||||
{ key: "userId", label: "User ID" } as unknown as TContactAttributeKey,
|
||||
];
|
||||
|
||||
describe("SegmentTable", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders table headers", async () => {
|
||||
render(
|
||||
await SegmentTable({
|
||||
segments: [],
|
||||
contactAttributeKeys: mockContactAttributeKeys,
|
||||
isContactsEnabled: true,
|
||||
isReadOnly: false,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText("common.title")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.surveys")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.updated")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.created")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders "create your first segment" message when no segments are provided', async () => {
|
||||
render(
|
||||
await SegmentTable({
|
||||
segments: [],
|
||||
contactAttributeKeys: mockContactAttributeKeys,
|
||||
isContactsEnabled: true,
|
||||
isReadOnly: false,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText("environments.segments.create_your_first_segment")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders segment rows when segments are provided", async () => {
|
||||
render(
|
||||
await SegmentTable({
|
||||
segments: mockSegments,
|
||||
contactAttributeKeys: mockContactAttributeKeys,
|
||||
isContactsEnabled: true,
|
||||
isReadOnly: false,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.queryByText("environments.segments.create_your_first_segment")).not.toBeInTheDocument();
|
||||
expect(vi.mocked(SegmentTableDataRowContainer)).toHaveBeenCalledTimes(mockSegments.length);
|
||||
|
||||
mockSegments.forEach((segment) => {
|
||||
expect(screen.getByTestId(`segment-row-${segment.id}`)).toBeInTheDocument();
|
||||
expect(screen.getByText(segment.title)).toBeInTheDocument();
|
||||
// Check both arguments passed to the component
|
||||
expect(vi.mocked(SegmentTableDataRowContainer)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
currentSegment: segment,
|
||||
segments: mockSegments,
|
||||
contactAttributeKeys: mockContactAttributeKeys,
|
||||
isContactsEnabled: true,
|
||||
isReadOnly: false,
|
||||
}),
|
||||
undefined // Explicitly check for the second argument being undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,416 @@
|
||||
import { TargetingCard } from "@/modules/ee/contacts/segments/components/targeting-card";
|
||||
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 { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock Data (Moved from mocks.ts)
|
||||
const mockInitialSegment: TSegment = {
|
||||
id: "segment-1",
|
||||
title: "Initial Segment",
|
||||
description: "Initial segment description",
|
||||
isPrivate: false,
|
||||
filters: [
|
||||
{
|
||||
id: "base-filter-1", // ID for the base filter group/node
|
||||
connector: "and",
|
||||
resource: {
|
||||
// This holds the actual filter condition (TSegmentFilter)
|
||||
id: "segment-filter-1", // ID for the specific filter rule
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "attr1",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "value1",
|
||||
},
|
||||
},
|
||||
],
|
||||
surveys: ["survey-1"],
|
||||
environmentId: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
type: "app", // Changed from "link" to "web"
|
||||
environmentId: "test-env-id",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: 7,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayPercentage: 100,
|
||||
autoComplete: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: mockInitialSegment,
|
||||
languages: [],
|
||||
triggers: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const mockContactAttributeKeys: TContactAttributeKey[] = [
|
||||
{ id: "attr1", description: "Desc 1", type: "default" } as unknown as TContactAttributeKey,
|
||||
{ id: "attr2", description: "Desc 2", type: "default" } as unknown as TContactAttributeKey,
|
||||
];
|
||||
|
||||
const mockSegments: TSegment[] = [
|
||||
mockInitialSegment,
|
||||
{
|
||||
id: "segment-2",
|
||||
title: "Segment 2",
|
||||
description: "Segment 2 description",
|
||||
isPrivate: true,
|
||||
filters: [],
|
||||
surveys: ["survey-2"],
|
||||
environmentId: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
// End Mock Data
|
||||
|
||||
// Mock actions
|
||||
const mockCloneSegmentAction = vi.fn();
|
||||
const mockCreateSegmentAction = vi.fn();
|
||||
const mockLoadNewSegmentAction = vi.fn();
|
||||
const mockResetSegmentFiltersAction = vi.fn();
|
||||
const mockUpdateSegmentAction = vi.fn();
|
||||
|
||||
vi.mock("@/modules/ee/contacts/segments/actions", () => ({
|
||||
cloneSegmentAction: (...args) => mockCloneSegmentAction(...args),
|
||||
createSegmentAction: (...args) => mockCreateSegmentAction(...args),
|
||||
loadNewSegmentAction: (...args) => mockLoadNewSegmentAction(...args),
|
||||
resetSegmentFiltersAction: (...args) => mockResetSegmentFiltersAction(...args),
|
||||
updateSegmentAction: (...args) => mockUpdateSegmentAction(...args),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children }) => <div>{children}</div>,
|
||||
AlertDescription: ({ children }) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/alert-dialog", () => ({
|
||||
// Update the mock to render headerText
|
||||
AlertDialog: ({ children, open, headerText }) =>
|
||||
open ? (
|
||||
<div>
|
||||
AlertDialog Mock {headerText} {children}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/load-segment-modal", () => ({
|
||||
LoadSegmentModal: ({ open }) => (open ? <div>LoadSegmentModal Mock</div> : null),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/save-as-new-segment-modal", () => ({
|
||||
SaveAsNewSegmentModal: ({ open }) => (open ? <div>SaveAsNewSegmentModal Mock</div> : null),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/segment-title", () => ({
|
||||
SegmentTitle: ({ title, description }) => (
|
||||
<div>
|
||||
SegmentTitle Mock: {title} {description}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/targeting-indicator", () => ({
|
||||
TargetingIndicator: () => <div>TargetingIndicator Mock</div>,
|
||||
}));
|
||||
vi.mock("./add-filter-modal", () => ({
|
||||
AddFilterModal: ({ open }) => (open ? <div>AddFilterModal Mock</div> : null),
|
||||
}));
|
||||
vi.mock("./segment-editor", () => ({
|
||||
SegmentEditor: ({ viewOnly }) => <div>SegmentEditor Mock {viewOnly ? "(View Only)" : "(Editable)"}</div>,
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSetLocalSurvey = vi.fn();
|
||||
const environmentId = "test-env-id";
|
||||
|
||||
describe("TargetingCard", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks before each test if needed
|
||||
mockCloneSegmentAction.mockResolvedValue({ data: { ...mockInitialSegment, id: "cloned-segment-id" } });
|
||||
mockResetSegmentFiltersAction.mockResolvedValue({ data: { ...mockInitialSegment, filters: [] } });
|
||||
mockUpdateSegmentAction.mockResolvedValue({ data: mockInitialSegment });
|
||||
});
|
||||
|
||||
test("renders null for link surveys", () => {
|
||||
const linkSurvey: TSurvey = { ...mockSurvey, type: "link" };
|
||||
const { container } = render(
|
||||
<TargetingCard
|
||||
localSurvey={linkSurvey}
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={mockInitialSegment}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
test("renders correctly for web/app surveys", () => {
|
||||
render(
|
||||
<TargetingCard
|
||||
localSurvey={mockSurvey}
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={mockInitialSegment}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.segments.target_audience")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.segments.pre_segment_users")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens and closes collapsible content", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TargetingCard
|
||||
localSurvey={mockSurvey}
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={mockInitialSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially open because segment has filters
|
||||
expect(screen.getByText("TargetingIndicator Mock")).toBeVisible();
|
||||
|
||||
// Click trigger to close (assuming it's open)
|
||||
await user.click(screen.getByText("environments.segments.target_audience"));
|
||||
// Check that the element is no longer in the document
|
||||
expect(screen.queryByText("TargetingIndicator Mock")).not.toBeInTheDocument();
|
||||
|
||||
// Click trigger to open
|
||||
await user.click(screen.getByText("environments.segments.target_audience"));
|
||||
expect(screen.getByText("TargetingIndicator Mock")).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens Add Filter modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TargetingCard
|
||||
localSurvey={{ ...mockSurvey, segment: { ...mockInitialSegment, isPrivate: true } }} // Start with editor open
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={mockInitialSegment}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByText("common.add_filter"));
|
||||
expect(screen.getByText("AddFilterModal Mock")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens Load Segment modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TargetingCard
|
||||
localSurvey={mockSurvey}
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={mockInitialSegment}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByText("environments.segments.load_segment"));
|
||||
expect(screen.getByText("LoadSegmentModal Mock")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens Reset All Filters confirmation dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TargetingCard
|
||||
localSurvey={mockSurvey}
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={mockInitialSegment}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByText("environments.segments.reset_all_filters"));
|
||||
// Check that the mock container with the text exists
|
||||
expect(screen.getByText(/AlertDialog Mock\s*common.are_you_sure/)).toBeInTheDocument();
|
||||
// Use regex to find the specific text, ignoring whitespace
|
||||
expect(screen.getByText(/common\.are_you_sure/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("toggles segment editor view", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TargetingCard
|
||||
localSurvey={mockSurvey}
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={mockInitialSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially view only, editor is visible
|
||||
expect(screen.getByText("SegmentEditor Mock (View Only)")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.segments.hide_filters")).toBeInTheDocument();
|
||||
|
||||
// Click to hide filters
|
||||
await user.click(screen.getByText("environments.segments.hide_filters"));
|
||||
// Editor should now be removed from the DOM
|
||||
expect(screen.queryByText("SegmentEditor Mock (View Only)")).not.toBeInTheDocument();
|
||||
// Button text should change to "View Filters"
|
||||
expect(screen.getByText("environments.segments.view_filters")).toBeInTheDocument();
|
||||
expect(screen.queryByText("environments.segments.hide_filters")).not.toBeInTheDocument();
|
||||
|
||||
// Click again to show filters
|
||||
await user.click(screen.getByText("environments.segments.view_filters"));
|
||||
// Editor should be back in the DOM
|
||||
expect(screen.getByText("SegmentEditor Mock (View Only)")).toBeInTheDocument();
|
||||
// Button text should change back to "Hide Filters"
|
||||
expect(screen.getByText("environments.segments.hide_filters")).toBeInTheDocument();
|
||||
expect(screen.queryByText("environments.segments.view_filters")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens segment editor on 'Edit Segment' click", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TargetingCard
|
||||
localSurvey={mockSurvey} // Segment used only in this survey
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={mockInitialSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("SegmentEditor Mock (View Only)")).toBeInTheDocument();
|
||||
await user.click(screen.getByText("environments.segments.edit_segment"));
|
||||
expect(screen.getByText("SegmentEditor Mock (Editable)")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.add_filter")).toBeInTheDocument(); // Editor controls visible
|
||||
});
|
||||
|
||||
test("calls clone action on 'Clone and Edit Segment' click", async () => {
|
||||
const user = userEvent.setup();
|
||||
const surveyWithSharedSegment: TSurvey = {
|
||||
...mockSurvey,
|
||||
segment: { ...mockInitialSegment, surveys: ["survey1", "survey2"] }, // Used in > 1 survey
|
||||
};
|
||||
render(
|
||||
<TargetingCard
|
||||
localSurvey={surveyWithSharedSegment}
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={{ ...mockInitialSegment, surveys: ["survey1", "survey2"] }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("environments.segments.this_segment_is_used_in_other_surveys")
|
||||
).toBeInTheDocument();
|
||||
await user.click(screen.getByText("environments.segments.clone_and_edit_segment"));
|
||||
expect(mockCloneSegmentAction).toHaveBeenCalledWith({
|
||||
segmentId: mockInitialSegment.id,
|
||||
surveyId: mockSurvey.id,
|
||||
});
|
||||
// Check if setSegment was called (indirectly via useEffect)
|
||||
// We need to wait for the promise to resolve and state update
|
||||
// await vi.waitFor(() => expect(mockSetLocalSurvey).toHaveBeenCalled()); // This might be tricky due to internal state
|
||||
});
|
||||
|
||||
test("opens Save As New Segment modal when editor is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TargetingCard
|
||||
localSurvey={{ ...mockSurvey, segment: { ...mockInitialSegment, isPrivate: true } }} // Start with editor open
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={mockInitialSegment}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByText("environments.segments.save_as_new_segment"));
|
||||
expect(screen.getByText("SaveAsNewSegmentModal Mock")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls update action on 'Save Changes' click (non-private segment)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TargetingCard
|
||||
localSurvey={mockSurvey} // Non-private segment
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={mockInitialSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open editor
|
||||
await user.click(screen.getByText("environments.segments.edit_segment"));
|
||||
expect(screen.getByText("SegmentEditor Mock (Editable)")).toBeInTheDocument();
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText("common.save_changes"));
|
||||
expect(mockUpdateSegmentAction).toHaveBeenCalledWith({
|
||||
segmentId: mockInitialSegment.id,
|
||||
environmentId: environmentId,
|
||||
data: { filters: mockInitialSegment.filters },
|
||||
});
|
||||
});
|
||||
|
||||
test("closes editor on 'Cancel' click (non-private segment)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<TargetingCard
|
||||
localSurvey={mockSurvey} // Non-private segment
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
initialSegment={mockInitialSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open editor
|
||||
await user.click(screen.getByText("environments.segments.edit_segment"));
|
||||
expect(screen.getByText("SegmentEditor Mock (Editable)")).toBeInTheDocument();
|
||||
|
||||
// Click cancel
|
||||
await user.click(screen.getByText("common.cancel"));
|
||||
expect(screen.getByText("SegmentEditor Mock (View Only)")).toBeInTheDocument();
|
||||
expect(screen.queryByText("common.add_filter")).not.toBeInTheDocument(); // Editor controls hidden
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TBaseFilters, TSegment } from "@formbricks/types/segment";
|
||||
import { getSegment } from "../../segments";
|
||||
import { segmentFilterToPrismaQuery } from "../prisma-query";
|
||||
import { getSegment } from "../segments";
|
||||
import { segmentFilterToPrismaQuery } from "./prisma-query";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: vi.fn((fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("../../segments", () => ({
|
||||
vi.mock("../segments", () => ({
|
||||
getSegment: vi.fn(),
|
||||
}));
|
||||
|
||||
1222
apps/web/modules/ee/contacts/segments/lib/segments.test.ts
Normal file
1222
apps/web/modules/ee/contacts/segments/lib/segments.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { isResourceFilter, searchForAttributeKeyInSegment } from "@/modules/ee/c
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZString } from "@formbricks/types/common";
|
||||
import {
|
||||
DatabaseError,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
ZSegmentUpdateInput,
|
||||
} from "@formbricks/types/segment";
|
||||
|
||||
type PrismaSegment = Prisma.SegmentGetPayload<{
|
||||
export type PrismaSegment = Prisma.SegmentGetPayload<{
|
||||
include: {
|
||||
surveys: {
|
||||
select: {
|
||||
@@ -195,7 +196,8 @@ export const cloneSegment = async (segmentId: string, surveyId: string): Promise
|
||||
|
||||
let suffix = 1;
|
||||
if (lastCopyTitle) {
|
||||
const match = lastCopyTitle.match(/\((\d+)\)$/);
|
||||
const regex = /\((\d+)\)$/;
|
||||
const match = regex.exec(lastCopyTitle);
|
||||
if (match) {
|
||||
suffix = parseInt(match[1], 10) + 1;
|
||||
}
|
||||
@@ -260,7 +262,7 @@ export const deleteSegment = async (segmentId: string): Promise<TSegment> => {
|
||||
});
|
||||
|
||||
segmentCache.revalidate({ id: segmentId, environmentId: segment.environmentId });
|
||||
segment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id }));
|
||||
segment.surveys.forEach((survey) => surveyCache.revalidate({ id: survey.id }));
|
||||
|
||||
surveyCache.revalidate({ environmentId: currentSegment.environmentId });
|
||||
|
||||
@@ -374,7 +376,7 @@ export const updateSegment = async (segmentId: string, data: TSegmentUpdateInput
|
||||
});
|
||||
|
||||
segmentCache.revalidate({ id: segmentId, environmentId: segment.environmentId });
|
||||
segment.surveys.map((survey) => surveyCache.revalidate({ id: survey.id }));
|
||||
segment.surveys.forEach((survey) => surveyCache.revalidate({ id: survey.id }));
|
||||
|
||||
return transformPrismaSegment(segment);
|
||||
} catch (error) {
|
||||
@@ -622,6 +624,8 @@ export const evaluateSegment = async (
|
||||
|
||||
return finalResult;
|
||||
} catch (error) {
|
||||
logger.error("Error evaluating segment", error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
702
apps/web/modules/ee/contacts/segments/lib/utils.test.ts
Normal file
702
apps/web/modules/ee/contacts/segments/lib/utils.test.ts
Normal file
@@ -0,0 +1,702 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TBaseFilter,
|
||||
TBaseFilters,
|
||||
TSegment,
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentDeviceFilter,
|
||||
TSegmentFilter,
|
||||
TSegmentPersonFilter,
|
||||
TSegmentSegmentFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import {
|
||||
addFilterBelow,
|
||||
addFilterInGroup,
|
||||
convertOperatorToText,
|
||||
convertOperatorToTitle,
|
||||
createGroupFromResource,
|
||||
deleteEmptyGroups,
|
||||
deleteResource,
|
||||
formatSegmentDateFields,
|
||||
isAdvancedSegment,
|
||||
isResourceFilter,
|
||||
moveResource,
|
||||
searchForAttributeKeyInSegment,
|
||||
toggleFilterConnector,
|
||||
toggleGroupConnector,
|
||||
updateContactAttributeKeyInFilter,
|
||||
updateDeviceTypeInFilter,
|
||||
updateFilterValue,
|
||||
updateOperatorInFilter,
|
||||
updatePersonIdentifierInFilter,
|
||||
updateSegmentIdInFilter,
|
||||
} from "./utils";
|
||||
|
||||
// Mock createId
|
||||
vi.mock("@paralleldrive/cuid2", () => ({
|
||||
createId: vi.fn(),
|
||||
}));
|
||||
|
||||
// Helper function to create a mock filter
|
||||
const createMockFilter = (
|
||||
id: string,
|
||||
type: "attribute" | "person" | "segment" | "device"
|
||||
): TSegmentFilter => {
|
||||
const base = {
|
||||
id,
|
||||
root: { type },
|
||||
qualifier: { operator: "equals" as const },
|
||||
value: "someValue",
|
||||
};
|
||||
if (type === "attribute") {
|
||||
return { ...base, root: { type, contactAttributeKey: "email" } } as TSegmentAttributeFilter;
|
||||
}
|
||||
if (type === "person") {
|
||||
return { ...base, root: { type, personIdentifier: "userId" } } as TSegmentPersonFilter;
|
||||
}
|
||||
if (type === "segment") {
|
||||
return {
|
||||
...base,
|
||||
root: { type, segmentId: "seg1" },
|
||||
qualifier: { operator: "userIsIn" as const },
|
||||
value: "seg1",
|
||||
} as TSegmentSegmentFilter;
|
||||
}
|
||||
if (type === "device") {
|
||||
return { ...base, root: { type, deviceType: "desktop" }, value: "desktop" } as TSegmentDeviceFilter;
|
||||
}
|
||||
throw new Error("Invalid filter type");
|
||||
};
|
||||
|
||||
// Helper function to create a base filter structure
|
||||
const createBaseFilter = (
|
||||
resource: TSegmentFilter | TBaseFilters,
|
||||
connector: "and" | "or" | null = "and",
|
||||
id?: string
|
||||
): TBaseFilter => ({
|
||||
id: id ?? (isResourceFilter(resource) ? resource.id : `group-${Math.random()}`), // Use filter ID or random for group
|
||||
connector,
|
||||
resource,
|
||||
});
|
||||
|
||||
describe("Segment Utils", () => {
|
||||
test("isResourceFilter", () => {
|
||||
const filter = createMockFilter("f1", "attribute");
|
||||
const baseFilter = createBaseFilter(filter);
|
||||
const group = createBaseFilter([baseFilter]);
|
||||
|
||||
expect(isResourceFilter(filter)).toBe(true);
|
||||
expect(isResourceFilter(group.resource)).toBe(false);
|
||||
expect(isResourceFilter(baseFilter.resource)).toBe(true);
|
||||
});
|
||||
|
||||
test("convertOperatorToText", () => {
|
||||
expect(convertOperatorToText("equals")).toBe("=");
|
||||
expect(convertOperatorToText("notEquals")).toBe("!=");
|
||||
expect(convertOperatorToText("lessThan")).toBe("<");
|
||||
expect(convertOperatorToText("lessEqual")).toBe("<=");
|
||||
expect(convertOperatorToText("greaterThan")).toBe(">");
|
||||
expect(convertOperatorToText("greaterEqual")).toBe(">=");
|
||||
expect(convertOperatorToText("isSet")).toBe("is set");
|
||||
expect(convertOperatorToText("isNotSet")).toBe("is not set");
|
||||
expect(convertOperatorToText("contains")).toBe("contains ");
|
||||
expect(convertOperatorToText("doesNotContain")).toBe("does not contain");
|
||||
expect(convertOperatorToText("startsWith")).toBe("starts with");
|
||||
expect(convertOperatorToText("endsWith")).toBe("ends with");
|
||||
expect(convertOperatorToText("userIsIn")).toBe("User is in");
|
||||
expect(convertOperatorToText("userIsNotIn")).toBe("User is not in");
|
||||
// @ts-expect-error - testing default case
|
||||
expect(convertOperatorToText("unknown")).toBe("unknown");
|
||||
});
|
||||
|
||||
test("convertOperatorToTitle", () => {
|
||||
expect(convertOperatorToTitle("equals")).toBe("Equals");
|
||||
expect(convertOperatorToTitle("notEquals")).toBe("Not equals to");
|
||||
expect(convertOperatorToTitle("lessThan")).toBe("Less than");
|
||||
expect(convertOperatorToTitle("lessEqual")).toBe("Less than or equal to");
|
||||
expect(convertOperatorToTitle("greaterThan")).toBe("Greater than");
|
||||
expect(convertOperatorToTitle("greaterEqual")).toBe("Greater than or equal to");
|
||||
expect(convertOperatorToTitle("isSet")).toBe("Is set");
|
||||
expect(convertOperatorToTitle("isNotSet")).toBe("Is not set");
|
||||
expect(convertOperatorToTitle("contains")).toBe("Contains");
|
||||
expect(convertOperatorToTitle("doesNotContain")).toBe("Does not contain");
|
||||
expect(convertOperatorToTitle("startsWith")).toBe("Starts with");
|
||||
expect(convertOperatorToTitle("endsWith")).toBe("Ends with");
|
||||
expect(convertOperatorToTitle("userIsIn")).toBe("User is in");
|
||||
expect(convertOperatorToTitle("userIsNotIn")).toBe("User is not in");
|
||||
// @ts-expect-error - testing default case
|
||||
expect(convertOperatorToTitle("unknown")).toBe("unknown");
|
||||
});
|
||||
|
||||
test("addFilterBelow", () => {
|
||||
const filter1 = createMockFilter("f1", "attribute");
|
||||
const filter2 = createMockFilter("f2", "person");
|
||||
const newFilter = createMockFilter("f3", "segment");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const baseFilter2 = createBaseFilter(filter2, "and", "bf2");
|
||||
const newBaseFilter = createBaseFilter(newFilter, "or", "bf3");
|
||||
|
||||
const group: TBaseFilters = [baseFilter1, baseFilter2];
|
||||
addFilterBelow(group, "f1", newBaseFilter);
|
||||
expect(group).toEqual([baseFilter1, newBaseFilter, baseFilter2]);
|
||||
|
||||
const nestedFilter = createMockFilter("nf1", "device");
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, null, "nbf1");
|
||||
const nestedGroup = createBaseFilter([nestedBaseFilter], "or", "ng1");
|
||||
const groupWithNested: TBaseFilters = [baseFilter1, nestedGroup];
|
||||
const newFilterForNested = createMockFilter("nf2", "attribute");
|
||||
const newBaseFilterForNested = createBaseFilter(newFilterForNested, "and", "nbf2");
|
||||
|
||||
addFilterBelow(groupWithNested, "nf1", newBaseFilterForNested);
|
||||
expect((groupWithNested[1].resource as TBaseFilters)[1]).toEqual(newBaseFilterForNested);
|
||||
|
||||
const group3: TBaseFilters = [baseFilter1, nestedGroup];
|
||||
const newFilterBelowGroup = createMockFilter("f4", "person");
|
||||
const newBaseFilterBelowGroup = createBaseFilter(newFilterBelowGroup, "and", "bf4");
|
||||
addFilterBelow(group3, "ng1", newBaseFilterBelowGroup);
|
||||
expect(group3).toEqual([baseFilter1, nestedGroup, newBaseFilterBelowGroup]);
|
||||
});
|
||||
|
||||
test("createGroupFromResource", () => {
|
||||
vi.mocked(createId).mockReturnValue("newGroupId");
|
||||
|
||||
const filter1 = createMockFilter("f1", "attribute");
|
||||
const filter2 = createMockFilter("f2", "person");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const baseFilter2 = createBaseFilter(filter2, "and", "bf2");
|
||||
const group: TBaseFilters = [baseFilter1, baseFilter2];
|
||||
|
||||
createGroupFromResource(group, "f1");
|
||||
expect(group[0].id).toBe("newGroupId");
|
||||
expect(group[0].connector).toBeNull();
|
||||
expect(isResourceFilter(group[0].resource)).toBe(false);
|
||||
expect((group[0].resource as TBaseFilters)[0].resource).toEqual(filter1);
|
||||
expect((group[0].resource as TBaseFilters)[0].connector).toBeNull();
|
||||
expect(group[1]).toEqual(baseFilter2);
|
||||
|
||||
const nestedFilter = createMockFilter("nf1", "device");
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, null, "nbf1");
|
||||
const initialNestedGroup = createBaseFilter([nestedBaseFilter], "or", "ng1");
|
||||
const groupWithNested: TBaseFilters = [baseFilter1, initialNestedGroup];
|
||||
|
||||
vi.mocked(createId).mockReturnValue("outerGroupId");
|
||||
createGroupFromResource(groupWithNested, "ng1");
|
||||
|
||||
expect(groupWithNested[1].id).toBe("outerGroupId");
|
||||
expect(groupWithNested[1].connector).toBe("or");
|
||||
expect(isResourceFilter(groupWithNested[1].resource)).toBe(false);
|
||||
const outerGroupResource = groupWithNested[1].resource as TBaseFilters;
|
||||
expect(outerGroupResource.length).toBe(1);
|
||||
expect(outerGroupResource[0].id).toBe("ng1");
|
||||
expect(outerGroupResource[0].connector).toBeNull();
|
||||
expect(outerGroupResource[0].resource).toEqual([nestedBaseFilter]);
|
||||
|
||||
const filter3 = createMockFilter("f3", "segment");
|
||||
const baseFilter3 = createBaseFilter(filter3, "and", "bf3");
|
||||
const nestedGroup2: TBaseFilters = [nestedBaseFilter, baseFilter3];
|
||||
const initialNestedGroup2 = createBaseFilter(nestedGroup2, "or", "ng2");
|
||||
const groupWithNested2: TBaseFilters = [baseFilter1, initialNestedGroup2];
|
||||
|
||||
vi.mocked(createId).mockReturnValue("newInnerGroupId");
|
||||
createGroupFromResource(groupWithNested2, "nf1");
|
||||
|
||||
const targetGroup = groupWithNested2[1].resource as TBaseFilters;
|
||||
expect(targetGroup[0].id).toBe("newInnerGroupId");
|
||||
expect(targetGroup[0].connector).toBeNull();
|
||||
expect(isResourceFilter(targetGroup[0].resource)).toBe(false);
|
||||
expect((targetGroup[0].resource as TBaseFilters)[0].resource).toEqual(nestedFilter);
|
||||
expect((targetGroup[0].resource as TBaseFilters)[0].connector).toBeNull();
|
||||
expect(targetGroup[1]).toEqual(baseFilter3);
|
||||
});
|
||||
|
||||
test("moveResource", () => {
|
||||
// Initial setup for filter moving
|
||||
const filter1_orig = createMockFilter("f1", "attribute");
|
||||
const filter2_orig = createMockFilter("f2", "person");
|
||||
const filter3_orig = createMockFilter("f3", "segment");
|
||||
const baseFilter1_orig = createBaseFilter(filter1_orig, null, "bf1");
|
||||
const baseFilter2_orig = createBaseFilter(filter2_orig, "and", "bf2");
|
||||
const baseFilter3_orig = createBaseFilter(filter3_orig, "or", "bf3");
|
||||
let group: TBaseFilters = [baseFilter1_orig, baseFilter2_orig, baseFilter3_orig];
|
||||
|
||||
// Test moving filters up/down
|
||||
moveResource(group, "f2", "up");
|
||||
// Expected: [bf2(null), bf1(and), bf3(or)]
|
||||
expect(group[0].id).toBe("bf2");
|
||||
expect(group[0].connector).toBeNull();
|
||||
expect(group[1].id).toBe("bf1");
|
||||
expect(group[1].connector).toBe("and");
|
||||
expect(group[2].id).toBe("bf3");
|
||||
|
||||
moveResource(group, "f2", "up"); // Move first up (no change)
|
||||
expect(group[0].id).toBe("bf2");
|
||||
expect(group[0].connector).toBeNull();
|
||||
expect(group[1].id).toBe("bf1");
|
||||
expect(group[1].connector).toBe("and");
|
||||
|
||||
moveResource(group, "f1", "down"); // Move bf1 (index 1) down
|
||||
// Expected: [bf2(null), bf3(or), bf1(and)]
|
||||
expect(group[0].id).toBe("bf2");
|
||||
expect(group[0].connector).toBeNull();
|
||||
expect(group[1].id).toBe("bf3");
|
||||
expect(group[1].connector).toBe("or");
|
||||
expect(group[2].id).toBe("bf1");
|
||||
expect(group[2].connector).toBe("and");
|
||||
|
||||
moveResource(group, "f1", "down"); // Move last down (no change)
|
||||
expect(group[2].id).toBe("bf1");
|
||||
expect(group[2].connector).toBe("and");
|
||||
|
||||
// Setup for nested filter moving
|
||||
const nestedFilter1_orig = createMockFilter("nf1", "device");
|
||||
const nestedFilter2_orig = createMockFilter("nf2", "attribute");
|
||||
// Use fresh baseFilter1 to avoid state pollution from previous tests
|
||||
const baseFilter1_fresh_nested = createBaseFilter(createMockFilter("f1", "attribute"), null, "bf1");
|
||||
const nestedBaseFilter1_orig = createBaseFilter(nestedFilter1_orig, null, "nbf1");
|
||||
const nestedBaseFilter2_orig = createBaseFilter(nestedFilter2_orig, "and", "nbf2");
|
||||
const nestedGroup_orig = createBaseFilter([nestedBaseFilter1_orig, nestedBaseFilter2_orig], "or", "ng1");
|
||||
const groupWithNested: TBaseFilters = [baseFilter1_fresh_nested, nestedGroup_orig];
|
||||
|
||||
moveResource(groupWithNested, "nf2", "up"); // Move nf2 up within nested group
|
||||
const innerGroup = groupWithNested[1].resource as TBaseFilters;
|
||||
expect(innerGroup[0].id).toBe("nbf2");
|
||||
expect(innerGroup[0].connector).toBeNull();
|
||||
expect(innerGroup[1].id).toBe("nbf1");
|
||||
expect(innerGroup[1].connector).toBe("and");
|
||||
|
||||
// Setup for moving groups - Ensure fresh state here
|
||||
const filter1_group = createMockFilter("f1", "attribute");
|
||||
const filter3_group = createMockFilter("f3", "segment");
|
||||
const nestedFilter1_group = createMockFilter("nf1", "device");
|
||||
const nestedFilter2_group = createMockFilter("nf2", "attribute");
|
||||
|
||||
const baseFilter1_group = createBaseFilter(filter1_group, null, "bf1"); // Fresh, connector null
|
||||
const nestedBaseFilter1_group = createBaseFilter(nestedFilter1_group, null, "nbf1");
|
||||
const nestedBaseFilter2_group = createBaseFilter(nestedFilter2_group, "and", "nbf2");
|
||||
const nestedGroup_group = createBaseFilter(
|
||||
[nestedBaseFilter1_group, nestedBaseFilter2_group],
|
||||
"or",
|
||||
"ng1"
|
||||
); // Fresh, connector 'or'
|
||||
const baseFilter3_group = createBaseFilter(filter3_group, "or", "bf3"); // Fresh, connector 'or'
|
||||
|
||||
const groupToMove: TBaseFilters = [baseFilter1_group, nestedGroup_group, baseFilter3_group];
|
||||
// Initial state: [bf1(null), ng1(or), bf3(or)]
|
||||
|
||||
moveResource(groupToMove, "ng1", "down"); // Move ng1 (index 1) down
|
||||
// Expected state: [bf1(null), bf3(or), ng1(or)]
|
||||
expect(groupToMove[0].id).toBe("bf1");
|
||||
expect(groupToMove[0].connector).toBeNull(); // Should pass now
|
||||
expect(groupToMove[1].id).toBe("bf3");
|
||||
expect(groupToMove[1].connector).toBe("or");
|
||||
expect(groupToMove[2].id).toBe("ng1");
|
||||
expect(groupToMove[2].connector).toBe("or");
|
||||
|
||||
moveResource(groupToMove, "ng1", "up"); // Move ng1 (index 2) up
|
||||
// Expected state: [bf1(null), ng1(or), bf3(or)]
|
||||
expect(groupToMove[0].id).toBe("bf1");
|
||||
expect(groupToMove[0].connector).toBeNull();
|
||||
expect(groupToMove[1].id).toBe("ng1");
|
||||
expect(groupToMove[1].connector).toBe("or");
|
||||
expect(groupToMove[2].id).toBe("bf3");
|
||||
expect(groupToMove[2].connector).toBe("or");
|
||||
});
|
||||
|
||||
test("deleteResource", () => {
|
||||
// Scenario 1: Delete middle filter
|
||||
let filter1_s1 = createMockFilter("f1", "attribute");
|
||||
let filter2_s1 = createMockFilter("f2", "person");
|
||||
let filter3_s1 = createMockFilter("f3", "segment");
|
||||
let baseFilter1_s1 = createBaseFilter(filter1_s1, null, "bf1");
|
||||
let baseFilter2_s1 = createBaseFilter(filter2_s1, "and", "bf2");
|
||||
let baseFilter3_s1 = createBaseFilter(filter3_s1, "or", "bf3");
|
||||
let group_s1: TBaseFilters = [baseFilter1_s1, baseFilter2_s1, baseFilter3_s1];
|
||||
deleteResource(group_s1, "f2");
|
||||
expect(group_s1.length).toBe(2);
|
||||
expect(group_s1[0].id).toBe("bf1");
|
||||
expect(group_s1[0].connector).toBeNull();
|
||||
expect(group_s1[1].id).toBe("bf3");
|
||||
expect(group_s1[1].connector).toBe("or");
|
||||
|
||||
// Scenario 2: Delete first filter
|
||||
let filter1_s2 = createMockFilter("f1", "attribute");
|
||||
let filter2_s2 = createMockFilter("f2", "person");
|
||||
let filter3_s2 = createMockFilter("f3", "segment");
|
||||
let baseFilter1_s2 = createBaseFilter(filter1_s2, null, "bf1");
|
||||
let baseFilter2_s2 = createBaseFilter(filter2_s2, "and", "bf2");
|
||||
let baseFilter3_s2 = createBaseFilter(filter3_s2, "or", "bf3");
|
||||
let group_s2: TBaseFilters = [baseFilter1_s2, baseFilter2_s2, baseFilter3_s2];
|
||||
deleteResource(group_s2, "f1");
|
||||
expect(group_s2.length).toBe(2);
|
||||
expect(group_s2[0].id).toBe("bf2");
|
||||
expect(group_s2[0].connector).toBeNull(); // Connector becomes null
|
||||
expect(group_s2[1].id).toBe("bf3");
|
||||
expect(group_s2[1].connector).toBe("or");
|
||||
|
||||
// Scenario 3: Delete last filter
|
||||
let filter1_s3 = createMockFilter("f1", "attribute");
|
||||
let filter2_s3 = createMockFilter("f2", "person");
|
||||
let filter3_s3 = createMockFilter("f3", "segment");
|
||||
let baseFilter1_s3 = createBaseFilter(filter1_s3, null, "bf1");
|
||||
let baseFilter2_s3 = createBaseFilter(filter2_s3, "and", "bf2");
|
||||
let baseFilter3_s3 = createBaseFilter(filter3_s3, "or", "bf3");
|
||||
let group_s3: TBaseFilters = [baseFilter1_s3, baseFilter2_s3, baseFilter3_s3];
|
||||
deleteResource(group_s3, "f3");
|
||||
expect(group_s3.length).toBe(2);
|
||||
expect(group_s3[0].id).toBe("bf1");
|
||||
expect(group_s3[0].connector).toBeNull();
|
||||
expect(group_s3[1].id).toBe("bf2");
|
||||
expect(group_s3[1].connector).toBe("and"); // Should pass now
|
||||
|
||||
// Scenario 4: Delete only filter
|
||||
let filter1_s4 = createMockFilter("f1", "attribute");
|
||||
let baseFilter1_s4 = createBaseFilter(filter1_s4, null, "bf1");
|
||||
let group_s4: TBaseFilters = [baseFilter1_s4];
|
||||
deleteResource(group_s4, "f1");
|
||||
expect(group_s4).toEqual([]);
|
||||
|
||||
// Scenario 5: Delete filter in nested group
|
||||
let filter1_s5 = createMockFilter("f1", "attribute"); // Outer filter
|
||||
let nestedFilter1_s5 = createMockFilter("nf1", "device");
|
||||
let nestedFilter2_s5 = createMockFilter("nf2", "attribute");
|
||||
let baseFilter1_s5 = createBaseFilter(filter1_s5, null, "bf1");
|
||||
let nestedBaseFilter1_s5 = createBaseFilter(nestedFilter1_s5, null, "nbf1");
|
||||
let nestedBaseFilter2_s5 = createBaseFilter(nestedFilter2_s5, "and", "nbf2");
|
||||
let nestedGroup_s5 = createBaseFilter([nestedBaseFilter1_s5, nestedBaseFilter2_s5], "or", "ng1");
|
||||
let groupWithNested_s5: TBaseFilters = [baseFilter1_s5, nestedGroup_s5];
|
||||
|
||||
deleteResource(groupWithNested_s5, "nf1");
|
||||
let innerGroup_s5 = groupWithNested_s5[1].resource as TBaseFilters;
|
||||
expect(innerGroup_s5.length).toBe(1);
|
||||
expect(innerGroup_s5[0].id).toBe("nbf2");
|
||||
expect(innerGroup_s5[0].connector).toBeNull(); // Connector becomes null
|
||||
|
||||
// Scenario 6: Delete filter that makes group empty, then delete the empty group
|
||||
// Continue from Scenario 5 state
|
||||
deleteResource(groupWithNested_s5, "nf2");
|
||||
expect(groupWithNested_s5.length).toBe(1);
|
||||
expect(groupWithNested_s5[0].id).toBe("bf1"); // Empty group ng1 should be deleted
|
||||
|
||||
// Scenario 7: Delete a group directly
|
||||
let filter1_s7 = createMockFilter("f1", "attribute");
|
||||
let filter3_s7 = createMockFilter("f3", "segment");
|
||||
let nestedFilter1_s7 = createMockFilter("nf1", "device");
|
||||
let nestedFilter2_s7 = createMockFilter("nf2", "attribute");
|
||||
let baseFilter1_s7 = createBaseFilter(filter1_s7, null, "bf1");
|
||||
let nestedBaseFilter1_s7 = createBaseFilter(nestedFilter1_s7, null, "nbf1");
|
||||
let nestedBaseFilter2_s7 = createBaseFilter(nestedFilter2_s7, "and", "nbf2");
|
||||
let nestedGroup_s7 = createBaseFilter([nestedBaseFilter1_s7, nestedBaseFilter2_s7], "or", "ng1");
|
||||
let baseFilter3_s7 = createBaseFilter(filter3_s7, "or", "bf3");
|
||||
const groupToDelete_s7: TBaseFilters = [baseFilter1_s7, nestedGroup_s7, baseFilter3_s7];
|
||||
|
||||
deleteResource(groupToDelete_s7, "ng1");
|
||||
expect(groupToDelete_s7.length).toBe(2);
|
||||
expect(groupToDelete_s7[0].id).toBe("bf1");
|
||||
expect(groupToDelete_s7[0].connector).toBeNull();
|
||||
expect(groupToDelete_s7[1].id).toBe("bf3");
|
||||
expect(groupToDelete_s7[1].connector).toBe("or"); // Connector from bf3 remains
|
||||
});
|
||||
|
||||
test("deleteEmptyGroups", () => {
|
||||
const filter1 = createMockFilter("f1", "attribute");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const emptyGroup1 = createBaseFilter([], "and", "eg1");
|
||||
const nestedEmptyGroup = createBaseFilter([], "or", "neg1");
|
||||
const groupWithEmptyNested = createBaseFilter([nestedEmptyGroup], "and", "gwen1");
|
||||
const group: TBaseFilters = [baseFilter1, emptyGroup1, groupWithEmptyNested];
|
||||
|
||||
deleteEmptyGroups(group);
|
||||
|
||||
// Now expect the correct behavior: all empty groups are removed.
|
||||
const expectedCorrectResult = [baseFilter1];
|
||||
|
||||
expect(group).toEqual(expectedCorrectResult);
|
||||
});
|
||||
|
||||
test("addFilterInGroup", () => {
|
||||
const filter1 = createMockFilter("f1", "attribute");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const emptyGroup = createBaseFilter([], "and", "eg1");
|
||||
const nestedFilter = createMockFilter("nf1", "device");
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, null, "nbf1");
|
||||
const nestedGroup = createBaseFilter([nestedBaseFilter], "or", "ng1");
|
||||
const group: TBaseFilters = [baseFilter1, emptyGroup, nestedGroup];
|
||||
|
||||
const newFilter1 = createMockFilter("newF1", "person");
|
||||
const newBaseFilter1 = createBaseFilter(newFilter1, "and", "newBf1");
|
||||
addFilterInGroup(group, "eg1", newBaseFilter1);
|
||||
expect(group[1].resource as TBaseFilters).toEqual([{ ...newBaseFilter1, connector: null }]); // First filter in group has null connector
|
||||
|
||||
const newFilter2 = createMockFilter("newF2", "segment");
|
||||
const newBaseFilter2 = createBaseFilter(newFilter2, "or", "newBf2");
|
||||
addFilterInGroup(group, "ng1", newBaseFilter2);
|
||||
expect(group[2].resource as TBaseFilters).toEqual([nestedBaseFilter, newBaseFilter2]);
|
||||
expect((group[2].resource as TBaseFilters)[1].connector).toBe("or");
|
||||
});
|
||||
|
||||
test("toggleGroupConnector", () => {
|
||||
const filter1 = createMockFilter("f1", "attribute");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const nestedFilter = createMockFilter("nf1", "device");
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, null, "nbf1");
|
||||
const nestedGroup = createBaseFilter([nestedBaseFilter], "or", "ng1");
|
||||
const group: TBaseFilters = [baseFilter1, nestedGroup];
|
||||
|
||||
toggleGroupConnector(group, "ng1", "and");
|
||||
expect(group[1].connector).toBe("and");
|
||||
|
||||
// Toggle connector of a non-existent group (should do nothing)
|
||||
toggleGroupConnector(group, "nonExistent", "and");
|
||||
expect(group[1].connector).toBe("and");
|
||||
});
|
||||
|
||||
test("toggleFilterConnector", () => {
|
||||
const filter1 = createMockFilter("f1", "attribute");
|
||||
const filter2 = createMockFilter("f2", "person");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const baseFilter2 = createBaseFilter(filter2, "and", "bf2");
|
||||
const nestedFilter = createMockFilter("nf1", "device");
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, "or", "nbf1");
|
||||
const nestedGroup = createBaseFilter([nestedBaseFilter], "and", "ng1");
|
||||
const group: TBaseFilters = [baseFilter1, baseFilter2, nestedGroup];
|
||||
|
||||
toggleFilterConnector(group, "f2", "or");
|
||||
expect(group[1].connector).toBe("or");
|
||||
|
||||
toggleFilterConnector(group, "nf1", "and");
|
||||
expect((group[2].resource as TBaseFilters)[0].connector).toBe("and");
|
||||
|
||||
// Toggle connector of a non-existent filter (should do nothing)
|
||||
toggleFilterConnector(group, "nonExistent", "and");
|
||||
expect(group[1].connector).toBe("or");
|
||||
expect((group[2].resource as TBaseFilters)[0].connector).toBe("and");
|
||||
});
|
||||
|
||||
test("updateOperatorInFilter", () => {
|
||||
const filter1 = createMockFilter("f1", "attribute");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const nestedFilter = createMockFilter("nf1", "device");
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, null, "nbf1");
|
||||
const nestedGroup = createBaseFilter([nestedBaseFilter], "or", "ng1");
|
||||
const group: TBaseFilters = [baseFilter1, nestedGroup];
|
||||
|
||||
updateOperatorInFilter(group, "f1", "notEquals");
|
||||
expect((group[0].resource as TSegmentFilter).qualifier.operator).toBe("notEquals");
|
||||
|
||||
updateOperatorInFilter(group, "nf1", "isSet");
|
||||
expect(((group[1].resource as TBaseFilters)[0].resource as TSegmentFilter).qualifier.operator).toBe(
|
||||
"isSet"
|
||||
);
|
||||
|
||||
// Update operator of non-existent filter (should do nothing)
|
||||
updateOperatorInFilter(group, "nonExistent", "contains");
|
||||
expect((group[0].resource as TSegmentFilter).qualifier.operator).toBe("notEquals");
|
||||
expect(((group[1].resource as TBaseFilters)[0].resource as TSegmentFilter).qualifier.operator).toBe(
|
||||
"isSet"
|
||||
);
|
||||
});
|
||||
|
||||
test("updateContactAttributeKeyInFilter", () => {
|
||||
const filter1 = createMockFilter("f1", "attribute");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const nestedFilter = createMockFilter("nf1", "attribute");
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, null, "nbf1");
|
||||
const nestedGroup = createBaseFilter([nestedBaseFilter], "or", "ng1");
|
||||
const group: TBaseFilters = [baseFilter1, nestedGroup];
|
||||
|
||||
updateContactAttributeKeyInFilter(group, "f1", "newKey1");
|
||||
expect((group[0].resource as TSegmentAttributeFilter).root.contactAttributeKey).toBe("newKey1");
|
||||
|
||||
updateContactAttributeKeyInFilter(group, "nf1", "newKey2");
|
||||
expect(
|
||||
((group[1].resource as TBaseFilters)[0].resource as TSegmentAttributeFilter).root.contactAttributeKey
|
||||
).toBe("newKey2");
|
||||
|
||||
// Update key of non-existent filter (should do nothing)
|
||||
updateContactAttributeKeyInFilter(group, "nonExistent", "anotherKey");
|
||||
expect((group[0].resource as TSegmentAttributeFilter).root.contactAttributeKey).toBe("newKey1");
|
||||
expect(
|
||||
((group[1].resource as TBaseFilters)[0].resource as TSegmentAttributeFilter).root.contactAttributeKey
|
||||
).toBe("newKey2");
|
||||
});
|
||||
|
||||
test("updatePersonIdentifierInFilter", () => {
|
||||
const filter1 = createMockFilter("f1", "person");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const nestedFilter = createMockFilter("nf1", "person");
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, null, "nbf1");
|
||||
const nestedGroup = createBaseFilter([nestedBaseFilter], "or", "ng1");
|
||||
const group: TBaseFilters = [baseFilter1, nestedGroup];
|
||||
|
||||
updatePersonIdentifierInFilter(group, "f1", "newId1");
|
||||
expect((group[0].resource as TSegmentPersonFilter).root.personIdentifier).toBe("newId1");
|
||||
|
||||
updatePersonIdentifierInFilter(group, "nf1", "newId2");
|
||||
expect(
|
||||
((group[1].resource as TBaseFilters)[0].resource as TSegmentPersonFilter).root.personIdentifier
|
||||
).toBe("newId2");
|
||||
|
||||
// Update identifier of non-existent filter (should do nothing)
|
||||
updatePersonIdentifierInFilter(group, "nonExistent", "anotherId");
|
||||
expect((group[0].resource as TSegmentPersonFilter).root.personIdentifier).toBe("newId1");
|
||||
expect(
|
||||
((group[1].resource as TBaseFilters)[0].resource as TSegmentPersonFilter).root.personIdentifier
|
||||
).toBe("newId2");
|
||||
});
|
||||
|
||||
test("updateSegmentIdInFilter", () => {
|
||||
const filter1 = createMockFilter("f1", "segment");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const nestedFilter = createMockFilter("nf1", "segment");
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, null, "nbf1");
|
||||
const nestedGroup = createBaseFilter([nestedBaseFilter], "or", "ng1");
|
||||
const group: TBaseFilters = [baseFilter1, nestedGroup];
|
||||
|
||||
updateSegmentIdInFilter(group, "f1", "newSegId1");
|
||||
expect((group[0].resource as TSegmentSegmentFilter).root.segmentId).toBe("newSegId1");
|
||||
expect((group[0].resource as TSegmentSegmentFilter).value).toBe("newSegId1");
|
||||
|
||||
updateSegmentIdInFilter(group, "nf1", "newSegId2");
|
||||
expect(((group[1].resource as TBaseFilters)[0].resource as TSegmentSegmentFilter).root.segmentId).toBe(
|
||||
"newSegId2"
|
||||
);
|
||||
expect(((group[1].resource as TBaseFilters)[0].resource as TSegmentSegmentFilter).value).toBe(
|
||||
"newSegId2"
|
||||
);
|
||||
|
||||
// Update segment ID of non-existent filter (should do nothing)
|
||||
updateSegmentIdInFilter(group, "nonExistent", "anotherSegId");
|
||||
expect((group[0].resource as TSegmentSegmentFilter).root.segmentId).toBe("newSegId1");
|
||||
expect(((group[1].resource as TBaseFilters)[0].resource as TSegmentSegmentFilter).root.segmentId).toBe(
|
||||
"newSegId2"
|
||||
);
|
||||
});
|
||||
|
||||
test("updateFilterValue", () => {
|
||||
const filter1 = createMockFilter("f1", "attribute");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const nestedFilter = createMockFilter("nf1", "person");
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, null, "nbf1");
|
||||
const nestedGroup = createBaseFilter([nestedBaseFilter], "or", "ng1");
|
||||
const group: TBaseFilters = [baseFilter1, nestedGroup];
|
||||
|
||||
updateFilterValue(group, "f1", "newValue1");
|
||||
expect((group[0].resource as TSegmentFilter).value).toBe("newValue1");
|
||||
|
||||
updateFilterValue(group, "nf1", 123);
|
||||
expect(((group[1].resource as TBaseFilters)[0].resource as TSegmentFilter).value).toBe(123);
|
||||
|
||||
// Update value of non-existent filter (should do nothing)
|
||||
updateFilterValue(group, "nonExistent", "anotherValue");
|
||||
expect((group[0].resource as TSegmentFilter).value).toBe("newValue1");
|
||||
expect(((group[1].resource as TBaseFilters)[0].resource as TSegmentFilter).value).toBe(123);
|
||||
});
|
||||
|
||||
test("updateDeviceTypeInFilter", () => {
|
||||
const filter1 = createMockFilter("f1", "device");
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const nestedFilter = createMockFilter("nf1", "device");
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, null, "nbf1");
|
||||
const nestedGroup = createBaseFilter([nestedBaseFilter], "or", "ng1");
|
||||
const group: TBaseFilters = [baseFilter1, nestedGroup];
|
||||
|
||||
updateDeviceTypeInFilter(group, "f1", "phone");
|
||||
expect((group[0].resource as TSegmentDeviceFilter).root.deviceType).toBe("phone");
|
||||
expect((group[0].resource as TSegmentDeviceFilter).value).toBe("phone");
|
||||
|
||||
updateDeviceTypeInFilter(group, "nf1", "desktop");
|
||||
expect(((group[1].resource as TBaseFilters)[0].resource as TSegmentDeviceFilter).root.deviceType).toBe(
|
||||
"desktop"
|
||||
);
|
||||
expect(((group[1].resource as TBaseFilters)[0].resource as TSegmentDeviceFilter).value).toBe("desktop");
|
||||
|
||||
// Update device type of non-existent filter (should do nothing)
|
||||
updateDeviceTypeInFilter(group, "nonExistent", "phone");
|
||||
expect((group[0].resource as TSegmentDeviceFilter).root.deviceType).toBe("phone");
|
||||
expect(((group[1].resource as TBaseFilters)[0].resource as TSegmentDeviceFilter).root.deviceType).toBe(
|
||||
"desktop"
|
||||
);
|
||||
});
|
||||
|
||||
test("formatSegmentDateFields", () => {
|
||||
const dateString = "2023-01-01T12:00:00.000Z";
|
||||
const segment: TSegment = {
|
||||
id: "seg1",
|
||||
title: "Test Segment",
|
||||
description: "Desc",
|
||||
isPrivate: false,
|
||||
environmentId: "env1",
|
||||
surveys: ["survey1"],
|
||||
filters: [],
|
||||
createdAt: dateString as any, // Cast to any to simulate string input
|
||||
updatedAt: dateString as any, // Cast to any to simulate string input
|
||||
};
|
||||
|
||||
const formattedSegment = formatSegmentDateFields(segment);
|
||||
expect(formattedSegment.createdAt).toBeInstanceOf(Date);
|
||||
expect(formattedSegment.updatedAt).toBeInstanceOf(Date);
|
||||
expect(formattedSegment.createdAt.toISOString()).toBe(dateString);
|
||||
expect(formattedSegment.updatedAt.toISOString()).toBe(dateString);
|
||||
|
||||
// Test with Date objects already (should not change)
|
||||
const dateObj = new Date(dateString);
|
||||
const segmentWithDates: TSegment = { ...segment, createdAt: dateObj, updatedAt: dateObj };
|
||||
const formattedSegment2 = formatSegmentDateFields(segmentWithDates);
|
||||
expect(formattedSegment2.createdAt).toBe(dateObj);
|
||||
expect(formattedSegment2.updatedAt).toBe(dateObj);
|
||||
});
|
||||
|
||||
test("searchForAttributeKeyInSegment", () => {
|
||||
const filter1 = createMockFilter("f1", "attribute"); // key: 'email'
|
||||
const filter2 = createMockFilter("f2", "person");
|
||||
const filter3 = createMockFilter("f3", "attribute");
|
||||
(filter3 as TSegmentAttributeFilter).root.contactAttributeKey = "company";
|
||||
const baseFilter1 = createBaseFilter(filter1, null, "bf1");
|
||||
const baseFilter2 = createBaseFilter(filter2, "and", "bf2");
|
||||
const baseFilter3 = createBaseFilter(filter3, "or", "bf3");
|
||||
const nestedFilter = createMockFilter("nf1", "attribute");
|
||||
(nestedFilter as TSegmentAttributeFilter).root.contactAttributeKey = "role";
|
||||
const nestedBaseFilter = createBaseFilter(nestedFilter, null, "nbf1");
|
||||
const nestedGroup = createBaseFilter([nestedBaseFilter], "and", "ng1");
|
||||
const group: TBaseFilters = [baseFilter1, baseFilter2, nestedGroup, baseFilter3];
|
||||
|
||||
expect(searchForAttributeKeyInSegment(group, "email")).toBe(true);
|
||||
expect(searchForAttributeKeyInSegment(group, "company")).toBe(true);
|
||||
expect(searchForAttributeKeyInSegment(group, "role")).toBe(true);
|
||||
expect(searchForAttributeKeyInSegment(group, "nonExistentKey")).toBe(false);
|
||||
expect(searchForAttributeKeyInSegment([], "anyKey")).toBe(false); // Empty filters
|
||||
});
|
||||
|
||||
test("isAdvancedSegment", () => {
|
||||
const attrFilter = createMockFilter("f_attr", "attribute");
|
||||
const personFilter = createMockFilter("f_person", "person");
|
||||
const deviceFilter = createMockFilter("f_device", "device");
|
||||
const segmentFilter = createMockFilter("f_segment", "segment");
|
||||
|
||||
const baseAttr = createBaseFilter(attrFilter, null);
|
||||
const basePerson = createBaseFilter(personFilter, "and");
|
||||
const baseDevice = createBaseFilter(deviceFilter, "and");
|
||||
const baseSegment = createBaseFilter(segmentFilter, "or");
|
||||
|
||||
// Only attribute/person filters
|
||||
const basicFilters: TBaseFilters = [baseAttr, basePerson];
|
||||
expect(isAdvancedSegment(basicFilters)).toBe(false);
|
||||
|
||||
// Contains a device filter
|
||||
const deviceFilters: TBaseFilters = [baseAttr, baseDevice];
|
||||
expect(isAdvancedSegment(deviceFilters)).toBe(true);
|
||||
|
||||
// Contains a segment filter
|
||||
const segmentFilters: TBaseFilters = [basePerson, baseSegment];
|
||||
expect(isAdvancedSegment(segmentFilters)).toBe(true);
|
||||
|
||||
// Contains a group
|
||||
const nestedGroup = createBaseFilter([baseAttr], "and", "ng1");
|
||||
const groupFilters: TBaseFilters = [basePerson, nestedGroup];
|
||||
expect(isAdvancedSegment(groupFilters)).toBe(true);
|
||||
|
||||
// Empty filters
|
||||
expect(isAdvancedSegment([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -246,13 +246,17 @@ export const deleteResource = (group: TBaseFilters, resourceId: string) => {
|
||||
};
|
||||
|
||||
export const deleteEmptyGroups = (group: TBaseFilters) => {
|
||||
for (let i = 0; i < group.length; i++) {
|
||||
// Iterate backward to safely remove items while iterating
|
||||
for (let i = group.length - 1; i >= 0; i--) {
|
||||
const { resource } = group[i];
|
||||
|
||||
if (!isResourceFilter(resource) && resource.length === 0) {
|
||||
group.splice(i, 1);
|
||||
} else if (!isResourceFilter(resource)) {
|
||||
if (!isResourceFilter(resource)) {
|
||||
// Recursively delete empty groups within the current group first
|
||||
deleteEmptyGroups(resource);
|
||||
// After cleaning the inner group, check if it has become empty
|
||||
if (resource.length === 0) {
|
||||
group.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
38
apps/web/modules/ee/contacts/segments/loading.test.tsx
Normal file
38
apps/web/modules/ee/contacts/segments/loading.test.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Loading from "./loading";
|
||||
|
||||
// Mock the getTranslate function
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
// Mock the ContactsSecondaryNavigation component
|
||||
vi.mock("@/modules/ee/contacts/components/contacts-secondary-navigation", () => ({
|
||||
ContactsSecondaryNavigation: () => <div>ContactsSecondaryNavigation</div>,
|
||||
}));
|
||||
|
||||
describe("Loading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders loading state correctly", async () => {
|
||||
render(await Loading());
|
||||
|
||||
// Check for the presence of the secondary navigation mock
|
||||
expect(screen.getByText("ContactsSecondaryNavigation")).toBeInTheDocument();
|
||||
|
||||
// Check for table headers based on tolgee keys
|
||||
expect(screen.getByText("common.title")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.surveys")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.updated_at")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.created_at")).toBeInTheDocument();
|
||||
|
||||
// Check for the presence of multiple skeleton loaders (at least one)
|
||||
const skeletonLoaders = screen.getAllByRole("generic", { name: "" }); // Assuming skeleton divs don't have specific roles/names
|
||||
// Filter for elements with animate-pulse class
|
||||
const pulseElements = skeletonLoaders.filter((el) => el.classList.contains("animate-pulse"));
|
||||
expect(pulseElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
220
apps/web/modules/ee/contacts/segments/page.test.tsx
Normal file
220
apps/web/modules/ee/contacts/segments/page.test.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
// Import the actual constants module to get its type/shape for mocking
|
||||
import * as constants from "@/lib/constants";
|
||||
import { ContactsSecondaryNavigation } from "@/modules/ee/contacts/components/contacts-secondary-navigation";
|
||||
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
||||
import { SegmentTable } from "@/modules/ee/contacts/segments/components/segment-table";
|
||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { CreateSegmentModal } from "./components/create-segment-modal";
|
||||
import { SegmentsPage } from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/components/contacts-secondary-navigation", () => ({
|
||||
ContactsSecondaryNavigation: vi.fn(() => <div>ContactsSecondaryNavigation</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/lib/contact-attribute-keys", () => ({
|
||||
getContactAttributeKeys: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/segments/components/segment-table", () => ({
|
||||
SegmentTable: vi.fn(() => <div>SegmentTable</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
|
||||
getSegments: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsContactsEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: vi.fn(({ children, cta }) => (
|
||||
<div>
|
||||
PageHeader
|
||||
{cta}
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
|
||||
UpgradePrompt: vi.fn(() => <div>UpgradePrompt</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./components/create-segment-modal", () => ({
|
||||
CreateSegmentModal: vi.fn(() => <div>CreateSegmentModal</div>),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockParams = { environmentId: mockEnvironmentId };
|
||||
const mockSegments = [
|
||||
{ id: "seg1", title: "Segment 1", isPrivate: false, filters: [], surveys: [] },
|
||||
{ id: "seg2", title: "Segment 2", isPrivate: true, filters: [], surveys: [] },
|
||||
{ id: "seg3", title: "Segment 3", isPrivate: false, filters: [], surveys: [] },
|
||||
] as unknown as TSegment[];
|
||||
const mockFilteredSegments = mockSegments.filter((s) => !s.isPrivate);
|
||||
const mockContactAttributeKeys = [{ name: "email", type: "text" } as unknown as TContactAttributeKey];
|
||||
const mockT = vi.fn((key) => key); // Simple mock translation function
|
||||
|
||||
describe("SegmentsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// Explicitly set the mocked constant value before each test if needed,
|
||||
// otherwise it defaults to the value in vi.mock
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
|
||||
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockT);
|
||||
vi.mocked(getSegments).mockResolvedValue(mockSegments);
|
||||
vi.mocked(getContactAttributeKeys).mockResolvedValue(mockContactAttributeKeys);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders segment table and create button when contacts enabled and not read-only", async () => {
|
||||
vi.mocked(getIsContactsEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({ isReadOnly: false } as TEnvironmentAuth);
|
||||
|
||||
const promise = Promise.resolve(mockParams);
|
||||
render(await SegmentsPage({ params: promise }));
|
||||
|
||||
await screen.findByText("PageHeader"); // Wait for async component to render
|
||||
|
||||
expect(screen.getByText("PageHeader")).toBeInTheDocument();
|
||||
expect(screen.getByText("ContactsSecondaryNavigation")).toBeInTheDocument();
|
||||
expect(screen.getByText("CreateSegmentModal")).toBeInTheDocument();
|
||||
expect(screen.getByText("SegmentTable")).toBeInTheDocument();
|
||||
expect(screen.queryByText("UpgradePrompt")).not.toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(PageHeader).mock.calls[0][0].pageTitle).toBe("Contacts");
|
||||
expect(vi.mocked(ContactsSecondaryNavigation).mock.calls[0][0].activeId).toBe("segments");
|
||||
expect(vi.mocked(ContactsSecondaryNavigation).mock.calls[0][0].environmentId).toBe(mockEnvironmentId);
|
||||
expect(vi.mocked(CreateSegmentModal).mock.calls[0][0].environmentId).toBe(mockEnvironmentId);
|
||||
expect(vi.mocked(CreateSegmentModal).mock.calls[0][0].contactAttributeKeys).toEqual(
|
||||
mockContactAttributeKeys
|
||||
);
|
||||
expect(vi.mocked(CreateSegmentModal).mock.calls[0][0].segments).toEqual(mockFilteredSegments);
|
||||
expect(vi.mocked(SegmentTable).mock.calls[0][0].segments).toEqual(mockFilteredSegments);
|
||||
expect(vi.mocked(SegmentTable).mock.calls[0][0].contactAttributeKeys).toEqual(mockContactAttributeKeys);
|
||||
expect(vi.mocked(SegmentTable).mock.calls[0][0].isContactsEnabled).toBe(true);
|
||||
expect(vi.mocked(SegmentTable).mock.calls[0][0].isReadOnly).toBe(false);
|
||||
});
|
||||
|
||||
test("renders segment table without create button when contacts enabled and read-only", async () => {
|
||||
vi.mocked(getIsContactsEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({ isReadOnly: true } as TEnvironmentAuth);
|
||||
|
||||
const promise = Promise.resolve(mockParams);
|
||||
render(await SegmentsPage({ params: promise }));
|
||||
|
||||
await screen.findByText("PageHeader");
|
||||
|
||||
expect(screen.getByText("PageHeader")).toBeInTheDocument();
|
||||
expect(screen.getByText("ContactsSecondaryNavigation")).toBeInTheDocument();
|
||||
expect(screen.queryByText("CreateSegmentModal")).not.toBeInTheDocument(); // CTA should be undefined
|
||||
expect(screen.getByText("SegmentTable")).toBeInTheDocument();
|
||||
expect(screen.queryByText("UpgradePrompt")).not.toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(SegmentTable).mock.calls[0][0].isReadOnly).toBe(true);
|
||||
});
|
||||
|
||||
test("renders upgrade prompt when contacts disabled (Cloud)", async () => {
|
||||
vi.mocked(getIsContactsEnabled).mockResolvedValue(false);
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({ isReadOnly: false } as TEnvironmentAuth);
|
||||
|
||||
const promise = Promise.resolve(mockParams);
|
||||
render(await SegmentsPage({ params: promise }));
|
||||
|
||||
await screen.findByText("PageHeader");
|
||||
|
||||
expect(screen.getByText("PageHeader")).toBeInTheDocument();
|
||||
expect(screen.getByText("ContactsSecondaryNavigation")).toBeInTheDocument();
|
||||
expect(screen.queryByText("CreateSegmentModal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SegmentTable")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("UpgradePrompt")).toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(UpgradePrompt).mock.calls[0][0].title).toBe(
|
||||
"environments.segments.unlock_segments_title"
|
||||
);
|
||||
expect(vi.mocked(UpgradePrompt).mock.calls[0][0].description).toBe(
|
||||
"environments.segments.unlock_segments_description"
|
||||
);
|
||||
expect(vi.mocked(UpgradePrompt).mock.calls[0][0].buttons).toEqual([
|
||||
{
|
||||
text: "common.start_free_trial",
|
||||
href: `/environments/${mockEnvironmentId}/settings/billing`,
|
||||
},
|
||||
{
|
||||
text: "common.learn_more",
|
||||
href: `/environments/${mockEnvironmentId}/settings/billing`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("renders upgrade prompt when contacts disabled (Self-hosted)", async () => {
|
||||
// Modify the mocked constant for this specific test
|
||||
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
|
||||
vi.mocked(getIsContactsEnabled).mockResolvedValue(false);
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({ isReadOnly: false } as TEnvironmentAuth);
|
||||
|
||||
const promise = Promise.resolve(mockParams);
|
||||
render(await SegmentsPage({ params: promise }));
|
||||
|
||||
await screen.findByText("PageHeader");
|
||||
|
||||
expect(screen.getByText("PageHeader")).toBeInTheDocument();
|
||||
expect(screen.getByText("ContactsSecondaryNavigation")).toBeInTheDocument();
|
||||
expect(screen.queryByText("CreateSegmentModal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SegmentTable")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("UpgradePrompt")).toBeInTheDocument();
|
||||
|
||||
expect(vi.mocked(UpgradePrompt).mock.calls[0][0].buttons).toEqual([
|
||||
{
|
||||
text: "common.request_trial_license",
|
||||
href: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
},
|
||||
{
|
||||
text: "common.learn_more",
|
||||
href: "https://formbricks.com/learn-more-self-hosting-license",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("throws error if getSegments returns null", async () => {
|
||||
// Change mockResolvedValue from [] to null to trigger the error condition
|
||||
vi.mocked(getSegments).mockResolvedValue(null as any);
|
||||
vi.mocked(getIsContactsEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({ isReadOnly: false } as TEnvironmentAuth);
|
||||
|
||||
const promise = Promise.resolve(mockParams);
|
||||
await expect(SegmentsPage({ params: promise })).rejects.toThrow("Failed to fetch segments");
|
||||
});
|
||||
});
|
||||
335
apps/web/modules/ee/role-management/actions.test.ts
Normal file
335
apps/web/modules/ee/role-management/actions.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
TUpdateInviteAction,
|
||||
TUpdateMembershipAction,
|
||||
checkRoleManagementPermission,
|
||||
updateInviteAction,
|
||||
updateMembershipAction,
|
||||
} from "@/modules/ee/role-management/actions";
|
||||
import { updateInvite } from "@/modules/ee/role-management/lib/invite";
|
||||
import { updateMembership } from "@/modules/ee/role-management/lib/membership";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
|
||||
|
||||
// Mock constants with getter functions to allow overriding in tests
|
||||
let mockIsFormbricksCloud = false;
|
||||
let mockDisableUserManagement = false;
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
get IS_FORMBRICKS_CLOUD() {
|
||||
return mockIsFormbricksCloud;
|
||||
},
|
||||
get DISABLE_USER_MANAGEMENT() {
|
||||
return mockDisableUserManagement;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getRoleManagementPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/role-management/lib/invite", () => ({
|
||||
updateInvite: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/role-management/lib/membership", () => ({
|
||||
updateMembership: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client", () => ({
|
||||
authenticatedActionClient: {
|
||||
schema: () => ({
|
||||
action: (callback) => callback,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Role Management Actions", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockIsFormbricksCloud = false;
|
||||
mockDisableUserManagement = false;
|
||||
});
|
||||
|
||||
describe("checkRoleManagementPermission", () => {
|
||||
test("throws error if organization not found", async () => {
|
||||
vi.mocked(getOrganization).mockResolvedValue(null);
|
||||
|
||||
await expect(checkRoleManagementPermission("org-123")).rejects.toThrow("Organization not found");
|
||||
});
|
||||
|
||||
test("throws error if role management is not allowed", async () => {
|
||||
vi.mocked(getOrganization).mockResolvedValue({
|
||||
billing: { plan: "free" },
|
||||
} as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
|
||||
|
||||
await expect(checkRoleManagementPermission("org-123")).rejects.toThrow(
|
||||
new OperationNotAllowedError("Role management is not allowed for this organization")
|
||||
);
|
||||
});
|
||||
|
||||
test("succeeds if role management is allowed", async () => {
|
||||
vi.mocked(getOrganization).mockResolvedValue({
|
||||
billing: { plan: "pro" },
|
||||
} as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
|
||||
await expect(checkRoleManagementPermission("org-123")).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateInviteAction", () => {
|
||||
test("throws error if user is not a member of the organization", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
updateInviteAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
inviteId: "invite-123",
|
||||
organizationId: "org-123",
|
||||
data: { role: "member" },
|
||||
},
|
||||
} as unknown as TUpdateInviteAction)
|
||||
).rejects.toThrow(new AuthenticationError("User not a member of this organization"));
|
||||
});
|
||||
|
||||
test("throws error if billing role is not allowed in self-hosted", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
updateInviteAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
inviteId: "invite-123",
|
||||
organizationId: "org-123",
|
||||
data: { role: "billing" },
|
||||
},
|
||||
} as unknown as TUpdateInviteAction)
|
||||
).rejects.toThrow(new ValidationError("Billing role is not allowed"));
|
||||
});
|
||||
|
||||
test("allows billing role in cloud environment", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
vi.mocked(updateInvite).mockResolvedValue({ id: "invite-123", role: "billing" } as any);
|
||||
|
||||
const result = await updateInviteAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
inviteId: "invite-123",
|
||||
organizationId: "org-123",
|
||||
data: { role: "billing" },
|
||||
},
|
||||
} as unknown as TUpdateInviteAction);
|
||||
|
||||
expect(result).toEqual({ id: "invite-123", role: "billing" });
|
||||
});
|
||||
|
||||
test("throws error if manager tries to invite a role other than member", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
updateInviteAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
inviteId: "invite-123",
|
||||
organizationId: "org-123",
|
||||
data: { role: "owner" },
|
||||
},
|
||||
} as unknown as TUpdateInviteAction)
|
||||
).rejects.toThrow(new OperationNotAllowedError("Managers can only invite members"));
|
||||
});
|
||||
|
||||
test("allows manager to invite a member", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
vi.mocked(updateInvite).mockResolvedValue({ id: "invite-123", role: "member" } as any);
|
||||
|
||||
const result = await updateInviteAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
inviteId: "invite-123",
|
||||
organizationId: "org-123",
|
||||
data: { role: "member" },
|
||||
},
|
||||
} as unknown as TUpdateInviteAction);
|
||||
|
||||
expect(result).toEqual({ id: "invite-123", role: "member" });
|
||||
expect(updateInvite).toHaveBeenCalledWith("invite-123", { role: "member" });
|
||||
});
|
||||
|
||||
test("successful invite update as owner", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
vi.mocked(updateInvite).mockResolvedValue({ id: "invite-123", role: "member" } as any);
|
||||
|
||||
const result = await updateInviteAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
inviteId: "invite-123",
|
||||
organizationId: "org-123",
|
||||
data: { role: "member" },
|
||||
},
|
||||
} as unknown as TUpdateInviteAction);
|
||||
|
||||
expect(result).toEqual({ id: "invite-123", role: "member" });
|
||||
expect(updateInvite).toHaveBeenCalledWith("invite-123", { role: "member" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMembershipAction", () => {
|
||||
test("throws error if user is not a member of the organization", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
updateMembershipAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
userId: "user-456",
|
||||
organizationId: "org-123",
|
||||
data: { role: "member" },
|
||||
},
|
||||
} as unknown as TUpdateMembershipAction)
|
||||
).rejects.toThrow(new AuthenticationError("User not a member of this organization"));
|
||||
});
|
||||
|
||||
test("throws error if user management is disabled", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = true;
|
||||
|
||||
await expect(
|
||||
updateMembershipAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
userId: "user-456",
|
||||
organizationId: "org-123",
|
||||
data: { role: "member" },
|
||||
},
|
||||
} as unknown as TUpdateMembershipAction)
|
||||
).rejects.toThrow(new OperationNotAllowedError("User management is disabled"));
|
||||
});
|
||||
|
||||
test("throws error if billing role is not allowed in self-hosted", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
updateMembershipAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
userId: "user-456",
|
||||
organizationId: "org-123",
|
||||
data: { role: "billing" },
|
||||
},
|
||||
} as unknown as TUpdateMembershipAction)
|
||||
).rejects.toThrow(new ValidationError("Billing role is not allowed"));
|
||||
});
|
||||
|
||||
test("allows billing role in cloud environment", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
vi.mocked(updateMembership).mockResolvedValue({ id: "membership-123", role: "billing" } as any);
|
||||
|
||||
const result = await updateMembershipAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
userId: "user-456",
|
||||
organizationId: "org-123",
|
||||
data: { role: "billing" },
|
||||
},
|
||||
} as unknown as TUpdateMembershipAction);
|
||||
|
||||
expect(result).toEqual({ id: "membership-123", role: "billing" });
|
||||
});
|
||||
|
||||
test("throws error if manager tries to assign a role other than member", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
updateMembershipAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
userId: "user-456",
|
||||
organizationId: "org-123",
|
||||
data: { role: "owner" },
|
||||
},
|
||||
} as unknown as TUpdateMembershipAction)
|
||||
).rejects.toThrow(new OperationNotAllowedError("Managers can only assign users to the member role"));
|
||||
});
|
||||
|
||||
test("allows manager to assign member role", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "manager" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
vi.mocked(updateMembership).mockResolvedValue({ id: "membership-123", role: "member" } as any);
|
||||
|
||||
const result = await updateMembershipAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
userId: "user-456",
|
||||
organizationId: "org-123",
|
||||
data: { role: "member" },
|
||||
},
|
||||
} as unknown as TUpdateMembershipAction);
|
||||
|
||||
expect(result).toEqual({ id: "membership-123", role: "member" });
|
||||
expect(updateMembership).toHaveBeenCalledWith("user-456", "org-123", { role: "member" });
|
||||
});
|
||||
|
||||
test("successful membership update as owner", async () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role: "owner" } as any);
|
||||
mockDisableUserManagement = false;
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(true);
|
||||
vi.mocked(getOrganization).mockResolvedValue({ billing: { plan: "pro" } } as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||
vi.mocked(updateMembership).mockResolvedValue({ id: "membership-123", role: "member" } as any);
|
||||
|
||||
const result = await updateMembershipAction({
|
||||
ctx: { user: { id: "user-123" } },
|
||||
parsedInput: {
|
||||
userId: "user-456",
|
||||
organizationId: "org-123",
|
||||
data: { role: "member" },
|
||||
},
|
||||
} as unknown as TUpdateMembershipAction);
|
||||
|
||||
expect(result).toEqual({ id: "membership-123", role: "member" });
|
||||
expect(updateMembership).toHaveBeenCalledWith("user-456", "org-123", { role: "member" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { DISABLE_USER_MANAGEMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
@@ -33,6 +33,8 @@ const ZUpdateInviteAction = z.object({
|
||||
data: ZInviteUpdateInput,
|
||||
});
|
||||
|
||||
export type TUpdateInviteAction = z.infer<typeof ZUpdateInviteAction>;
|
||||
|
||||
export const updateInviteAction = authenticatedActionClient
|
||||
.schema(ZUpdateInviteAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
@@ -86,6 +88,9 @@ export const updateMembershipAction = authenticatedActionClient
|
||||
if (!currentUserMembership) {
|
||||
throw new AuthenticationError("User not a member of this organization");
|
||||
}
|
||||
if (DISABLE_USER_MANAGEMENT) {
|
||||
throw new OperationNotAllowedError("User management is disabled");
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
|
||||
@@ -29,6 +29,7 @@ interface Role {
|
||||
inviteId?: string;
|
||||
doesOrgHaveMoreThanOneOwner?: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isUserManagementDisabledFromUi: boolean;
|
||||
}
|
||||
|
||||
export function EditMembershipRole({
|
||||
@@ -41,6 +42,7 @@ export function EditMembershipRole({
|
||||
inviteId,
|
||||
doesOrgHaveMoreThanOneOwner,
|
||||
isFormbricksCloud,
|
||||
isUserManagementDisabledFromUi,
|
||||
}: Role) {
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
@@ -50,6 +52,7 @@ export function EditMembershipRole({
|
||||
const isOwnerOrManager = isOwner || isManager;
|
||||
|
||||
const disableRole =
|
||||
isUserManagementDisabledFromUi ||
|
||||
memberId === userId ||
|
||||
(memberRole === "owner" && !doesOrgHaveMoreThanOneOwner) ||
|
||||
(currentUserRole === "manager" && memberRole === "owner");
|
||||
|
||||
@@ -83,6 +83,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SAML_DATABASE_URL: "test-saml-db-url",
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
DISABLE_USER_MANAGEMENT: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client-middleware", () => ({
|
||||
|
||||
@@ -22,7 +22,7 @@ export const getRatingNumberOptionColor = (range: number, idx: number): string =
|
||||
const defaultLocale = "en-US";
|
||||
|
||||
const getMessages = (locale: string): Record<string, string> => {
|
||||
const messages = require(`@/lib/messages/${locale}.json`) as {
|
||||
const messages = require(`@/locales/${locale}.json`) as {
|
||||
emails: Record<string, string>;
|
||||
};
|
||||
return messages.emails;
|
||||
|
||||
@@ -11,6 +11,7 @@ interface EditMembershipsProps {
|
||||
currentUserId: string;
|
||||
role: TOrganizationRole;
|
||||
canDoRoleManagement: boolean;
|
||||
isUserManagementDisabledFromUi: boolean;
|
||||
}
|
||||
|
||||
export const EditMemberships = async ({
|
||||
@@ -18,6 +19,7 @@ export const EditMemberships = async ({
|
||||
currentUserId,
|
||||
role,
|
||||
canDoRoleManagement,
|
||||
isUserManagementDisabledFromUi,
|
||||
}: EditMembershipsProps) => {
|
||||
const members = await getMembershipByOrganizationId(organization.id);
|
||||
const invites = await getInvitesByOrganizationId(organization.id);
|
||||
@@ -34,7 +36,9 @@ export const EditMemberships = async ({
|
||||
|
||||
<div className="min-w-[80px] whitespace-nowrap">{t("common.status")}</div>
|
||||
|
||||
<div className="min-w-[125px] whitespace-nowrap">{t("common.actions")}</div>
|
||||
{!isUserManagementDisabledFromUi && (
|
||||
<div className="min-w-[125px] whitespace-nowrap">{t("common.actions")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{role && (
|
||||
@@ -46,6 +50,7 @@ export const EditMemberships = async ({
|
||||
currentUserRole={role}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface MembersInfoProps {
|
||||
currentUserId: string;
|
||||
canDoRoleManagement: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isUserManagementDisabledFromUi: boolean;
|
||||
}
|
||||
|
||||
// Type guard to check if member is an invitee
|
||||
@@ -35,6 +36,7 @@ export const MembersInfo = ({
|
||||
currentUserId,
|
||||
canDoRoleManagement,
|
||||
isFormbricksCloud,
|
||||
isUserManagementDisabledFromUi,
|
||||
}: MembersInfoProps) => {
|
||||
const allMembers = [...members, ...invites];
|
||||
const { t } = useTranslate();
|
||||
@@ -115,17 +117,20 @@ export const MembersInfo = ({
|
||||
inviteId={isInvitee(member) ? member.id : ""}
|
||||
doesOrgHaveMoreThanOneOwner={doesOrgHaveMoreThanOneOwner}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-[80px]">{getMembershipBadge(member)}</div>
|
||||
|
||||
<MemberActions
|
||||
organization={organization}
|
||||
member={!isInvitee(member) ? member : undefined}
|
||||
invite={isInvitee(member) ? member : undefined}
|
||||
showDeleteButton={showDeleteButton(member)}
|
||||
/>
|
||||
{!isUserManagementDisabledFromUi && (
|
||||
<MemberActions
|
||||
organization={organization}
|
||||
member={!isInvitee(member) ? member : undefined}
|
||||
invite={isInvitee(member) ? member : undefined}
|
||||
showDeleteButton={showDeleteButton(member)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ interface OrganizationActionsProps {
|
||||
isFormbricksCloud: boolean;
|
||||
environmentId: string;
|
||||
isMultiOrgEnabled: boolean;
|
||||
isUserManagementDisabledFromUi: boolean;
|
||||
}
|
||||
|
||||
export const OrganizationActions = ({
|
||||
@@ -41,6 +42,7 @@ export const OrganizationActions = ({
|
||||
isFormbricksCloud,
|
||||
environmentId,
|
||||
isMultiOrgEnabled,
|
||||
isUserManagementDisabledFromUi,
|
||||
}: OrganizationActionsProps) => {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
@@ -128,7 +130,7 @@ export const OrganizationActions = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isInviteDisabled && isOwnerOrManager && (
|
||||
{!isInviteDisabled && isOwnerOrManager && !isUserManagementDisabledFromUi && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
|
||||
@@ -17,6 +17,7 @@ interface MembersViewProps {
|
||||
currentUserId: string;
|
||||
environmentId: string;
|
||||
canDoRoleManagement: boolean;
|
||||
isUserManagementDisabledFromUi: boolean;
|
||||
}
|
||||
|
||||
const MembersLoading = () => (
|
||||
@@ -35,6 +36,7 @@ export const MembersView = async ({
|
||||
currentUserId,
|
||||
environmentId,
|
||||
canDoRoleManagement,
|
||||
isUserManagementDisabledFromUi,
|
||||
}: MembersViewProps) => {
|
||||
const t = await getTranslate();
|
||||
|
||||
@@ -68,6 +70,7 @@ export const MembersView = async ({
|
||||
environmentId={environmentId}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
teams={teams}
|
||||
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -78,6 +81,7 @@ export const MembersView = async ({
|
||||
organization={organization}
|
||||
currentUserId={currentUserId}
|
||||
role={membershipRole}
|
||||
isUserManagementDisabledFromUi={isUserManagementDisabledFromUi}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { DISABLE_USER_MANAGEMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -32,6 +32,7 @@ export const TeamsPage = async (props) => {
|
||||
currentUserId={session.user.id}
|
||||
environmentId={params.environmentId}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
isUserManagementDisabledFromUi={DISABLE_USER_MANAGEMENT}
|
||||
/>
|
||||
<TeamsView
|
||||
organizationId={organization.id}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { segmentCache } from "@/lib/cache/segment";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { subscribeOrganizationMembersToSurveyResponses } from "@/modules/survey/components/template-list/lib/organization";
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
@@ -63,6 +64,8 @@ export const createSurvey = async (
|
||||
delete data.followUps;
|
||||
}
|
||||
|
||||
if (data.questions) checkForInvalidImagesInQuestions(data.questions);
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
...data,
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { userCache } from "@/lib/user/cache";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TUser, TUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
// function to update a user's user
|
||||
export const updateUser = async (personId: string, data: TUserUpdateInput): Promise<TUser> => {
|
||||
if (data.imageUrl && !isValidImageFile(data.imageUrl)) throw new InvalidInputError("Invalid image file");
|
||||
|
||||
try {
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { segmentCache } from "@/lib/cache/segment";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
|
||||
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||
import { getOrganizationAIKeys, getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
@@ -26,6 +27,8 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
const { triggers, environmentId, segment, questions, languages, type, followUps, ...surveyData } =
|
||||
updatedSurvey;
|
||||
|
||||
checkForInvalidImagesInQuestions(questions);
|
||||
|
||||
if (languages) {
|
||||
// Process languages update logic here
|
||||
// Extract currentLanguageIds and updatedLanguageIds
|
||||
|
||||
@@ -5,6 +5,7 @@ import { segmentCache } from "@/lib/cache/segment";
|
||||
import { projectCache } from "@/lib/project/cache";
|
||||
import { responseCache } from "@/lib/response/cache";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { checkForInvalidImagesInQuestions } from "@/lib/survey/utils";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { buildOrderByClause, buildWhereClause } from "@/modules/survey/lib/utils";
|
||||
import { doesEnvironmentExist } from "@/modules/survey/list/lib/environment";
|
||||
@@ -528,6 +529,9 @@ export const copySurveyToOtherEnvironment = async (
|
||||
}
|
||||
|
||||
const targetProjectLanguageCodes = targetProject.languages.map((language) => language.code);
|
||||
|
||||
if (surveyData.questions) checkForInvalidImagesInQuestions(surveyData.questions);
|
||||
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: surveyData,
|
||||
select: {
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { segmentCache } from "@/lib/cache/segment";
|
||||
import { capturePosthogEnvironmentEvent } from "@/lib/posthogServer";
|
||||
import { surveyCache } from "@/lib/survey/cache";
|
||||
import { Prisma, Survey } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const createSurvey = async (
|
||||
environmentId: string,
|
||||
surveyBody: Pick<Survey, "name" | "questions">
|
||||
): Promise<{ id: string }> => {
|
||||
try {
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
...surveyBody,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
resultShareKey: true,
|
||||
},
|
||||
});
|
||||
|
||||
// if the survey created is an "app" survey, we also create a private segment for it.
|
||||
if (survey.type === "app") {
|
||||
const newSegment = await prisma.segment.create({
|
||||
data: {
|
||||
title: survey.id,
|
||||
filters: [],
|
||||
isPrivate: true,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.survey.update({
|
||||
where: {
|
||||
id: survey.id,
|
||||
},
|
||||
data: {
|
||||
segment: {
|
||||
connect: {
|
||||
id: newSegment.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
segmentCache.revalidate({
|
||||
id: newSegment.id,
|
||||
environmentId: survey.environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
surveyCache.revalidate({
|
||||
id: survey.id,
|
||||
environmentId: survey.environmentId,
|
||||
resultShareKey: survey.resultShareKey ?? undefined,
|
||||
});
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "survey created", {
|
||||
surveyId: survey.id,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
return { id: survey.id };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error creating survey");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -27,7 +27,6 @@
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@formbricks/database": "workspace:*",
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/js-core": "workspace:*",
|
||||
"@formbricks/i18n-utils": "workspace:*",
|
||||
"@formbricks/logger": "workspace:*",
|
||||
"@formbricks/surveys": "workspace:*",
|
||||
@@ -83,9 +82,7 @@
|
||||
"@unkey/ratelimit": "0.5.5",
|
||||
"@vercel/functions": "2.0.0",
|
||||
"@vercel/og": "0.6.8",
|
||||
"@vercel/otel": "1.10.4",
|
||||
"autoprefixer": "10.4.21",
|
||||
"aws-crt": "1.26.2",
|
||||
"bcryptjs": "3.0.2",
|
||||
"boring-avatars": "1.11.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -94,7 +91,6 @@
|
||||
"csv-parse": "5.6.0",
|
||||
"date-fns": "4.1.0",
|
||||
"dotenv": "16.4.7",
|
||||
"encoding": "0.1.13",
|
||||
"file-loader": "6.2.0",
|
||||
"framer-motion": "12.6.3",
|
||||
"googleapis": "148.0.0",
|
||||
@@ -107,7 +103,6 @@
|
||||
"lru-cache": "11.1.0",
|
||||
"lucide-react": "0.487.0",
|
||||
"markdown-it": "14.1.0",
|
||||
"mime": "4.0.7",
|
||||
"mime-types": "3.0.1",
|
||||
"nanoid": "5.1.5",
|
||||
"next": "15.2.5",
|
||||
@@ -115,8 +110,6 @@
|
||||
"next-safe-action": "7.10.5",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.10.0",
|
||||
"opentelemetry": "0.1.0",
|
||||
"optional": "0.1.4",
|
||||
"otplib": "12.0.1",
|
||||
"papaparse": "5.5.2",
|
||||
"postcss": "8.5.3",
|
||||
@@ -132,9 +125,6 @@
|
||||
"react-dom": "19.1.0",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-hot-toast": "2.5.2",
|
||||
"react-icons": "5.5.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-radio-group": "3.0.3",
|
||||
"react-turnstile": "1.1.4",
|
||||
"react-use": "17.6.0",
|
||||
"redis": "4.7.0",
|
||||
|
||||
@@ -155,11 +155,13 @@ test.describe("Survey Create & Submit Response without logic", async () => {
|
||||
await expect(
|
||||
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
|
||||
).toBeVisible();
|
||||
|
||||
await page.locator("input[type=file]").setInputFiles({
|
||||
name: "file.txt",
|
||||
mimeType: "text/plain",
|
||||
name: "file.doc",
|
||||
mimeType: "application/msword",
|
||||
buffer: Buffer.from("this is test"),
|
||||
});
|
||||
|
||||
await page.getByText("Uploading...").waitFor({ state: "hidden" });
|
||||
await page.locator("#questionCard-8").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
@@ -842,8 +844,8 @@ test.describe("Testing Survey with advanced logic", async () => {
|
||||
page.locator("label").filter({ hasText: "Click or drag to upload files." }).locator("button").nth(0)
|
||||
).toBeVisible();
|
||||
await page.locator("input[type=file]").setInputFiles({
|
||||
name: "file.txt",
|
||||
mimeType: "text/plain",
|
||||
name: "file.doc",
|
||||
mimeType: "application/msword",
|
||||
buffer: Buffer.from("this is test"),
|
||||
});
|
||||
await page.getByText("Uploading...").waitFor({ state: "hidden" });
|
||||
|
||||
@@ -16,12 +16,12 @@ export function TolgeeBase() {
|
||||
apiKey,
|
||||
apiUrl,
|
||||
staticData: {
|
||||
"en-US": () => import("@/lib/messages/en-US.json"),
|
||||
"de-DE": () => import("@/lib/messages/de-DE.json"),
|
||||
"fr-FR": () => import("@/lib/messages/fr-FR.json"),
|
||||
"pt-BR": () => import("@/lib/messages/pt-BR.json"),
|
||||
"pt-PT": () => import("@/lib/messages/pt-PT.json"),
|
||||
"zh-Hant-TW": () => import("@/lib/messages/zh-Hant-TW.json"),
|
||||
"en-US": () => import("@/locales/en-US.json"),
|
||||
"de-DE": () => import("@/locales/de-DE.json"),
|
||||
"fr-FR": () => import("@/locales/fr-FR.json"),
|
||||
"pt-BR": () => import("@/locales/pt-BR.json"),
|
||||
"pt-PT": () => import("@/locales/pt-PT.json"),
|
||||
"zh-Hant-TW": () => import("@/locales/zh-Hant-TW.json"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export default defineConfig({
|
||||
"modules/environments/lib/**/*.ts",
|
||||
"modules/ui/components/post-hog-client/*.tsx",
|
||||
"modules/ee/role-management/components/*.tsx",
|
||||
"modules/ee/role-management/actions.ts",
|
||||
"modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx",
|
||||
"modules/ui/components/alert/*.tsx",
|
||||
"modules/ui/components/environmentId-base-layout/*.tsx",
|
||||
@@ -92,8 +93,7 @@ export default defineConfig({
|
||||
"modules/survey/list/components/survey-card.tsx",
|
||||
"modules/survey/list/components/survey-dropdown-menu.tsx",
|
||||
"modules/survey/follow-ups/components/follow-up-item.tsx",
|
||||
"modules/ee/contacts/segments/lib/**/*.ts",
|
||||
"modules/ee/contacts/segments/components/segment-settings.tsx",
|
||||
"modules/ee/contacts/segments/*",
|
||||
"modules/survey/editor/lib/utils.tsx",
|
||||
"modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact.ts",
|
||||
"modules/ee/sso/components/**/*.tsx",
|
||||
@@ -110,8 +110,11 @@ export default defineConfig({
|
||||
"lib/crypto.ts",
|
||||
"lib/surveyLogic/utils.ts",
|
||||
"lib/utils/billing.ts",
|
||||
"modules/ui/components/card/index.tsx",
|
||||
"lib/fileValidation.ts",
|
||||
"survey/editor/lib/utils.tsx",
|
||||
"modules/ui/components/card/index.tsx"
|
||||
"modules/ui/components/card/index.tsx",
|
||||
"modules/ui/components/card/index.tsx",
|
||||
],
|
||||
exclude: [
|
||||
"**/.next/**",
|
||||
|
||||
@@ -87,7 +87,6 @@ x-environment: &environment
|
||||
# It's used for authentication when uploading source maps to Sentry, to make errors more readable.
|
||||
# SENTRY_AUTH_TOKEN:
|
||||
|
||||
|
||||
################################################### OPTIONAL (STORAGE) ###################################################
|
||||
|
||||
# Set the below to set a custom Upload Directory
|
||||
@@ -189,6 +188,9 @@ x-environment: &environment
|
||||
# DEFAULT_ORGANIZATION_ID:
|
||||
# DEFAULT_ORGANIZATION_ROLE: owner
|
||||
|
||||
# Set the below to 1 to disable the user management UI
|
||||
# DISABLE_USER_MANAGEMENT: 0
|
||||
|
||||
services:
|
||||
postgres:
|
||||
restart: always
|
||||
|
||||
@@ -68,5 +68,5 @@ These variables are present inside your machine’s docker-compose file. Restart
|
||||
| SURVEY_URL | Set this to change the domain of the survey. | optional | WEBAPP_URL
|
||||
| SENTRY_DSN | Set this to track errors and monitor performance in Sentry. | optional |
|
||||
| SENTRY_AUTH_TOKEN | Set this if you want to make errors more readable in Sentry. | optional |
|
||||
|
||||
| DISABLE_USER_MANAGEMENT | Set this to hide the user management UI. | optional |
|
||||
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we’ll try our best to work out a solution with you.
|
||||
|
||||
@@ -391,7 +391,7 @@ resource "kubernetes_manifest" "node_pool" {
|
||||
cpu = 1000
|
||||
}
|
||||
disruption = {
|
||||
consolidationPolicy = "WhenEmpty"
|
||||
consolidationPolicy = "WhenEmptyOrUnderutilized"
|
||||
consolidateAfter = "30s"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@paralleldrive/cuid2": "2.2.2",
|
||||
"prisma": "6.6.0",
|
||||
"prisma-dbml-generator": "0.12.0",
|
||||
"prisma-json-types-generator": "3.2.3",
|
||||
"ts-node": "10.9.2",
|
||||
"zod": "3.24.1"
|
||||
|
||||
@@ -12,10 +12,6 @@ generator client {
|
||||
previewFeatures = ["postgresqlExtensions"]
|
||||
}
|
||||
|
||||
// generator dbml {
|
||||
// provider = "prisma-dbml-generator"
|
||||
// }
|
||||
|
||||
generator json {
|
||||
provider = "prisma-json-types-generator"
|
||||
}
|
||||
|
||||
@@ -51,16 +51,6 @@ export class Config {
|
||||
// This is a hack to get around the fact that we don't have a proper
|
||||
// way to validate the config yet.
|
||||
const parsedConfig = JSON.parse(savedConfig) as TConfig;
|
||||
|
||||
// check if the config has expired
|
||||
if (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- In case of an error, we don't have environment
|
||||
parsedConfig.environment?.expiresAt &&
|
||||
new Date(parsedConfig.environment.expiresAt) <= new Date()
|
||||
) {
|
||||
return err(new Error("Config in local storage has expired"));
|
||||
}
|
||||
|
||||
return ok(parsedConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export const setup = async (
|
||||
|
||||
const expiresAt = existingConfig.status.expiresAt;
|
||||
|
||||
if (expiresAt && isNowExpired(expiresAt)) {
|
||||
if (expiresAt && isNowExpired(new Date(expiresAt))) {
|
||||
console.error("🧱 Formbricks - Error state is not expired, skipping initialization");
|
||||
return okVoid();
|
||||
}
|
||||
@@ -164,13 +164,15 @@ export const setup = async (
|
||||
let isEnvironmentStateExpired = false;
|
||||
let isUserStateExpired = false;
|
||||
|
||||
if (isNowExpired(existingConfig.environment.expiresAt)) {
|
||||
const environmentStateExpiresAt = new Date(existingConfig.environment.expiresAt);
|
||||
|
||||
if (isNowExpired(environmentStateExpiresAt)) {
|
||||
logger.debug("Environment state expired. Syncing.");
|
||||
isEnvironmentStateExpired = true;
|
||||
}
|
||||
|
||||
if (existingConfig.user.expiresAt && isNowExpired(existingConfig.user.expiresAt)) {
|
||||
logger.debug("Person state expired. Syncing.");
|
||||
if (existingConfig.user.expiresAt && isNowExpired(new Date(existingConfig.user.expiresAt))) {
|
||||
logger.debug("User state expired. Syncing.");
|
||||
isUserStateExpired = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,21 +57,13 @@ describe("Config", () => {
|
||||
expect(getItemMock).toHaveBeenCalledWith(JS_LOCAL_STORAGE_KEY);
|
||||
});
|
||||
|
||||
test("loadFromStorage() returns err if config is expired", () => {
|
||||
const expiredConfig = {
|
||||
...mockConfig,
|
||||
environment: {
|
||||
...mockConfig.environment,
|
||||
expiresAt: new Date("2000-01-01T00:00:00Z"),
|
||||
},
|
||||
};
|
||||
|
||||
getItemMock.mockReturnValueOnce(JSON.stringify(expiredConfig));
|
||||
test("loadFromStorage() returns err if config is not saved", () => {
|
||||
getItemMock.mockReturnValueOnce(null);
|
||||
|
||||
const result = configInstance.loadFromLocalStorage();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toBe("Config in local storage has expired");
|
||||
expect(result.error.message).toBe("No or invalid config in local storage");
|
||||
}
|
||||
|
||||
expect(getItemMock).toHaveBeenCalledWith(JS_LOCAL_STORAGE_KEY);
|
||||
|
||||
@@ -188,9 +188,7 @@ export const shouldDisplayBasedOnPercentage = (displayPercentage: number): boole
|
||||
return randomNum <= displayPercentage;
|
||||
};
|
||||
|
||||
export const isNowExpired = (expirationDate: Date): boolean => {
|
||||
return new Date() >= expirationDate;
|
||||
};
|
||||
export const isNowExpired = (expirationDate: Date): boolean => new Date() >= expirationDate;
|
||||
|
||||
export const checkUrlMatch = (
|
||||
url: string,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/react.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"import/no-relative-packages": "off",
|
||||
},
|
||||
};
|
||||
28
packages/react-native/.gitignore
vendored
28
packages/react-native/.gitignore
vendored
@@ -1,28 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# build
|
||||
dist
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
@@ -1,9 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Formbricks GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Formbricks React Native SDK
|
||||
|
||||
[](https://www.npmjs.com/package/@formbricks/react-native)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
Please see [Formbricks Docs](https://formbricks.com/docs).
|
||||
Specifically, [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).
|
||||
|
||||
## What is Formbricks
|
||||
|
||||
Formbricks is your go-to solution for in-product micro-surveys that will supercharge your product experience! 🚀 For more information please check out [formbricks.com](https://formbricks.com).
|
||||
|
||||
## How to use this library
|
||||
|
||||
1. Install the Formbricks package inside your project using npm:
|
||||
|
||||
```bash
|
||||
npm install @formbricks/react-native
|
||||
```
|
||||
|
||||
1. Import Formbricks and initialize the widget in your main component (e.g., App.tsx or App.js):
|
||||
|
||||
```javascript
|
||||
import Formbricks, { track } from "@formbricks/react-native";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<View>
|
||||
{/* Your app code */}
|
||||
<Formbricks appUrl="https://app.formbricks.com" environmentId="your-environment-id" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Replace your-environment-id with your actual environment ID. You can find your environment ID in the **Connections instructions** in the Formbricks **Configuration** pages.
|
||||
|
||||
For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"name": "@formbricks/react-native",
|
||||
"version": "2.1.2",
|
||||
"license": "MIT",
|
||||
"description": "Formbricks React Native SDK allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/formbricks/formbricks"
|
||||
},
|
||||
"keywords": [
|
||||
"Formbricks",
|
||||
"surveys",
|
||||
"experience management",
|
||||
"react native",
|
||||
"sdk"
|
||||
],
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && vite build",
|
||||
"build:dev": "tsc && vite build --mode dev",
|
||||
"lint": "eslint src --ext .ts,.js,.tsx,.jsx",
|
||||
"dev": "vite build --watch --mode dev",
|
||||
"clean": "rimraf .turbo node_modules dist .turbo",
|
||||
"test": "vitest",
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"zod": "3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@types/react": "18.3.1",
|
||||
"@vitest/coverage-v8": "3.1.1",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.74.5",
|
||||
"terser": "5.37.0",
|
||||
"vite": "6.2.5",
|
||||
"vite-plugin-dts": "4.5.3",
|
||||
"vitest": "3.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-native-async-storage/async-storage": ">=2.1.0",
|
||||
"react": ">=16.8.0",
|
||||
"react-native": ">=0.60.0",
|
||||
"react-native-webview": ">=13.0.0"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user