Compare commits

...

51 Commits

Author SHA1 Message Date
pandeymangg
9c0306c27b Merge branch 'main' into fix/bulk-contacts 2025-04-18 15:03:11 +05:30
victorvhs017
81d717ccff fix: iOS SDK memory leaks (#5388)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-17 18:03:05 +00:00
Dhruwang Jariwala
2e979c7323 fix: managers should not be allowed to create api keys (#5409) 2025-04-17 14:12:55 +00:00
Anshuman Pandey
4dfd15d6dd fix: adds no-cache header when debug mode is ON (#5405) 2025-04-17 09:03:44 +00:00
Matti Nannt
5b9bf3ff43 chore(infra): increase cloudwatch elb alarm limit to 10 (#5407) 2025-04-17 06:41:13 +00:00
Anshuman Pandey
d2f7485098 feat: advanced follow ups (#5340)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-04-17 06:39:22 +00:00
Dhruwang Jariwala
f8fee1fba7 fix: refactor end screen card description ux (#5386)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-17 06:28:18 +00:00
Matti Nannt
19249ca00f chore: enable performance insights for rds (#5404) 2025-04-17 06:27:34 +00:00
Anshuman Pandey
01e5700340 fix: adds eslint rules for using test and refactors the current tests (#5397) 2025-04-17 03:32:03 +00:00
Johannes
ff2f7660a6 chore: add segment id to modal view (#5391)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-17 02:56:32 +00:00
Johannes
2bc05e2b4a docs: tweak quick start guides (#5403) 2025-04-16 19:38:54 -07:00
Johannes
137c6447b7 docs: tweak Data Prefilling docs for clarity (#5402) 2025-04-16 18:43:00 -07:00
Piyush Gupta
ebc8f0c917 fix: docker build test (#5396) 2025-04-16 12:32:38 +00:00
Anshuman Pandey
5a8d10b5b4 fix: removes the onFinished callbacks from the iOS package (#5384) 2025-04-16 11:11:30 +00:00
Anshuman Pandey
875815fb62 fix: fixes cb urls (#5392) 2025-04-16 04:47:14 +00:00
Dhruwang Jariwala
cdf526e130 fix: type issue in notion integration (#5385) 2025-04-16 04:15:27 +00:00
Dhruwang Jariwala
b685032b34 chore: make env permissions optional in api key (#5309)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-15 11:42:48 +00:00
Vijay
a171f9cb00 fix: security hotspots in Dockerfile (#5314) 2025-04-15 04:15:56 -07:00
Dhruwang Jariwala
c452f05ec2 feat: questionid to summary (#5381) 2025-04-15 05:58:10 +00:00
Dhruwang Jariwala
93d91f80f2 fix: progress bar calculation (#5339) 2025-04-15 00:51:47 +00:00
Piyush Gupta
7b764c8427 fix: adds api_key label to the view permission modal (#5326) 2025-04-14 08:53:14 +00:00
Piyush Gupta
016289c8cb fix: download responses button label (#5324)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-14 07:10:40 +00:00
Dhruwang Jariwala
93a9575389 fix: missing translation in api key modal (#5341) 2025-04-14 07:02:43 +00:00
victorvhs017
9e265adf14 chore: add test files to modules/account and modules/analysis (#5294)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-13 19:04:56 +00:00
Dhruwang Jariwala
eb08a0ed14 fix: buttonLabel conditions (#5336)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-13 08:36:59 +00:00
Dhruwang Jariwala
c533f37983 chore: improve accessibility for matrix question (#5320)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-04-12 04:40:24 +00:00
Anshuman Pandey
ca4f8385e4 fix: adds FormbricksEnvironment struct for url constants (#5312) 2025-04-11 13:44:25 +00:00
Matti Nannt
3eb9aa74ed chore: upgrade typescript and react dependencies (#5317) 2025-04-11 13:01:54 +02:00
Piyush Gupta
637b51464c docs: updates the API keys docs in API reference (#5319) 2025-04-11 08:46:04 +00:00
Dhruwang Jariwala
fd9585a66e fix: respondent should not see redirect card text (#5239)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-11 04:22:25 +00:00
Matti Nannt
49ecbcb0c9 fix: updatedAt not set in response update (#5315) 2025-04-10 11:04:42 +00:00
Piyush Gupta
1132bdd66a fix: openAPI spec for contact endpoints (#5247)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-10 10:22:40 +00:00
Anshuman Pandey
c7d6ed9ea3 chore: removes api package and deps (#5251)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-04-10 09:41:39 +00:00
Matti Nannt
782528f169 chore: update surveys package npm dependencies (#5302) 2025-04-10 10:44:56 +02:00
Piyush Gupta
104c78275f docs: fixes framework guide link (#5307) 2025-04-10 08:11:40 +00:00
Matti Nannt
d9d88f7175 chore: update eslint npm dependencies (#5313) 2025-04-10 10:22:58 +02:00
Dhruwang Jariwala
bf7e24cf11 fix: stripe issue for customers with existing stripe ID (#5308) 2025-04-10 07:56:01 +00:00
Anshuman Pandey
c8aba01db3 fix: adds isWebEnvironment check in the surveys package (#5310) 2025-04-10 09:01:36 +02:00
Piyush Gupta
a896c7e46e docs: updated API playground link in the webhooks docs (#5301) 2025-04-09 08:33:36 +00:00
Matti Nannt
8018ec14a2 chore: use remote turbocache for building formbricks (#5305) 2025-04-09 10:38:17 +02:00
victorvhs017
9c3208c860 chore: Refactored the Formbricks next public env variables and added test files (#5014)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 08:10:32 +00:00
Anshuman Pandey
e1063964cf fix: fixes segment self referencing issue (#5254)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-09 06:58:28 +00:00
victorvhs017
38568738cc feat: Added test configuration and initial test files to the surveys package (#5253)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-09 06:53:16 +00:00
Piyush Gupta
15b8358b14 fix: date format in response table (#5304) 2025-04-09 05:39:57 +00:00
Anshuman Pandey
2173cb2610 fix: removes sourcemaps (#5257) 2025-04-09 04:50:56 +00:00
Matti Nannt
87b925d622 chore: update apps/web npm dependencies (#5300) 2025-04-09 06:58:53 +02:00
Piyush Gupta
885b06cc26 fix: adds date value check in date question summary (#5296) 2025-04-09 04:07:39 +00:00
Matti Nannt
adb6a5f41e chore: upgrade npm dependencies (#5299) 2025-04-09 04:47:07 +02:00
Matti Nannt
3b815e22e3 chore: add docker build check github action (#4875)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-04-08 13:26:48 +00:00
Matti Nannt
4d4a5c0e64 fix: solve sonarqube security hotspots (#5292) 2025-04-08 14:58:24 +02:00
pandeymangg
62960686a1 fix: bulk contacts refactors 2025-04-08 11:32:00 +05:30
347 changed files with 14920 additions and 8471 deletions

View File

@@ -155,9 +155,8 @@ STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks
NEXT_PUBLIC_FORMBRICKS_API_HOST=
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
FORMBRICKS_API_HOST=
FORMBRICKS_ENVIRONMENT_ID=
# Oauth credentials for Google sheet integration
GOOGLE_SHEETS_CLIENT_ID=

View File

@@ -8,6 +8,14 @@ on:
required: false
default: "0"
inputs:
turbo_token:
description: "Turborepo token"
required: false
turbo_team:
description: "Turborepo team"
required: false
runs:
using: "composite"
steps:
@@ -62,6 +70,8 @@ runs:
- run: |
pnpm build --filter=@formbricks/web...
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
env:
TURBO_TOKEN: ${{ inputs.turbo_token }}
TURBO_TEAM: ${{ inputs.turbo_team }}

View File

@@ -4,7 +4,7 @@ on:
permissions:
contents: read
jobs:
build:
name: Build Formbricks-web
@@ -25,3 +25,5 @@ jobs:
id: cache-build-web
with:
e2e_testing_mode: "0"
turbo_token: ${{ secrets.TURBO_TOKEN }}
turbo_team: ${{ vars.TURBO_TEAM }}

View File

@@ -0,0 +1,167 @@
name: Docker Build Validation
on:
pull_request:
branches:
- main
merge_group:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
jobs:
validate-docker-build:
name: Validate Docker Build
runs-on: ubuntu-latest
# Add PostgreSQL service container
services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: formbricks
ports:
- 5432:5432
# Health check to ensure PostgreSQL is ready before using it
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout Repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Verify PostgreSQL Connection
run: |
echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
# Show network configuration
echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Test Docker Image with Health Check
shell: bash
run: |
echo "🧪 Testing if the Docker image starts correctly..."
# Add extra docker run args to support host.docker.internal on Linux
DOCKER_RUN_ARGS="--add-host=host.docker.internal:host-gateway"
# Start the container with host.docker.internal pointing to the host
docker run --name formbricks-test \
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
# Give it more time to start up
echo "Waiting 45 seconds for application to start..."
sleep 45
# Check if the container is running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
echo "❌ Container failed to start properly!"
docker logs formbricks-test
exit 1
else
echo "✅ Container started successfully!"
fi
# Try connecting to PostgreSQL from inside the container
echo "Testing PostgreSQL connection from inside container..."
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
# Try to access the health endpoint
echo "🏥 Testing /health endpoint..."
MAX_RETRIES=10
RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false
set +e # Disable exit on error to allow for retries
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Show container logs before each attempt to help debugging
if [ $RETRY_COUNT -gt 1 ]; then
echo "📋 Current container logs:"
docker logs --tail 20 formbricks-test
fi
# Get detailed curl output for debugging
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
CURL_EXIT_CODE=$?
echo "Curl exit code: $CURL_EXIT_CODE"
echo "Curl output: $HTTP_OUTPUT"
if [ $CURL_EXIT_CODE -eq 0 ]; then
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
echo "Status code detected: $STATUS_CODE"
if [ "$STATUS_CODE" = "200" ]; then
echo "✅ Health check successful!"
HEALTH_CHECK_SUCCESS=true
break
else
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
fi
else
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
fi
echo "Waiting 15 seconds before next attempt..."
sleep 15
done
# Show full container logs for debugging
echo "📋 Full container logs:"
docker logs formbricks-test
# Clean up the container
echo "🧹 Cleaning up..."
docker rm -f formbricks-test
# Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $MAX_RETRIES attempts"
exit 1
fi
echo "✨ Docker validation complete - all checks passed!"

View File

@@ -16,6 +16,8 @@ on:
env:
TELEMETRY_DISABLED: 1
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
permissions:
id-token: write

View File

@@ -1,4 +1,13 @@
{
"github.copilot.chat.codeGeneration.instructions": [
{
"text": "When generating tests, always use vitest and use the `test` function instead of `it`."
}
],
"sonarlint.connectedMode.project": {
"connectionId": "formbricks",
"projectKey": "formbricks_formbricks"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -18,7 +18,7 @@
"expo-status-bar": "2.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.78.2",
"react-native": "0.76.6",
"react-native-webview": "13.12.5"
},
"devDependencies": {

View File

@@ -27,7 +27,7 @@ const secondaryNavigation = [
export function Sidebar(): React.JSX.Element {
return (
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pt-5 pb-4">
<nav
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
aria-label="Sidebar">
@@ -38,7 +38,7 @@ export function Sidebar(): React.JSX.Element {
href={item.href}
className={classNames(
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium"
)}
aria-current={item.current ? "page" : undefined}>
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
@@ -52,7 +52,7 @@ export function Sidebar(): React.JSX.Element {
<a
key={item.name}
href={item.href}
className="group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6 text-cyan-100 hover:bg-cyan-600 hover:text-white">
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
{item.name}
</a>

View File

@@ -17,8 +17,8 @@
"lucide-react": "0.486.0",
"next": "15.2.4",
"postcss": "8.5.3",
"react": "19.0.0",
"react-dom": "19.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"tailwindcss": "4.1.3"
},
"devDependencies": {

View File

@@ -96,10 +96,10 @@ export default function AppPage(): React.JSX.Element {
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="rounded-xs mt-4" priority />
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded-xs" priority />
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
<p className="mb-1 sm:mb-0 sm:mr-2">You&apos;re connected with env:</p>
<p className="mb-1 sm:mr-2 sm:mb-0">You&apos;re connected with env:</p>
<div className="flex items-center">
<strong className="w-32 truncate sm:w-auto">
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}

View File

@@ -27,14 +27,14 @@
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.29.0",
"@typescript-eslint/parser": "8.29.0",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@vitejs/plugin-react": "4.3.4",
"esbuild": "0.25.2",
"eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
"storybook": "8.6.12",
"tsup": "8.4.0",
"vite": "6.2.4"
"vite": "6.2.5"
}
}

View File

@@ -22,7 +22,7 @@ RUN npm install -g corepack@latest
RUN corepack enable
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
# BuildKit secret handling without hardcoded fallback values
# This approach relies entirely on secrets passed from GitHub Actions
@@ -40,8 +40,6 @@ RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
ARG SENTRY_AUTH_TOKEN
# Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096"
ENV NODE_OPTIONS=${NODE_OPTIONS}
@@ -83,35 +81,65 @@ RUN corepack enable
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
&& addgroup -S nextjs \
&& adduser -S -u 1001 -G nextjs nextjs
WORKDIR /home/nextjs
# Ensure no write permissions are assigned to the copied resources
COPY --from=installer /app/apps/web/.next/standalone ./
RUN chown -R nextjs:nextjs ./ && chmod -R 755 ./
COPY --from=installer /app/apps/web/next.config.mjs .
RUN chmod 644 ./next.config.mjs
COPY --from=installer /app/apps/web/package.json .
# Leverage output traces to reduce image size
RUN chmod 644 ./package.json
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/node_modules ./packages/database/node_modules
COPY --from=installer --chown=nextjs:nextjs /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/apps/web/.next/static ./apps/web/.next/static
RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.next/static
# Copy Prisma-specific generated files
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=installer /app/apps/web/public ./apps/web/public
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
COPY --from=installer /prisma_version.txt .
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
COPY /docker/cronjobs /app/docker/cronjobs
RUN chmod -R 755 /app/docker/cronjobs
# Copy required dependencies
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g tsx typescript prisma pino-pretty

View File

@@ -112,7 +112,7 @@ export const LandingSidebar = ({
{/* Dropdown Items */}
{dropdownNavigation.map((link) => (
<Link href={link.href} target={link.target} className="flex w-full items-center">
<Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}

View File

@@ -3,7 +3,7 @@ import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { getOrganization } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
@@ -71,7 +71,7 @@ describe("ProjectOnboardingLayout", () => {
cleanup();
});
it("redirects to /auth/login if there is no session", async () => {
test("redirects to /auth/login if there is no session", async () => {
// Mock no session
vi.mocked(getServerSession).mockResolvedValueOnce(null);
@@ -85,7 +85,7 @@ describe("ProjectOnboardingLayout", () => {
expect(layoutElement).toBeUndefined();
});
it("throws an error if user does not exist", async () => {
test("throws an error if user does not exist", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
@@ -99,7 +99,7 @@ describe("ProjectOnboardingLayout", () => {
).rejects.toThrow("common.user_not_found");
});
it("throws AuthorizationError if user cannot access organization", async () => {
test("throws AuthorizationError if user cannot access organization", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false);
@@ -112,7 +112,7 @@ describe("ProjectOnboardingLayout", () => {
).rejects.toThrow("common.not_authorized");
});
it("throws an error if organization does not exist", async () => {
test("throws an error if organization does not exist", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
@@ -126,7 +126,7 @@ describe("ProjectOnboardingLayout", () => {
).rejects.toThrow("common.organization_not_found");
});
it("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => {
test("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => {
// Provide valid data
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser);

View File

@@ -1,191 +1,120 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth";
import { redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { afterEach, describe, expect, test, vi } from "vitest";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { TEnvironment } from "@formbricks/types/environment";
import { AuthorizationError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import SurveyEditorEnvironmentLayout from "./layout";
// mock all dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
// Mock sub-components to render identifiable elements
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
<div data-testid="EnvironmentIdBaseLayout">
{environmentId}
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: any) => (
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
// Mocks for dependencies
vi.mock("@/modules/environments/lib/utils", () => ({
environmentIdLayoutChecks: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
return (key: string) => key; // trivial translator returning the key
}),
}));
// mock child components rendered by the layout:
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) => (
<div data-testid="dev-environment-banner">{environment?.id || "no-env"}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
describe("SurveyEditorEnvironmentLayout", () => {
beforeEach(() => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("redirects to /auth/login if there is no session", async () => {
// Mock no session
vi.mocked(getServerSession).mockResolvedValueOnce(null);
test("renders successfully when environment is found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getEnvironment).mockResolvedValueOnce({ id: "env1" } as TEnvironment);
const layoutElement = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello!</div>,
const result = await SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Survey Editor Content</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
// No JSX is returned after redirect
expect(layoutElement).toBeUndefined();
render(result);
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
});
it("throws error if user does not exist in DB", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello!</div>,
})
).rejects.toThrow("common.user_not_found");
});
it("throws AuthorizationError if user does not have environment access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div>Child</div>,
})
).rejects.toThrow(AuthorizationError);
});
it("throws if no organization is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello from children!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("throws if no environment is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
test("throws an error when environment is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(
SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div>Child</div>,
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.environment_not_found");
});
it("renders environment layout if everything is valid", async () => {
// Provide all valid data
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getEnvironment).mockResolvedValueOnce({
id: "env-123",
name: "My Test Environment",
} as unknown as TEnvironment);
// Because it's an async server component, we typically wrap in act(...)
let layoutElement: React.ReactNode;
await act(async () => {
layoutElement = await SurveyEditorEnvironmentLayout({
params: { environmentId: "env-123" },
children: <div data-testid="child-content">Hello from children!</div>,
});
render(layoutElement);
test("calls redirect when session is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: undefined as unknown as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
// Now confirm we got the child plus all the mocked sub-components
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
expect(screen.getByTestId("dev-environment-banner")).toHaveTextContent("env-123");
await expect(
SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("Redirect called");
});
test("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
await expect(
SurveyEditorEnvironmentLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
});
});

View File

@@ -1,46 +1,24 @@
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
const SurveyEditorEnvironmentLayout = async (props) => {
const params = await props.params;
const { children } = props;
const t = await getTranslate();
const session = await getServerSession(authOptions);
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session?.user) {
if (!session) {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) {
throw new Error(t("common.user_not_found"));
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new AuthorizationError(t("common.not_authorized"));
}
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
@@ -48,23 +26,16 @@ const SurveyEditorEnvironmentLayout = async (props) => {
}
return (
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
</ResponseFilterProvider>
</EnvironmentIdBaseLayout>
);
};

View File

@@ -1,5 +1,5 @@
import { render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import formbricks from "@formbricks/js";
import { FormbricksClient } from "./FormbricksClient";
@@ -9,14 +9,6 @@ vi.mock("next/navigation", () => ({
useSearchParams: () => new URLSearchParams("foo=bar"),
}));
// Mock the environment variables.
vi.mock("@formbricks/lib/env", () => ({
env: {
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: "env-test",
NEXT_PUBLIC_FORMBRICKS_API_HOST: "https://api.test.com",
},
}));
// Mock the flag that enables Formbricks.
vi.mock("@/app/lib/formbricks", () => ({
formbricksEnabled: true,
@@ -34,17 +26,21 @@ vi.mock("@formbricks/js", () => ({
}));
describe("FormbricksClient", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
const mockSetup = vi.spyOn(formbricks, "setup");
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="user-123" email="test@example.com" />);
render(
<FormbricksClient
userId="user-123"
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Expect the first effect to call setup and assign the provided user details.
expect(mockSetup).toHaveBeenCalledWith({
@@ -64,7 +60,15 @@ describe("FormbricksClient", () => {
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
render(<FormbricksClient userId="" email="test@example.com" />);
render(
<FormbricksClient
userId=""
email="test@example.com"
formbricksEnvironmentId="env-test"
formbricksApiHost="https://api.test.com"
formbricksEnabled={true}
/>
);
// Since userId is falsy, the first effect should not call setup or assign user details.
expect(mockSetup).not.toHaveBeenCalled();

View File

@@ -1,32 +1,44 @@
"use client";
import { formbricksEnabled } from "@/app/lib/formbricks";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect } from "react";
import formbricks from "@formbricks/js";
import { env } from "@formbricks/lib/env";
export const FormbricksClient = ({ userId, email }: { userId: string; email: string }) => {
interface FormbricksClientProps {
userId: string;
email: string;
formbricksEnvironmentId?: string;
formbricksApiHost?: string;
formbricksEnabled?: boolean;
}
export const FormbricksClient = ({
userId,
email,
formbricksEnvironmentId,
formbricksApiHost,
formbricksEnabled,
}: FormbricksClientProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
if (formbricksEnabled && userId) {
formbricks.setup({
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
appUrl: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
environmentId: formbricksEnvironmentId ?? "",
appUrl: formbricksApiHost ?? "",
});
formbricks.setUserId(userId);
formbricks.setEmail(email);
}
}, [userId, email]);
}, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]);
useEffect(() => {
if (formbricksEnabled) {
formbricks.registerRouteChange();
}
}, [pathname, searchParams]);
}, [pathname, searchParams, formbricksEnabled]);
return null;
};

View File

@@ -265,7 +265,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -2,7 +2,7 @@ import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { PosthogIdentify } from "./PosthogIdentify";
@@ -18,7 +18,7 @@ describe("PosthogIdentify", () => {
cleanup();
});
it("identifies the user and sets groups when isPosthogEnabled is true", () => {
test("identifies the user and sets groups when isPosthogEnabled is true", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();
@@ -72,7 +72,7 @@ describe("PosthogIdentify", () => {
});
});
it("does nothing if isPosthogEnabled is false", () => {
test("does nothing if isPosthogEnabled is false", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();
@@ -95,7 +95,7 @@ describe("PosthogIdentify", () => {
expect(mockGroup).not.toHaveBeenCalled();
});
it("does nothing if session user is missing", () => {
test("does nothing if session user is missing", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();
@@ -120,7 +120,7 @@ describe("PosthogIdentify", () => {
expect(mockGroup).not.toHaveBeenCalled();
});
it("identifies user but does not group if environmentId/organizationId not provided", () => {
test("identifies user but does not group if environmentId/organizationId not provided", () => {
const mockIdentify = vi.fn();
const mockGroup = vi.fn();

View File

@@ -25,6 +25,8 @@ export const TYPE_MAPPING = {
[TSurveyQuestionTypeEnum.Address]: ["rich_text"],
[TSurveyQuestionTypeEnum.Matrix]: ["rich_text"],
[TSurveyQuestionTypeEnum.Cal]: ["checkbox"],
[TSurveyQuestionTypeEnum.ContactInfo]: ["rich_text"],
[TSurveyQuestionTypeEnum.Ranking]: ["rich_text"],
};
export const UNSUPPORTED_TYPES_BY_NOTION = [

View File

@@ -1,250 +1,156 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import EnvLayout from "./layout";
// mock all the dependencies
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
// Mock sub-components to render identifiable elements
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>,
}));
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
<div data-testid="EnvironmentIdBaseLayout">
{environmentId}
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="ToasterClient" />,
}));
vi.mock("../../components/FormbricksClient", () => ({
FormbricksClient: ({ userId, email }: any) => (
<div data-testid="FormbricksClient">
{userId}-{email}
</div>
),
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => {
return (key: string) => {
return key;
};
}),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@formbricks/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
// Mocks for dependencies
vi.mock("@/modules/environments/lib/utils", () => ({
environmentIdLayoutChecks: vi.fn(),
}));
vi.mock("@formbricks/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@formbricks/lib/aiModels", () => ({
llmModel: {},
}));
// mock all the components that are rendered in the layout
vi.mock("./components/PosthogIdentify", () => ({
PosthogIdentify: () => <div data-testid="posthog-identify" />,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
FormbricksClient: () => <div data-testid="formbricks-client" />,
}));
vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="mock-toaster" />,
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: () => <div data-testid="mock-storage-handler" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
ResponseFilterProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-response-filter-provider">{children}</div>
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: { children: React.ReactNode }) => (
<div data-testid="mock-environment-result">{children}</div>
),
vi.mock("@formbricks/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
describe("EnvLayout", () => {
beforeEach(() => {
afterEach(() => {
cleanup();
});
it("redirects to /auth/login if there is no session", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
// Since it's an async server component, call EnvLayout yourself:
const layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello!</div>,
test("renders successfully when all dependencies return valid data", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
// Because we have no session, we expect a redirect to "/auth/login"
expect(redirect).toHaveBeenCalledWith("/auth/login");
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Content</div>,
});
render(result);
// If your code calls redirect() early and returns no JSX,
// layoutElement might be undefined or null.
expect(layoutElement).toBeUndefined();
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
expect(screen.getByTestId("child")).toHaveTextContent("Content");
});
it("redirects to /auth/login if user does not exist in DB", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
test("throws error if project is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getUser).mockResolvedValueOnce(null); // user not found
const layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello!</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(layoutElement).toBeUndefined();
});
it("throws AuthorizationError if user does not have environment access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
})
).rejects.toThrow(AuthorizationError);
});
it("throws if no organization is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello from children!</div>,
})
).rejects.toThrow("common.organization_not_found");
});
it("throws if no project is found", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("project_not_found");
).rejects.toThrow("common.project_not_found");
});
it("calls notFound if membership is missing", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
test("throws error if membership is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div>Child</div>,
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("membership_not_found");
).rejects.toThrow("common.membership_not_found");
});
it("renders environment layout if everything is valid", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({
user: { id: "user-123" },
test("calls redirect when session is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: undefined as unknown as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getUser).mockResolvedValueOnce({
id: "user-123",
email: "test@example.com",
} as TUser);
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValueOnce({ id: "org-999" } as TOrganization);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj-111" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "membership-123",
} as unknown as TMembership);
let layoutElement: React.ReactNode;
await act(async () => {
layoutElement = await EnvLayout({
params: Promise.resolve({ environmentId: "env-123" }),
children: <div data-testid="child-content">Hello from children!</div>,
});
// Now render the fully resolved layout
render(layoutElement);
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children!");
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
expect(screen.getByTestId("mock-toaster")).toBeInTheDocument();
expect(screen.getByTestId("mock-storage-handler")).toBeInTheDocument();
expect(screen.getByTestId("mock-response-filter-provider")).toBeInTheDocument();
expect(screen.getByTestId("mock-environment-result")).toBeInTheDocument();
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("Redirect called");
});
test("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
});
});

View File

@@ -1,20 +1,10 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getTranslate } from "@/tolgee/server";
import { getServerSession } from "next-auth";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import { redirect } from "next/navigation";
import { IS_POSTHOG_CONFIGURED } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
import { getUser } from "@formbricks/lib/user/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { FormbricksClient } from "../../components/FormbricksClient";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
import { PosthogIdentify } from "./components/PosthogIdentify";
const EnvLayout = async (props: {
params: Promise<{ environmentId: string }>;
@@ -24,27 +14,16 @@ const EnvLayout = async (props: {
const { children } = props;
const t = await getTranslate();
const session = await getServerSession(authOptions);
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
if (!session?.user) {
if (!session) {
return redirect(`/auth/login`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect(`/auth/login`);
throw new Error(t("common.user_not_found"));
}
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!hasAccess) {
throw new AuthorizationError(t("common.not_authorized"));
}
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error(t("common.organization_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
@@ -57,23 +36,16 @@ const EnvLayout = async (props: {
}
return (
<ResponseFilterProvider>
<PosthogIdentify
session={session}
user={user}
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
organizationBilling={organization.billing}
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
/>
<FormbricksClient userId={user.id} email={user.email} />
<ToasterClient />
<EnvironmentIdBaseLayout
environmentId={params.environmentId}
session={session}
user={user}
organization={organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
</ResponseFilterProvider>
</EnvironmentIdBaseLayout>
);
};

View File

@@ -22,7 +22,7 @@ export const OrganizationSettingsNavbar = ({
loading,
}: OrganizationSettingsNavbarProps) => {
const pathname = usePathname();
const { isMember } = getAccessFlags(membershipRole);
const { isMember, isOwner } = getAccessFlags(membershipRole);
const isPricingDisabled = isMember;
const { t } = useTranslate();
@@ -59,6 +59,7 @@ export const OrganizationSettingsNavbar = ({
label: t("common.api_keys"),
href: `/environments/${environmentId}/settings/api-keys`,
current: pathname?.includes("/api-keys"),
hidden: !isOwner,
},
];

View File

@@ -6,7 +6,7 @@ import {
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getTranslate } from "@/tolgee/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getUser } from "@formbricks/lib/user/service";
import { TUser } from "@formbricks/types/user";
import Page from "./page";
@@ -84,7 +84,7 @@ describe("Page", () => {
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
});
it("renders the page with organization settings", async () => {
test("renders the page with organization settings", async () => {
const props = {
params: Promise.resolve({ environmentId: "env-123" }),
};
@@ -94,7 +94,7 @@ describe("Page", () => {
expect(result).toBeTruthy();
});
it("renders if session user id empty", async () => {
test("renders if session user id empty", async () => {
mockEnvironmentAuth.session.user.id = "";
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
@@ -108,7 +108,7 @@ describe("Page", () => {
expect(result).toBeTruthy();
});
it("handles getEnvironmentAuth error", async () => {
test("handles getEnvironmentAuth error", async () => {
vi.mocked(getEnvironmentAuth).mockRejectedValue(new Error("Authentication error"));
const props = {

View File

@@ -71,7 +71,11 @@ const getQuestionColumnsData = (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{QUESTIONS_ICON_MAP["matrix"]}</span>
<span className="truncate">{getLocalizedValue(matrixRow, "default")}</span>
<span className="truncate">
{getLocalizedValue(question.headline, "default") +
" - " +
getLocalizedValue(matrixRow, "default")}
</span>
</div>
</div>
);

View File

@@ -35,6 +35,16 @@ export const DateQuestionSummary = ({
);
};
const renderResponseValue = (value: string) => {
const parsedDate = new Date(value);
const formattedDate = isNaN(parsedDate.getTime())
? `${t("common.invalid_date")}(${value})`
: formatDateWithOrdinal(parsedDate);
return formattedDate;
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
@@ -70,8 +80,8 @@ export const DateQuestionSummary = ({
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{formatDateWithOrdinal(new Date(response.value as string))}
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}

View File

@@ -1,6 +1,7 @@
"use client";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { useTranslate } from "@tolgee/react";
import { InboxIcon } from "lucide-react";
import type { JSX } from "react";
@@ -42,7 +43,7 @@ export const QuestionSummaryHeader = ({
};
return (
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes(
@@ -69,6 +70,7 @@ export const QuestionSummaryHeader = ({
</div>
)}
</div>
<SettingsId title={t("common.question_id")} id={questionSummary.question.id}></SettingsId>
</div>
);
};

View File

@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
@@ -85,7 +85,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
cleanup();
});
it("calls copySurveyLink and clipboard.writeText on success", async () => {
test("calls copySurveyLink and clipboard.writeText on success", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
@@ -108,7 +108,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
});
});
it("shows error toast on failure", async () => {
test("shows error toast on failure", async () => {
refreshSingleUseIdSpy.mockImplementationOnce(() => Promise.reject(new Error("fail")));
render(
<SurveyAnalysisCTA

View File

@@ -390,7 +390,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
value && handleDatePickerClose();
}}>
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
<div className="hidden w-full items-center justify-between sm:flex">
<span className="text-sm text-slate-700">{t("common.download")}</span>
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
@@ -416,14 +416,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.current_selection_csv")}</p>
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
}}>
<p className="text-slate-700">
{t("environments.surveys.summary.current_selection_excel")}
{t("environments.surveys.summary.filtered_responses_excel")}
</p>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { getUser } from "@formbricks/lib/user/service";
import { TUser } from "@formbricks/types/user";
import AppLayout from "./layout";
@@ -36,6 +36,9 @@ vi.mock("@formbricks/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_API_HOST: "mock-formbricks-api-host",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
}));
vi.mock("@/app/(app)/components/FormbricksClient", () => ({
@@ -56,7 +59,7 @@ describe("(app) AppLayout", () => {
cleanup();
});
it("renders child content and all sub-components when user exists", async () => {
test("renders child content and all sub-components when user exists", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", email: "test@example.com" } as TUser);
@@ -74,7 +77,7 @@ describe("(app) AppLayout", () => {
expect(screen.getByTestId("formbricks-client")).toBeInTheDocument();
});
it("skips FormbricksClient if no user is present", async () => {
test("skips FormbricksClient if no user is present", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
const element = await AppLayout({

View File

@@ -7,7 +7,14 @@ import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-cl
import { ToasterClient } from "@/modules/ui/components/toaster-client";
import { getServerSession } from "next-auth";
import { Suspense } from "react";
import { IS_POSTHOG_CONFIGURED, POSTHOG_API_HOST, POSTHOG_API_KEY } from "@formbricks/lib/constants";
import {
FORMBRICKS_API_HOST,
FORMBRICKS_ENVIRONMENT_ID,
IS_FORMBRICKS_ENABLED,
IS_POSTHOG_CONFIGURED,
POSTHOG_API_HOST,
POSTHOG_API_KEY,
} from "@formbricks/lib/constants";
import { getUser } from "@formbricks/lib/user/service";
const AppLayout = async ({ children }) => {
@@ -31,7 +38,15 @@ const AppLayout = async ({ children }) => {
</Suspense>
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
<>
{user ? <FormbricksClient userId={user.id} email={user.email} /> : null}
{user ? (
<FormbricksClient
userId={user.id}
email={user.email}
formbricksApiHost={FORMBRICKS_API_HOST}
formbricksEnvironmentId={FORMBRICKS_ENVIRONMENT_ID}
formbricksEnabled={IS_FORMBRICKS_ENABLED}
/>
) : null}
<IntercomClientWrapper user={user} />
<ToasterClient />
{children}

View File

@@ -1,6 +1,6 @@
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import AppLayout from "../(auth)/layout";
vi.mock("@formbricks/lib/constants", () => ({
@@ -18,7 +18,7 @@ vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
}));
describe("(auth) AppLayout", () => {
it("renders the NoMobileOverlay and IntercomClient, plus children", async () => {
test("renders the NoMobileOverlay and IntercomClient, plus children", async () => {
const appLayoutElement = await AppLayout({
children: <div data-testid="child-content">Hello from children!</div>,
});

View File

@@ -392,6 +392,19 @@ const getValue = (colType: string, value: string | string[] | Date | number | Re
},
];
}
if (Array.isArray(value)) {
const content = value.join("\n");
return [
{
text: {
content:
content.length > NOTION_RICH_TEXT_LIMIT
? truncateText(content, NOTION_RICH_TEXT_LIMIT)
: content,
},
},
];
}
return [
{
text: {

View File

@@ -12,9 +12,10 @@ type FollowUpResult = {
error?: string;
};
const evaluateFollowUp = async (
export const evaluateFollowUp = async (
followUpId: string,
followUpAction: TSurveyFollowUpAction,
survey: TSurvey,
response: TResponse,
organization: TOrganization
): Promise<void> => {
@@ -22,6 +23,25 @@ const evaluateFollowUp = async (
const { to, subject, body, replyTo } = properties;
const toValueFromResponse = response.data[to];
const logoUrl = organization.whitelabel?.logoUrl || "";
// Check if 'to' is a direct email address (team member or user email)
const parsedEmailTo = z.string().email().safeParse(to);
if (parsedEmailTo.success) {
// 'to' is a valid email address, send email directly
await sendFollowUpEmail({
html: body,
subject,
to: parsedEmailTo.data,
replyTo,
survey,
response,
attachResponseData: properties.attachResponseData,
logoUrl,
});
return;
}
// If not a direct email, check if it's a question ID or hidden field ID
if (!toValueFromResponse) {
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
}
@@ -31,7 +51,16 @@ const evaluateFollowUp = async (
const parsedResult = z.string().email().safeParse(toValueFromResponse);
if (parsedResult.data) {
// send email to this email address
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo, logoUrl);
await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
@@ -42,7 +71,16 @@ const evaluateFollowUp = async (
}
const parsedResult = z.string().email().safeParse(emailAddress);
if (parsedResult.data) {
await sendFollowUpEmail(body, subject, parsedResult.data, replyTo, logoUrl);
await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
@@ -53,7 +91,7 @@ export const sendSurveyFollowUps = async (
survey: TSurvey,
response: TResponse,
organization: TOrganization
) => {
): Promise<FollowUpResult[]> => {
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
const { trigger } = followUp;
@@ -70,7 +108,7 @@ export const sendSurveyFollowUps = async (
}
}
return evaluateFollowUp(followUp.id, followUp.action, response, organization)
return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
.then(() => ({
followUpId: followUp.id,
status: "success" as const,
@@ -92,4 +130,6 @@ export const sendSurveyFollowUps = async (
if (errors.length > 0) {
logger.error(errors, "Follow-up processing errors");
}
return followUpResults;
};

View File

@@ -0,0 +1,267 @@
import { TResponse } from "@formbricks/types/responses";
import {
TSurvey,
TSurveyContactInfoQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
export const mockEndingId1 = "mpkt4n5krsv2ulqetle7b9e7";
export const mockEndingId2 = "ge0h63htnmgq6kwx1suh9cyi";
export const mockResponseEmailFollowUp: TSurvey["followUps"][number] = {
id: "cm9gpuazd0002192z67olbfdt",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "cm9gptbhg0000192zceq9ayuc",
name: "nice follow up",
trigger: {
type: "response",
properties: null,
},
action: {
type: "send-email",
properties: {
to: "vjniuob08ggl8dewl0hwed41",
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
from: "noreply@example.com",
replyTo: ["test@user.com"],
subject: "Thanks for your answers!",
attachResponseData: true,
},
},
};
export const mockEndingFollowUp: TSurvey["followUps"][number] = {
id: "j0g23cue6eih6xs5m0m4cj50",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "cm9gptbhg0000192zceq9ayuc",
name: "nice follow up",
trigger: {
type: "endings",
properties: {
endingIds: [mockEndingId1],
},
},
action: {
type: "send-email",
properties: {
to: "vjniuob08ggl8dewl0hwed41",
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
from: "noreply@example.com",
replyTo: ["test@user.com"],
subject: "Thanks for your answers!",
attachResponseData: true,
},
},
};
export const mockDirectEmailFollowUp: TSurvey["followUps"][number] = {
id: "yyc5sq1fqofrsyw4viuypeku",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "cm9gptbhg0000192zceq9ayuc",
name: "nice follow up 1",
trigger: {
type: "response",
properties: null,
},
action: {
type: "send-email",
properties: {
to: "direct@email.com",
body: '<p class="fb-editor-paragraph"><span>Hey 👋</span><br><br><span>Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span>Have a great day!</span></p>',
from: "noreply@example.com",
replyTo: ["test@user.com"],
subject: "Thanks for your answers!",
attachResponseData: true,
},
},
};
export const mockFollowUps: TSurvey["followUps"] = [mockDirectEmailFollowUp, mockResponseEmailFollowUp];
export const mockSurvey: TSurvey = {
id: "cm9gptbhg0000192zceq9ayuc",
createdAt: new Date(),
updatedAt: new Date(),
name: "Start from scratch",
type: "link",
environmentId: "cm98djl8e000919hpzi6a80zp",
createdBy: "cm98dg3xm000019hpubj39vfi",
status: "inProgress",
welcomeCard: {
html: {
default: "Thanks for providing your feedback - let's go!",
},
enabled: false,
headline: {
default: "Welcome!",
},
buttonLabel: {
default: "Next",
},
timeToFinish: false,
showResponseCount: false,
},
questions: [
{
id: "vjniuob08ggl8dewl0hwed41",
type: "openText" as TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "What would you like to know?",
},
required: true,
charLimit: {},
inputType: "email",
longAnswer: false,
buttonLabel: {
default: "Next",
},
placeholder: {
default: "example@email.com",
},
},
],
endings: [
{
id: "gt1yoaeb5a3istszxqbl08mk",
type: "endScreen",
headline: {
default: "Thank you!",
},
subheader: {
default: "We appreciate your feedback.",
},
buttonLink: "https://formbricks.com",
buttonLabel: {
default: "Create your own Survey",
},
},
],
hiddenFields: {
enabled: true,
fieldIds: [],
},
variables: [],
displayOption: "displayOnce",
recontactDays: null,
displayLimit: null,
autoClose: null,
runOnDate: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
projectOverwrites: null,
styling: null,
surveyClosedMessage: null,
singleUse: {
enabled: false,
isEncrypted: true,
},
pin: null,
resultShareKey: null,
showLanguageSwitch: null,
languages: [],
triggers: [],
segment: null,
followUps: mockFollowUps,
};
export const mockContactQuestion: TSurveyContactInfoQuestion = {
id: "zyoobxyolyqj17bt1i4ofr37",
type: TSurveyQuestionTypeEnum.ContactInfo,
email: {
show: true,
required: true,
placeholder: {
default: "Email",
},
},
phone: {
show: true,
required: true,
placeholder: {
default: "Phone",
},
},
company: {
show: true,
required: true,
placeholder: {
default: "Company",
},
},
headline: {
default: "Contact Question",
},
lastName: {
show: true,
required: true,
placeholder: {
default: "Last Name",
},
},
required: true,
firstName: {
show: true,
required: true,
placeholder: {
default: "First Name",
},
},
buttonLabel: {
default: "Next",
},
backButtonLabel: {
default: "Back",
},
};
export const mockContactEmailFollowUp: TSurvey["followUps"][number] = {
...mockResponseEmailFollowUp,
action: {
...mockResponseEmailFollowUp.action,
properties: {
...mockResponseEmailFollowUp.action.properties,
to: mockContactQuestion.id,
},
},
};
export const mockSurveyWithContactQuestion: TSurvey = {
...mockSurvey,
questions: [mockContactQuestion],
followUps: [mockContactEmailFollowUp],
};
export const mockResponse: TResponse = {
id: "response1",
surveyId: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
variables: {},
language: "en",
data: {
["vjniuob08ggl8dewl0hwed41"]: "test@example.com",
},
contact: null,
contactAttributes: {},
meta: {},
finished: true,
notes: [],
singleUseId: null,
tags: [],
displayId: null,
};
export const mockResponseWithContactQuestion: TResponse = {
...mockResponse,
data: {
zyoobxyolyqj17bt1i4ofr37: ["test", "user1", "test@user1.com", "99999999999", "sampleCompany"],
},
};

View File

@@ -0,0 +1,235 @@
import {
mockContactEmailFollowUp,
mockDirectEmailFollowUp,
mockEndingFollowUp,
mockEndingId2,
mockResponse,
mockResponseEmailFollowUp,
mockResponseWithContactQuestion,
mockSurvey,
mockSurveyWithContactQuestion,
} from "@/app/api/(internal)/pipeline/lib/tests/__mocks__/survey-follow-up.mock";
import { sendFollowUpEmail } from "@/modules/email";
import { describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { evaluateFollowUp, sendSurveyFollowUps } from "../survey-follow-up";
// Mock dependencies
vi.mock("@/modules/email", () => ({
sendFollowUpEmail: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("Survey Follow Up", () => {
const mockOrganization: Partial<TOrganization> = {
id: "org1",
name: "Test Org",
whitelabel: {
logoUrl: "https://example.com/logo.png",
},
};
describe("evaluateFollowUp", () => {
test("sends email when to is a direct email address", async () => {
const followUpId = mockDirectEmailFollowUp.id;
const followUpAction = mockDirectEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey,
mockResponse,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockDirectEmailFollowUp.action.properties.body,
subject: mockDirectEmailFollowUp.action.properties.subject,
to: mockDirectEmailFollowUp.action.properties.to,
replyTo: mockDirectEmailFollowUp.action.properties.replyTo,
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("sends email when to is a question ID with valid email", async () => {
const followUpId = mockResponseEmailFollowUp.id;
const followUpAction = mockResponseEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey as TSurvey,
mockResponse as TResponse,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockResponseEmailFollowUp.action.properties.body,
subject: mockResponseEmailFollowUp.action.properties.subject,
to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to],
replyTo: mockResponseEmailFollowUp.action.properties.replyTo,
survey: mockSurvey,
response: mockResponse,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("sends email when to is a question ID with valid email in array", async () => {
const followUpId = mockContactEmailFollowUp.id;
const followUpAction = mockContactEmailFollowUp.action;
await evaluateFollowUp(
followUpId,
followUpAction,
mockSurveyWithContactQuestion,
mockResponseWithContactQuestion,
mockOrganization as TOrganization
);
expect(sendFollowUpEmail).toHaveBeenCalledWith({
html: mockContactEmailFollowUp.action.properties.body,
subject: mockContactEmailFollowUp.action.properties.subject,
to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2],
replyTo: mockContactEmailFollowUp.action.properties.replyTo,
survey: mockSurveyWithContactQuestion,
response: mockResponseWithContactQuestion,
attachResponseData: true,
logoUrl: "https://example.com/logo.png",
});
});
test("throws error when to value is not found in response data", async () => {
const followUpId = "followup1";
const followUpAction = {
...mockSurvey.followUps![0].action,
properties: {
...mockSurvey.followUps![0].action.properties,
to: "nonExistentField",
},
};
await expect(
evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey as TSurvey,
mockResponse as TResponse,
mockOrganization as TOrganization
)
).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`);
});
test("throws error when email address is invalid", async () => {
const followUpId = mockResponseEmailFollowUp.id;
const followUpAction = mockResponseEmailFollowUp.action;
const invalidResponse = {
...mockResponse,
data: {
[mockResponseEmailFollowUp.action.properties.to]: "invalid-email",
},
};
await expect(
evaluateFollowUp(
followUpId,
followUpAction,
mockSurvey,
invalidResponse,
mockOrganization as TOrganization
)
).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`);
});
});
describe("sendSurveyFollowUps", () => {
test("skips follow-up when ending Id doesn't match", async () => {
const responseWithDifferentEnding = {
...mockResponse,
endingId: mockEndingId2,
};
const mockSurveyWithEndingFollowUp: TSurvey = {
...mockSurvey,
followUps: [mockEndingFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithEndingFollowUp,
responseWithDifferentEnding as TResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockEndingFollowUp.id,
status: "skipped",
},
]);
expect(sendFollowUpEmail).not.toHaveBeenCalled();
});
test("processes follow-ups and log errors", async () => {
const error = new Error("Test error");
vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error);
const mockSurveyWithFollowUps: TSurvey = {
...mockSurvey,
followUps: [mockResponseEmailFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithFollowUps,
mockResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockResponseEmailFollowUp.id,
status: "error",
error: "Test error",
},
]);
expect(logger.error).toHaveBeenCalledWith(
[`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`],
"Follow-up processing errors"
);
});
test("successfully processes follow-ups", async () => {
vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined);
const mockSurveyWithFollowUp: TSurvey = {
...mockSurvey,
followUps: [mockDirectEmailFollowUp],
};
const results = await sendSurveyFollowUps(
mockSurveyWithFollowUp,
mockResponse,
mockOrganization as TOrganization
);
expect(results).toEqual([
{
followUpId: mockDirectEmailFollowUp.id,
status: "success",
},
]);
expect(logger.error).not.toHaveBeenCalled();
});
});
});

View File

@@ -20,6 +20,7 @@ import { convertDatesInObject } from "@formbricks/lib/time";
import { getPromptText } from "@formbricks/lib/utils/ai";
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { handleIntegrations } from "./lib/handleIntegrations";
export const POST = async (request: Request) => {
@@ -50,7 +51,7 @@ export const POST = async (request: Request) => {
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) {
throw new Error("Organization not found");
throw new ResourceNotFoundError("Organization", "Organization not found");
}
// Fetch webhooks

View File

@@ -1,7 +1,7 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { authenticateRequest } from "./auth";
@@ -20,7 +20,7 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({
}));
describe("getApiKeyWithPermissions", () => {
it("should return API key data with permissions when valid key is provided", async () => {
test("returns API key data with permissions when valid key is provided", async () => {
const mockApiKeyData = {
id: "api-key-id",
organizationId: "org-id",
@@ -51,7 +51,7 @@ describe("getApiKeyWithPermissions", () => {
});
});
it("should return null when API key is not found", async () => {
test("returns null when API key is not found", async () => {
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await getApiKeyWithPermissions("invalid-key");
@@ -85,31 +85,31 @@ describe("hasPermission", () => {
},
];
it("should return true for manage permission with any method", () => {
test("returns true for manage permission with any method", () => {
expect(hasPermission(permissions, "env-1", "GET")).toBe(true);
expect(hasPermission(permissions, "env-1", "POST")).toBe(true);
expect(hasPermission(permissions, "env-1", "DELETE")).toBe(true);
});
it("should handle write permission correctly", () => {
test("handles write permission correctly", () => {
expect(hasPermission(permissions, "env-2", "GET")).toBe(true);
expect(hasPermission(permissions, "env-2", "POST")).toBe(true);
expect(hasPermission(permissions, "env-2", "DELETE")).toBe(false);
});
it("should handle read permission correctly", () => {
test("handles read permission correctly", () => {
expect(hasPermission(permissions, "env-3", "GET")).toBe(true);
expect(hasPermission(permissions, "env-3", "POST")).toBe(false);
expect(hasPermission(permissions, "env-3", "DELETE")).toBe(false);
});
it("should return false for non-existent environment", () => {
test("returns false for non-existent environment", () => {
expect(hasPermission(permissions, "env-4", "GET")).toBe(false);
});
});
describe("authenticateRequest", () => {
it("should return authentication data for valid API key", async () => {
test("should return authentication data for valid API key", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
@@ -159,13 +159,13 @@ describe("authenticateRequest", () => {
});
});
it("should return null when no API key is provided", async () => {
test("returns null when no API key is provided", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);
expect(result).toBeNull();
});
it("should return null when API key is invalid", async () => {
test("returns null when API key is invalid", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getActionClasses } from "./action-classes";
@@ -43,7 +43,7 @@ describe("getActionClasses", () => {
vi.clearAllMocks();
});
it("should successfully fetch action classes for given environment IDs", async () => {
test("successfully fetches action classes for given environment IDs", async () => {
// Mock the prisma findMany response
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
@@ -61,14 +61,14 @@ describe("getActionClasses", () => {
});
});
it("should throw DatabaseError when prisma query fails", async () => {
test("throws DatabaseError when prisma query fails", async () => {
// Mock the prisma findMany to throw an error
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("Database error"));
await expect(getActionClasses(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
});
it("should handle empty environment IDs array", async () => {
test("handles empty environment IDs array", async () => {
// Mock the prisma findMany response
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);

View File

@@ -1,7 +1,7 @@
import Intercom from "@intercom/messenger-js-sdk";
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { IntercomClient } from "./IntercomClient";
@@ -26,7 +26,7 @@ describe("IntercomClient", () => {
global.window.Intercom = originalWindowIntercom;
});
it("calls Intercom with user data when isIntercomConfigured is true and user is provided", () => {
test("calls Intercom with user data when isIntercomConfigured is true and user is provided", () => {
const testUser = {
id: "test-id",
name: "Test User",
@@ -55,7 +55,7 @@ describe("IntercomClient", () => {
});
});
it("calls Intercom with user data without createdAt", () => {
test("calls Intercom with user data without createdAt", () => {
const testUser = {
id: "test-id",
name: "Test User",
@@ -83,7 +83,7 @@ describe("IntercomClient", () => {
});
});
it("calls Intercom with minimal params if user is not provided", () => {
test("calls Intercom with minimal params if user is not provided", () => {
render(
<IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" />
);
@@ -94,7 +94,7 @@ describe("IntercomClient", () => {
});
});
it("does not call Intercom if isIntercomConfigured is false", () => {
test("does not call Intercom if isIntercomConfigured is false", () => {
render(
<IntercomClient
isIntercomConfigured={false}
@@ -106,7 +106,7 @@ describe("IntercomClient", () => {
expect(Intercom).not.toHaveBeenCalled();
});
it("shuts down Intercom on unmount", () => {
test("shuts down Intercom on unmount", () => {
const { unmount } = render(
<IntercomClient isIntercomConfigured={true} intercomAppId="my-app-id" intercomUserHash="my-user-hash" />
);
@@ -120,7 +120,7 @@ describe("IntercomClient", () => {
expect(mockWindowIntercom).toHaveBeenCalledWith("shutdown");
});
it("logs an error if Intercom initialization fails", () => {
test("logs an error if Intercom initialization fails", () => {
// Spy on console.error
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -141,7 +141,7 @@ describe("IntercomClient", () => {
consoleErrorSpy.mockRestore();
});
it("logs an error if isIntercomConfigured is true but no intercomAppId is provided", () => {
test("logs an error if isIntercomConfigured is true but no intercomAppId is provided", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
render(
@@ -159,7 +159,7 @@ describe("IntercomClient", () => {
consoleErrorSpy.mockRestore();
});
it("logs an error if isIntercomConfigured is true but no intercomUserHash is provided", () => {
test("logs an error if isIntercomConfigured is true but no intercomUserHash is provided", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const testUser = {
id: "test-id",

View File

@@ -1,5 +1,5 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TUser } from "@formbricks/types/user";
import { IntercomClientWrapper } from "./IntercomClientWrapper";
@@ -31,7 +31,7 @@ describe("IntercomClientWrapper", () => {
cleanup();
});
it("renders IntercomClient with computed user hash when user is provided", () => {
test("renders IntercomClient with computed user hash when user is provided", () => {
const testUser = { id: "user-123", name: "Test User", email: "test@example.com" } as TUser;
render(<IntercomClientWrapper user={testUser} />);
@@ -48,7 +48,7 @@ describe("IntercomClientWrapper", () => {
expect(props.user).toEqual(testUser);
});
it("renders IntercomClient without computing a hash when no user is provided", () => {
test("renders IntercomClient without computing a hash when no user is provided", () => {
render(<IntercomClientWrapper user={null} />);
const intercomClientEl = screen.getByTestId("mock-intercom-client");

View File

@@ -3,7 +3,7 @@ import { getTolgee } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { TolgeeInstance } from "@tolgee/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import RootLayout from "./layout";
// Mock dependencies for the layout
@@ -81,7 +81,7 @@ describe("RootLayout", () => {
process.env.VERCEL = "1";
});
it("renders the layout with the correct structure and providers", async () => {
test("renders the layout with the correct structure and providers", async () => {
const fakeLocale = "en-US";
// Mock getLocale to resolve to a fake locale
vi.mocked(getLocale).mockResolvedValue(fakeLocale);

View File

@@ -1,10 +1,6 @@
import formbricks from "@formbricks/js";
import { env } from "@formbricks/lib/env";
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
export const formbricksEnabled =
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
export const formbricksLogout = async () => {
const loggedInWith = localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS);
localStorage.clear();

View File

@@ -1,5 +1,5 @@
import cuid2 from "@paralleldrive/cuid2";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as crypto from "@formbricks/lib/crypto";
import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys";
@@ -45,21 +45,21 @@ describe("generateSurveySingleUseId", () => {
vi.resetAllMocks();
});
it("returns unencrypted cuid when isEncrypted is false", () => {
test("returns unencrypted cuid when isEncrypted is false", () => {
const result = generateSurveySingleUseId(false);
expect(result).toBe(mockCuid);
expect(crypto.symmetricEncrypt).not.toHaveBeenCalled();
});
it("returns encrypted cuid when isEncrypted is true", () => {
test("returns encrypted cuid when isEncrypted is true", () => {
const result = generateSurveySingleUseId(true);
expect(result).toBe(mockEncryptedCuid);
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockCuid, "test-encryption-key");
});
it("returns undefined when cuid is not valid", () => {
test("returns undefined when cuid is not valid", () => {
vi.mocked(cuid2.isCuid).mockReturnValue(false);
const result = validateSurveySingleUseId(mockEncryptedCuid);
@@ -67,7 +67,7 @@ describe("generateSurveySingleUseId", () => {
expect(result).toBeUndefined();
});
it("returns undefined when decryption fails", () => {
test("returns undefined when decryption fails", () => {
vi.mocked(crypto.symmetricDecrypt).mockImplementation(() => {
throw new Error("Decryption failed");
});
@@ -77,7 +77,7 @@ describe("generateSurveySingleUseId", () => {
expect(result).toBeUndefined();
});
it("throws error when ENCRYPTION_KEY is not set in generateSurveySingleUseId", async () => {
test("throws error when ENCRYPTION_KEY is not set in generateSurveySingleUseId", async () => {
// Temporarily mock ENCRYPTION_KEY as undefined
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
@@ -90,7 +90,7 @@ describe("generateSurveySingleUseId", () => {
expect(() => generateSurveySingleUseIdNoKey(true)).toThrow("ENCRYPTION_KEY is not set");
});
it("throws error when ENCRYPTION_KEY is not set in validateSurveySingleUseId for symmetric encryption", async () => {
test("throws error when ENCRYPTION_KEY is not set in validateSurveySingleUseId for symmetric encryption", async () => {
// Temporarily mock ENCRYPTION_KEY as undefined
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: undefined,
@@ -103,7 +103,7 @@ describe("generateSurveySingleUseId", () => {
expect(() => validateSurveySingleUseIdNoKey(mockEncryptedCuid)).toThrow("ENCRYPTION_KEY is not set");
});
it("throws error when FORMBRICKS_ENCRYPTION_KEY is not set in validateSurveySingleUseId for AES128", async () => {
test("throws error when FORMBRICKS_ENCRYPTION_KEY is not set in validateSurveySingleUseId for AES128", async () => {
// Temporarily mock FORMBRICKS_ENCRYPTION_KEY as undefined
vi.doMock("@formbricks/lib/constants", () => ({
ENCRYPTION_KEY: "test-encryption-key",

View File

@@ -1,6 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SentryProvider } from "./SentryProvider";
vi.mock("@sentry/nextjs", async () => {
@@ -22,7 +22,7 @@ describe("SentryProvider", () => {
cleanup();
});
it("calls Sentry.init when sentryDsn is provided", () => {
test("calls Sentry.init when sentryDsn is provided", () => {
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
@@ -47,7 +47,7 @@ describe("SentryProvider", () => {
);
});
it("does not call Sentry.init when sentryDsn is not provided", () => {
test("does not call Sentry.init when sentryDsn is not provided", () => {
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
render(
@@ -59,7 +59,7 @@ describe("SentryProvider", () => {
expect(initSpy).not.toHaveBeenCalled();
});
it("renders children", () => {
test("renders children", () => {
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
render(
<SentryProvider sentryDsn={sentryDsn}>
@@ -69,7 +69,7 @@ describe("SentryProvider", () => {
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
});
it("processes beforeSend correctly", () => {
test("processes beforeSend correctly", () => {
const sentryDsn = "https://examplePublicKey@o0.ingest.sentry.io/0";
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);

View File

@@ -47,6 +47,8 @@ export const SentryProvider = ({ children, sentryDsn }: SentryProviderProps) =>
},
});
}
// We only want to run this once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <>{children}</>;

View File

@@ -42,7 +42,7 @@ const enforceHttps = (request: NextRequest): Response | null => {
details: [
{
field: "",
issue: "Only HTTPS connections are allowed on the management and contacts bulk endpoints.",
issue: "Only HTTPS connections are allowed on the management endpoints.",
},
],
};
@@ -54,18 +54,22 @@ const enforceHttps = (request: NextRequest): Response | null => {
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
const token = await getToken({ req: request as any });
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
return NextResponse.redirect(loginUrl);
}
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
if (callbackUrl && !isValidCallbackUrl(callbackUrl, WEBAPP_URL)) {
return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 });
}
if (token && callbackUrl) {
return NextResponse.redirect(WEBAPP_URL + callbackUrl);
return NextResponse.redirect(callbackUrl);
}
return null;
};

View File

@@ -0,0 +1,74 @@
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { describe, expect, test, vi } from "vitest";
import { getOrganizationsWhereUserIsSingleOwner } from "@formbricks/lib/organization/service";
import { deleteUser } from "@formbricks/lib/user/service";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { deleteUserAction } from "./actions";
// Mock all dependencies
vi.mock("@formbricks/lib/user/service", () => ({
deleteUser: vi.fn(),
}));
vi.mock("@formbricks/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
}));
// add a mock to authenticatedActionClient.action
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
action: (fn: any) => {
return fn;
},
},
}));
describe("deleteUserAction", () => {
test("deletes user successfully when multi-org is enabled", async () => {
const ctx = { user: { id: "test-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "test-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(true);
const result = await deleteUserAction({ ctx } as any);
expect(result).toStrictEqual({ id: "test-user" } as TUser);
expect(deleteUser).toHaveBeenCalledWith("test-user");
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("test-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
test("deletes user successfully when multi-org is disabled but user is not sole owner of any org", async () => {
const ctx = { user: { id: "another-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "another-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(false);
const result = await deleteUserAction({ ctx } as any);
expect(result).toStrictEqual({ id: "another-user" } as TUser);
expect(deleteUser).toHaveBeenCalledWith("another-user");
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("another-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
test("throws OperationNotAllowedError when user is sole owner in at least one org and multi-org is disabled", async () => {
const ctx = { user: { id: "sole-owner-user" } };
vi.mocked(deleteUser).mockResolvedValueOnce({ id: "test-user" } as TUser);
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValueOnce([
{ id: "org-1" } as TOrganization,
]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValueOnce(false);
await expect(() => deleteUserAction({ ctx } as any)).rejects.toThrow(OperationNotAllowedError);
expect(deleteUser).not.toHaveBeenCalled();
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("sole-owner-user");
expect(getIsMultiOrgEnabled).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,161 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import * as nextAuth from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import * as actions from "./actions";
import { DeleteAccountModal } from "./index";
vi.mock("next-auth/react", async () => {
const actual = await vi.importActual("next-auth/react");
return {
...actual,
signOut: vi.fn(),
};
});
vi.mock("./actions", () => ({
deleteUserAction: vi.fn(),
}));
describe("DeleteAccountModal", () => {
const mockUser: TUser = {
email: "test@example.com",
} as TUser;
const mockOrgs: TOrganization[] = [{ name: "Org1" }, { name: "Org2" }] as TOrganization[];
const mockSetOpen = vi.fn();
const mockLogout = vi.fn();
afterEach(() => {
cleanup();
});
test("renders modal with correct props", () => {
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={mockOrgs}
formbricksLogout={mockLogout}
/>
);
expect(screen.getByText("Org1")).toBeInTheDocument();
expect(screen.getByText("Org2")).toBeInTheDocument();
});
test("disables delete button when email does not match", () => {
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "wrong@example.com" } });
expect(input).toHaveValue("wrong@example.com");
});
test("allows account deletion flow (non-cloud)", async () => {
const deleteUserAction = vi
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
const signOut = vi.spyOn(nextAuth, "signOut").mockResolvedValue(undefined);
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } });
const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form);
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockLogout).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("allows account deletion flow (cloud)", async () => {
const deleteUserAction = vi
.spyOn(actions, "deleteUserAction")
.mockResolvedValue("deleted-user-id" as any); // the return doesn't matter here
const signOut = vi.spyOn(nextAuth, "signOut").mockResolvedValue(undefined);
Object.defineProperty(window, "location", {
writable: true,
value: { replace: vi.fn() },
});
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={true}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } });
const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form);
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockLogout).toHaveBeenCalled();
expect(signOut).toHaveBeenCalledWith({ redirect: true });
expect(window.location.replace).toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("handles deletion errors", async () => {
const deleteUserAction = vi.spyOn(actions, "deleteUserAction").mockRejectedValue(new Error("fail"));
render(
<DeleteAccountModal
open={true}
setOpen={mockSetOpen}
user={mockUser}
isFormbricksCloud={false}
organizationsWithSingleOwner={[]}
formbricksLogout={mockLogout}
/>
);
const input = screen.getByTestId("deleteAccountConfirmation");
fireEvent.change(input, { target: { value: mockUser.email } });
const form = screen.getByTestId("deleteAccountForm");
fireEvent.submit(form);
await waitFor(() => {
expect(deleteUserAction).toHaveBeenCalled();
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
});

View File

@@ -2,8 +2,7 @@
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { T } from "@tolgee/react";
import { T, useTranslate } from "@tolgee/react";
import { signOut } from "next-auth/react";
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
@@ -88,6 +87,7 @@ export const DeleteAccountModal = ({
<li>{t("environments.settings.profile.warning_cannot_undo")}</li>
</ul>
<form
data-testid="deleteAccountForm"
onSubmit={async (e) => {
e.preventDefault();
await deleteAccount();
@@ -98,6 +98,7 @@ export const DeleteAccountModal = ({
})}
</label>
<Input
data-testid="deleteAccountConfirmation"
value={inputValue}
onChange={handleInputChange}
placeholder={user.email}

View File

@@ -0,0 +1,117 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { RatingSmiley } from "./index";
// Mock the smiley components from ../SingleResponseCard/components/Smileys
vi.mock("../SingleResponseCard/components/Smileys", () => ({
TiredFace: (props: any) => (
<span data-testid="TiredFace" className={props.className}>
TiredFace
</span>
),
WearyFace: (props: any) => (
<span data-testid="WearyFace" className={props.className}>
WearyFace
</span>
),
PerseveringFace: (props: any) => (
<span data-testid="PerseveringFace" className={props.className}>
PerseveringFace
</span>
),
FrowningFace: (props: any) => (
<span data-testid="FrowningFace" className={props.className}>
FrowningFace
</span>
),
ConfusedFace: (props: any) => (
<span data-testid="ConfusedFace" className={props.className}>
ConfusedFace
</span>
),
NeutralFace: (props: any) => (
<span data-testid="NeutralFace" className={props.className}>
NeutralFace
</span>
),
SlightlySmilingFace: (props: any) => (
<span data-testid="SlightlySmilingFace" className={props.className}>
SlightlySmilingFace
</span>
),
SmilingFaceWithSmilingEyes: (props: any) => (
<span data-testid="SmilingFaceWithSmilingEyes" className={props.className}>
SmilingFaceWithSmilingEyes
</span>
),
GrinningFaceWithSmilingEyes: (props: any) => (
<span data-testid="GrinningFaceWithSmilingEyes" className={props.className}>
GrinningFaceWithSmilingEyes
</span>
),
GrinningSquintingFace: (props: any) => (
<span data-testid="GrinningSquintingFace" className={props.className}>
GrinningSquintingFace
</span>
),
}));
describe("RatingSmiley", () => {
afterEach(() => {
cleanup();
});
const activeClass = "fill-rating-fill";
// Test branch: range === 10 => iconsIdx = [0,1,2,...,9]
test("renders correct icon for range 10 when active", () => {
// For idx 0, iconsIdx[0] === 0, which corresponds to TiredFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={10} addColors={true} />);
const icon = getByTestId("TiredFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
test("renders correct icon for range 10 when inactive", () => {
const { getByTestId } = render(<RatingSmiley active={false} idx={0} range={10} />);
const icon = getByTestId("TiredFace");
expect(icon).toBeDefined();
expect(icon.className).toContain("fill-none");
});
// Test branch: range === 7 => iconsIdx = [1,3,4,5,6,8,9]
test("renders correct icon for range 7 when active", () => {
// For idx 0, iconsIdx[0] === 1, which corresponds to WearyFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={7} addColors={true} />);
const icon = getByTestId("WearyFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
// Test branch: range === 5 => iconsIdx = [3,4,5,6,7]
test("renders correct icon for range 5 when active", () => {
// For idx 0, iconsIdx[0] === 3, which corresponds to FrowningFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={5} addColors={true} />);
const icon = getByTestId("FrowningFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
// Test branch: range === 4 => iconsIdx = [4,5,6,7]
test("renders correct icon for range 4 when active", () => {
// For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={4} addColors={true} />);
const icon = getByTestId("ConfusedFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
// Test branch: range === 3 => iconsIdx = [4,5,7]
test("renders correct icon for range 3 when active", () => {
// For idx 0, iconsIdx[0] === 4, corresponding to ConfusedFace.
const { getByTestId } = render(<RatingSmiley active={true} idx={0} range={3} addColors={true} />);
const icon = getByTestId("ConfusedFace");
expect(icon).toBeDefined();
expect(icon.className).toContain(activeClass);
});
});

View File

@@ -40,16 +40,28 @@ const getSmiley = (iconIdx: number, idx: number, range: number, active: boolean,
const inactiveColor = addColors ? getSmileyColor(range, idx) : "fill-none";
const icons = [
<TiredFace className={active ? activeColor : inactiveColor} />,
<WearyFace className={active ? activeColor : inactiveColor} />,
<PerseveringFace className={active ? activeColor : inactiveColor} />,
<FrowningFace className={active ? activeColor : inactiveColor} />,
<ConfusedFace className={active ? activeColor : inactiveColor} />,
<NeutralFace className={active ? activeColor : inactiveColor} />,
<SlightlySmilingFace className={active ? activeColor : inactiveColor} />,
<SmilingFaceWithSmilingEyes className={active ? activeColor : inactiveColor} />,
<GrinningFaceWithSmilingEyes className={active ? activeColor : inactiveColor} />,
<GrinningSquintingFace className={active ? activeColor : inactiveColor} />,
<TiredFace className={active ? activeColor : inactiveColor} data-testid="TiredFace" />,
<WearyFace className={active ? activeColor : inactiveColor} data-testid="WearyFace" />,
<PerseveringFace className={active ? activeColor : inactiveColor} data-testid="PerseveringFace" />,
<FrowningFace className={active ? activeColor : inactiveColor} data-testid="FrowningFace" />,
<ConfusedFace className={active ? activeColor : inactiveColor} data-testid="ConfusedFace" />,
<NeutralFace className={active ? activeColor : inactiveColor} data-testid="NeutralFace" />,
<SlightlySmilingFace
className={active ? activeColor : inactiveColor}
data-testid="SlightlySmilingFace"
/>,
<SmilingFaceWithSmilingEyes
className={active ? activeColor : inactiveColor}
data-testid="SmilingFaceWithSmilingEyes"
/>,
<GrinningFaceWithSmilingEyes
className={active ? activeColor : inactiveColor}
data-testid="GrinningFaceWithSmilingEyes"
/>,
<GrinningSquintingFace
className={active ? activeColor : inactiveColor}
data-testid="GrinningSquintingFace"
/>,
];
return icons[iconIdx];

View File

@@ -0,0 +1,90 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { getEnabledLanguages, getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { TSurvey, TSurveyLanguage } from "@formbricks/types/surveys/types";
import { LanguageDropdown } from "./LanguageDropdown";
vi.mock("@formbricks/lib/i18n/utils", () => ({
getEnabledLanguages: vi.fn(),
getLanguageLabel: vi.fn(),
}));
describe("LanguageDropdown", () => {
const dummySurveyMultiple = {
languages: [
{ language: { code: "en" } } as TSurveyLanguage,
{ language: { code: "fr" } } as TSurveyLanguage,
],
} as TSurvey;
const dummySurveySingle = {
languages: [{ language: { code: "en" } }],
} as TSurvey;
const dummyLocale = "en-US";
const setLanguageMock = vi.fn();
afterEach(() => {
cleanup();
});
test("renders nothing when enabledLanguages length is 1", () => {
vi.mocked(getEnabledLanguages).mockReturnValueOnce([{ language: { code: "en" } } as TSurveyLanguage]);
render(
<LanguageDropdown survey={dummySurveySingle} setLanguage={setLanguageMock} locale={dummyLocale} />
);
// Since enabledLanguages.length === 1, component should render null.
expect(screen.queryByRole("button")).toBeNull();
});
test("renders button and toggles dropdown when multiple languages exist", async () => {
vi.mocked(getEnabledLanguages).mockReturnValue(dummySurveyMultiple.languages);
vi.mocked(getLanguageLabel).mockImplementation((code: string, _locale: string) => code.toUpperCase());
render(
<LanguageDropdown survey={dummySurveyMultiple} setLanguage={setLanguageMock} locale={dummyLocale} />
);
const button = screen.getByRole("button", { name: "Select Language" });
expect(button).toBeDefined();
await userEvent.click(button);
// Wait for the dropdown options to appear. They are wrapped in a div with no specific role,
// so we query for texts (our mock labels) instead.
const optionEn = await screen.findByText("EN");
const optionFr = await screen.findByText("FR");
expect(optionEn).toBeDefined();
expect(optionFr).toBeDefined();
await userEvent.click(optionFr);
expect(setLanguageMock).toHaveBeenCalledWith("fr");
// After clicking, dropdown should no longer be visible.
await waitFor(() => {
expect(screen.queryByText("EN")).toBeNull();
expect(screen.queryByText("FR")).toBeNull();
});
});
test("closes dropdown when clicking outside", async () => {
vi.mocked(getEnabledLanguages).mockReturnValue(dummySurveyMultiple.languages);
vi.mocked(getLanguageLabel).mockImplementation((code: string, _locale: string) => code);
render(
<LanguageDropdown survey={dummySurveyMultiple} setLanguage={setLanguageMock} locale={dummyLocale} />
);
const button = screen.getByRole("button", { name: "Select Language" });
await userEvent.click(button);
// Confirm dropdown shown
expect(await screen.findByText("en")).toBeDefined();
// Simulate clicking outside by dispatching a click event on the container's parent.
await userEvent.click(document.body);
// Wait for dropdown to close
await waitFor(() => {
expect(screen.queryByText("en")).toBeNull();
});
});
});

View File

@@ -1,8 +1,7 @@
import { Button } from "@/modules/ui/components/button";
import { Languages } from "lucide-react";
import { useRef, useState } from "react";
import { getEnabledLanguages } from "@formbricks/lib/i18n/utils";
import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { getEnabledLanguages, getLanguageLabel } from "@formbricks/lib/i18n/utils";
import { useClickOutside } from "@formbricks/lib/utils/hooks/useClickOutside";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";

View File

@@ -0,0 +1,22 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { SurveyLinkDisplay } from "./SurveyLinkDisplay";
describe("SurveyLinkDisplay", () => {
afterEach(() => {
cleanup();
});
test("renders the Input when surveyUrl is provided", () => {
const surveyUrl = "http://example.com/s/123";
render(<SurveyLinkDisplay surveyUrl={surveyUrl} />);
const input = screen.getByTestId("survey-url-input");
expect(input).toBeInTheDocument();
});
test("renders loading state when surveyUrl is empty", () => {
render(<SurveyLinkDisplay surveyUrl="" />);
const loadingDiv = screen.getByTestId("loading-div");
expect(loadingDiv).toBeInTheDocument();
});
});

View File

@@ -9,13 +9,16 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
<>
{surveyUrl ? (
<Input
data-testid="survey-url-input"
autoFocus={true}
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
className="mt-2 w-full min-w-96 rounded-lg border bg-white px-4 py-2 text-ellipsis text-slate-800 caret-transparent"
value={surveyUrl}
/>
) : (
//loading state
<div className="mt-2 h-10 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-4 py-2 text-slate-800 caret-transparent"></div>
<div
data-testid="loading-div"
className="mt-2 h-10 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-4 py-2 text-slate-800 caret-transparent"></div>
)}
</>
);

View File

@@ -0,0 +1,247 @@
import { useSurveyQRCode } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { generateSingleUseIdAction } from "@/modules/survey/list/actions";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ShareSurveyLink } from "./index";
const dummySurvey = {
id: "survey123",
singleUse: { enabled: true, isEncrypted: false },
type: "link",
status: "completed",
} as any;
const dummySurveyDomain = "http://dummy.com";
const dummyLocale = "en-US";
vi.mock("@formbricks/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
AI_AZURE_LLM_API_KEY: "mock-ai",
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
}));
vi.mock("@/modules/survey/list/actions", () => ({
generateSingleUseIdAction: vi.fn(),
}));
vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn(),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey-qr-code",
() => ({
useSurveyQRCode: vi.fn(() => ({
downloadQRCode: vi.fn(),
})),
})
);
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((error: any) => error.message),
}));
vi.mock("./components/LanguageDropdown", () => {
const React = require("react");
return {
LanguageDropdown: (props: { setLanguage: (lang: string) => void }) => {
// Call setLanguage("fr-FR") when the component mounts to simulate a language change.
React.useEffect(() => {
props.setLanguage("fr-FR");
}, [props.setLanguage]);
return <div>Mocked LanguageDropdown</div>;
},
};
});
describe("ShareSurveyLink", () => {
beforeEach(() => {
Object.assign(navigator, {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
window.open = vi.fn();
});
afterEach(() => {
cleanup();
});
test("calls getUrl on mount and sets surveyUrl accordingly with singleUse enabled and default language", async () => {
// Inline mocks for this test
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl=""
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
await waitFor(() => {
expect(setSurveyUrl).toHaveBeenCalled();
});
const url = setSurveyUrl.mock.calls[0][0];
expect(url).toContain(`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`);
expect(url).not.toContain("lang=");
});
test("appends language query when language is changed from default", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
const DummyWrapper = () => (
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl="initial"
setSurveyUrl={setSurveyUrl}
locale="fr-FR"
/>
);
render(<DummyWrapper />);
await waitFor(() => {
const generatedUrl = setSurveyUrl.mock.calls[1][0];
expect(generatedUrl).toContain("lang=fr-FR");
});
});
test("preview button opens new window with preview query", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn().mockReturnValue(`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`);
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const previewButton = await screen.findByRole("button", {
name: /environments.surveys.preview_survey_in_a_new_tab/i,
});
fireEvent.click(previewButton);
await waitFor(() => {
expect(window.open).toHaveBeenCalled();
const previewUrl = vi.mocked(window.open).mock.calls[0][0];
expect(previewUrl).toMatch(/\?suId=dummySuId(&|\\?)preview=true/);
});
});
test("copy button writes surveyUrl to clipboard and shows toast", async () => {
vi.mocked(getFormattedErrorMessage).mockReturnValue("common.copied_to_clipboard");
vi.mocked(copySurveyLink).mockImplementation((url: string, newId: string) => `${url}?suId=${newId}`);
const setSurveyUrl = vi.fn();
const surveyUrl = `${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`;
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const copyButton = await screen.findByRole("button", {
name: /environments.surveys.copy_survey_link_to_clipboard/i,
});
fireEvent.click(copyButton);
await waitFor(() => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
});
test("download QR code button calls downloadQRCode", async () => {
const dummyDownloadQRCode = vi.fn();
vi.mocked(getFormattedErrorMessage).mockReturnValue("common.copied_to_clipboard");
vi.mocked(useSurveyQRCode).mockReturnValue({ downloadQRCode: dummyDownloadQRCode } as any);
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const downloadButton = await screen.findByRole("button", {
name: /environments.surveys.summary.download_qr_code/i,
});
fireEvent.click(downloadButton);
expect(dummyDownloadQRCode).toHaveBeenCalled();
});
test("renders regenerate button when survey.singleUse.enabled is true and calls generateNewSingleUseLink", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: "dummySuId" });
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl={`${dummySurveyDomain}/s/${dummySurvey.id}?suId=dummySuId`}
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
const regenButton = await screen.findByRole("button", { name: /Regenerate single use survey link/i });
fireEvent.click(regenButton);
await waitFor(() => {
expect(generateSingleUseIdAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("environments.surveys.new_single_use_link_generated");
});
});
test("handles error when generating single-use link fails", async () => {
vi.mocked(generateSingleUseIdAction).mockResolvedValue({ data: undefined });
vi.mocked(getFormattedErrorMessage).mockReturnValue("Failed to generate link");
const setSurveyUrl = vi.fn();
render(
<ShareSurveyLink
survey={dummySurvey}
surveyDomain={dummySurveyDomain}
surveyUrl=""
setSurveyUrl={setSurveyUrl}
locale={dummyLocale}
/>
);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Failed to generate link");
});
});
});

View File

@@ -0,0 +1,206 @@
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
import {
getEnvironmentIdFromResponseId,
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromResponseId,
getOrganizationIdFromResponseNoteId,
getProjectIdFromEnvironmentId,
getProjectIdFromResponseId,
getProjectIdFromResponseNoteId,
} from "@/lib/utils/helper";
import { getTag } from "@/lib/utils/services";
import { describe, expect, test, vi } from "vitest";
import { deleteResponse, getResponse } from "@formbricks/lib/response/service";
import {
createResponseNote,
resolveResponseNote,
updateResponseNote,
} from "@formbricks/lib/responseNote/service";
import { createTag } from "@formbricks/lib/tag/service";
import { addTagToRespone, deleteTagOnResponse } from "@formbricks/lib/tagOnResponse/service";
import {
createResponseNoteAction,
createTagAction,
createTagToResponseAction,
deleteResponseAction,
deleteTagOnResponseAction,
getResponseAction,
resolveResponseNoteAction,
updateResponseNoteAction,
} from "./actions";
// Dummy inputs and context
const dummyCtx = { user: { id: "user1" } };
const dummyTagInput = { environmentId: "env1", tagName: "tag1" };
const dummyTagToResponseInput = { responseId: "resp1", tagId: "tag1" };
const dummyResponseIdInput = { responseId: "resp1" };
const dummyResponseNoteInput = { responseNoteId: "note1", text: "Updated note" };
const dummyCreateNoteInput = { responseId: "resp1", text: "New note" };
const dummyGetResponseInput = { responseId: "resp1" };
// Mocks for external dependencies
vi.mock("@/lib/utils/action-client-middleware", () => ({
checkAuthorizationUpdated: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
getProjectIdFromEnvironmentId: vi.fn().mockResolvedValue("proj-env"),
getOrganizationIdFromResponseId: vi.fn().mockResolvedValue("org-resp"),
getOrganizationIdFromResponseNoteId: vi.fn().mockResolvedValue("org-resp-note"),
getProjectIdFromResponseId: vi.fn().mockResolvedValue("proj-resp"),
getProjectIdFromResponseNoteId: vi.fn().mockResolvedValue("proj-resp-note"),
getEnvironmentIdFromResponseId: vi.fn(),
}));
vi.mock("@/lib/utils/services", () => ({
getTag: vi.fn(),
}));
vi.mock("@formbricks/lib/response/service", () => ({
deleteResponse: vi.fn().mockResolvedValue("deletedResponse"),
getResponse: vi.fn().mockResolvedValue({ data: "responseData" }),
}));
vi.mock("@formbricks/lib/responseNote/service", () => ({
createResponseNote: vi.fn().mockResolvedValue("createdNote"),
updateResponseNote: vi.fn().mockResolvedValue("updatedNote"),
resolveResponseNote: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@formbricks/lib/tag/service", () => ({
createTag: vi.fn().mockResolvedValue("createdTag"),
}));
vi.mock("@formbricks/lib/tagOnResponse/service", () => ({
addTagToRespone: vi.fn().mockResolvedValue("tagAdded"),
deleteTagOnResponse: vi.fn().mockResolvedValue("tagDeleted"),
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
schema: () => ({
action: (fn: any) => async (input: any) => {
const { user, ...rest } = input;
return fn({
parsedInput: rest,
ctx: { user },
});
},
}),
},
}));
describe("createTagAction", () => {
test("successfully creates a tag", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValueOnce("org1");
await createTagAction({ ...dummyTagInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromEnvironmentId).toHaveBeenCalledWith(dummyTagInput.environmentId);
expect(getProjectIdFromEnvironmentId).toHaveBeenCalledWith(dummyTagInput.environmentId);
expect(createTag).toHaveBeenCalledWith(dummyTagInput.environmentId, dummyTagInput.tagName);
});
});
describe("createTagToResponseAction", () => {
test("adds tag to response when environments match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "env1" });
await createTagToResponseAction({ ...dummyTagToResponseInput, ...dummyCtx });
expect(getEnvironmentIdFromResponseId).toHaveBeenCalledWith(dummyTagToResponseInput.responseId);
expect(getTag).toHaveBeenCalledWith(dummyTagToResponseInput.tagId);
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(addTagToRespone).toHaveBeenCalledWith(
dummyTagToResponseInput.responseId,
dummyTagToResponseInput.tagId
);
});
test("throws error when environments do not match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "differentEnv" });
await expect(createTagToResponseAction({ ...dummyTagToResponseInput, ...dummyCtx })).rejects.toThrow(
"Response and tag are not in the same environment"
);
});
});
describe("deleteTagOnResponseAction", () => {
test("deletes tag on response when environments match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "env1" });
await deleteTagOnResponseAction({ ...dummyTagToResponseInput, ...dummyCtx });
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyTagToResponseInput.responseId);
expect(getTag).toHaveBeenCalledWith(dummyTagToResponseInput.tagId);
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(deleteTagOnResponse).toHaveBeenCalledWith(
dummyTagToResponseInput.responseId,
dummyTagToResponseInput.tagId
);
});
test("throws error when environments do not match", async () => {
vi.mocked(getEnvironmentIdFromResponseId).mockResolvedValueOnce("env1");
vi.mocked(getTag).mockResolvedValueOnce({ environmentId: "differentEnv" });
await expect(deleteTagOnResponseAction({ ...dummyTagToResponseInput, ...dummyCtx })).rejects.toThrow(
"Response and tag are not in the same environment"
);
});
});
describe("deleteResponseAction", () => {
test("deletes response successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await deleteResponseAction({ ...dummyResponseIdInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
expect(deleteResponse).toHaveBeenCalledWith(dummyResponseIdInput.responseId);
});
});
describe("updateResponseNoteAction", () => {
test("updates response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await updateResponseNoteAction({ ...dummyResponseNoteInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseNoteId).toHaveBeenCalledWith(dummyResponseNoteInput.responseNoteId);
expect(getProjectIdFromResponseNoteId).toHaveBeenCalledWith(dummyResponseNoteInput.responseNoteId);
expect(updateResponseNote).toHaveBeenCalledWith(
dummyResponseNoteInput.responseNoteId,
dummyResponseNoteInput.text
);
});
});
describe("resolveResponseNoteAction", () => {
test("resolves response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await resolveResponseNoteAction({ responseNoteId: "note1", ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseNoteId).toHaveBeenCalledWith("note1");
expect(getProjectIdFromResponseNoteId).toHaveBeenCalledWith("note1");
expect(resolveResponseNote).toHaveBeenCalledWith("note1");
});
});
describe("createResponseNoteAction", () => {
test("creates a response note successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await createResponseNoteAction({ ...dummyCreateNoteInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyCreateNoteInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyCreateNoteInput.responseId);
expect(createResponseNote).toHaveBeenCalledWith(
dummyCreateNoteInput.responseId,
dummyCtx.user.id,
dummyCreateNoteInput.text
);
});
});
describe("getResponseAction", () => {
test("retrieves response successfully", async () => {
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(true);
await getResponseAction({ ...dummyGetResponseInput, ...dummyCtx });
expect(checkAuthorizationUpdated).toHaveBeenCalled();
expect(getOrganizationIdFromResponseId).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
expect(getProjectIdFromResponseId).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
expect(getResponse).toHaveBeenCalledWith(dummyGetResponseInput.responseId);
});
});

View File

@@ -0,0 +1,70 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurveyHiddenFields } from "@formbricks/types/surveys/types";
import { HiddenFields } from "./HiddenFields";
// Mock tooltip components to always render their children
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe("HiddenFields", () => {
afterEach(() => {
cleanup();
});
test("renders empty container when no fieldIds are provided", () => {
render(
<HiddenFields hiddenFields={{ fieldIds: [] } as unknown as TSurveyHiddenFields} responseData={{}} />
);
const container = screen.getByTestId("main-hidden-fields-div");
expect(container).toBeDefined();
});
test("renders nothing for fieldIds with no corresponding response data", () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1"] } as unknown as TSurveyHiddenFields}
responseData={{}}
/>
);
expect(screen.queryByText("field1")).toBeNull();
});
test("renders field and value when responseData exists and is a string", async () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1", "field2"] } as unknown as TSurveyHiddenFields}
responseData={{ field1: "Value 1", field2: "" }}
/>
);
expect(screen.getByText("field1")).toBeInTheDocument();
expect(screen.getByText("Value 1")).toBeInTheDocument();
expect(screen.queryByText("field2")).toBeNull();
});
test("renders empty text when responseData value is not a string", () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1"] } as unknown as TSurveyHiddenFields}
responseData={{ field1: { any: "object" } }}
/>
);
expect(screen.getByText("field1")).toBeInTheDocument();
const valueParagraphs = screen.getAllByText("", { selector: "p" });
expect(valueParagraphs.length).toBeGreaterThan(0);
});
test("displays tooltip content for hidden field", async () => {
render(
<HiddenFields
hiddenFields={{ fieldIds: ["field1"] } as unknown as TSurveyHiddenFields}
responseData={{ field1: "Value 1" }}
/>
);
expect(screen.getByText("common.hidden_field")).toBeInTheDocument();
});
});

View File

@@ -15,7 +15,7 @@ export const HiddenFields = ({ hiddenFields, responseData }: HiddenFieldsProps)
const { t } = useTranslate();
const fieldIds = hiddenFields.fieldIds ?? [];
return (
<div className="mt-6 flex flex-col gap-6">
<div data-testid="main-hidden-fields-div" className="mt-6 flex flex-col gap-6">
{fieldIds.map((field) => {
if (!responseData[field]) return;
return (

View File

@@ -0,0 +1,98 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { parseRecallInfo } from "@formbricks/lib/utils/recall";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { QuestionSkip } from "./QuestionSkip";
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: any) => <div>{children}</div>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
TooltipProvider: ({ children }: any) => <div>{children}</div>,
TooltipTrigger: ({ children }: any) => <div>{children}</div>,
}));
vi.mock("@/modules/i18n/utils", () => ({
getLocalizedValue: vi.fn((value, _) => value),
}));
// Mock recall utils
vi.mock("@formbricks/lib/utils/recall", () => ({
parseRecallInfo: vi.fn((headline, _) => {
return `parsed: ${headline}`;
}),
}));
const dummyQuestions = [
{ id: "f1", headline: "headline1" },
{ id: "f2", headline: "headline2" },
] as unknown as TSurveyQuestion[];
const dummyResponseData = { f1: "Answer 1", f2: "Answer 2" };
describe("QuestionSkip", () => {
afterEach(() => {
cleanup();
});
test("renders nothing when skippedQuestions is falsy", () => {
render(
<QuestionSkip
skippedQuestions={undefined}
status="skipped"
questions={dummyQuestions}
responseData={dummyResponseData}
/>
);
expect(screen.queryByText("headline1")).toBeNull();
expect(screen.queryByText("headline2")).toBeNull();
});
test("renders welcomeCard branch", () => {
render(
<QuestionSkip
skippedQuestions={["f1"]}
status="welcomeCard"
questions={dummyQuestions}
responseData={{ f1: "Answer 1" }}
isFirstQuestionAnswered={false}
/>
);
expect(screen.getByText("common.welcome_card")).toBeInTheDocument();
});
test("renders skipped branch with tooltip and parsed headlines", () => {
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline1");
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline2");
render(
<QuestionSkip
skippedQuestions={["f1", "f2"]}
status="skipped"
questions={dummyQuestions}
responseData={dummyResponseData}
/>
);
// Check tooltip text from TooltipContent
expect(screen.getByTestId("tooltip-respondent_skipped_questions")).toBeInTheDocument();
// Check mapping: parseRecallInfo should be called on each headline value, so expect the parsed text to appear.
expect(screen.getByText("parsed: headline1")).toBeInTheDocument();
expect(screen.getByText("parsed: headline2")).toBeInTheDocument();
});
test("renders aborted branch with closed message and parsed headlines", () => {
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline1");
vi.mocked(parseRecallInfo).mockReturnValueOnce("parsed: headline2");
render(
<QuestionSkip
skippedQuestions={["f1", "f2"]}
status="aborted"
questions={dummyQuestions}
responseData={dummyResponseData}
/>
);
expect(screen.getByTestId("tooltip-survey_closed")).toBeInTheDocument();
expect(screen.getByText("parsed: headline1")).toBeInTheDocument();
expect(screen.getByText("parsed: headline2")).toBeInTheDocument();
});
});

View File

@@ -39,7 +39,7 @@ export const QuestionSkip = ({
background:
"repeating-linear-gradient(rgb(148, 163, 184), rgb(148, 163, 184) 5px, transparent 5px, transparent 8px)", // adjust the values to fit your design
}}>
<CheckCircle2Icon className="p-0.25 absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white text-slate-400" />
<CheckCircle2Icon className="absolute top-0 w-[1.5rem] min-w-[1.5rem] rounded-full bg-white p-0.25 text-slate-400" />
</div>
}
<div className="ml-6 flex flex-col text-slate-700">{t("common.welcome_card")}</div>
@@ -60,27 +60,28 @@ export const QuestionSkip = ({
<ChevronsDownIcon className="w-[1.25rem] min-w-[1.25rem] rounded-full bg-slate-400 p-0.5 text-white" />
</TooltipTrigger>
<TooltipContent>
<p>{t("environments.surveys.responses.respondent_skipped_questions")}</p>
<p data-testid="tooltip-respondent_skipped_questions">
{t("environments.surveys.responses.respondent_skipped_questions")}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<div className="ml-6 flex flex-col">
{skippedQuestions &&
skippedQuestions.map((questionId) => {
return (
<p className="my-2" key={questionId}>
{parseRecallInfo(
getLocalizedValue(
questions.find((question) => question.id === questionId)!.headline,
"default"
),
responseData
)}
</p>
);
})}
{skippedQuestions?.map((questionId) => {
return (
<p className="my-2" key={questionId}>
{parseRecallInfo(
getLocalizedValue(
questions.find((question) => question.id === questionId)!.headline,
"default"
),
responseData
)}
</p>
);
})}
</div>
</div>
)}
@@ -97,7 +98,9 @@ export const QuestionSkip = ({
</div>
</div>
<div className="mb-2 ml-4 flex flex-col">
<p className="mb-2 w-fit rounded-lg bg-slate-100 px-2 font-medium text-slate-700">
<p
data-testid="tooltip-survey_closed"
className="mb-2 w-fit rounded-lg bg-slate-100 px-2 font-medium text-slate-700">
{t("environments.surveys.responses.survey_closed")}
</p>
{skippedQuestions &&

View File

@@ -0,0 +1,277 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { RenderResponse } from "./RenderResponse";
// Mocks for dependencies
vi.mock("@/modules/ui/components/rating-response", () => ({
RatingResponse: ({ answer }: any) => <div data-testid="RatingResponse">Rating: {answer}</div>,
}));
vi.mock("@/modules/ui/components/file-upload-response", () => ({
FileUploadResponse: ({ selected }: any) => (
<div data-testid="FileUploadResponse">FileUpload: {selected.join(",")}</div>
),
}));
vi.mock("@/modules/ui/components/picture-selection-response", () => ({
PictureSelectionResponse: ({ selected, isExpanded }: any) => (
<div data-testid="PictureSelectionResponse">
PictureSelection: {selected.join(",")} ({isExpanded ? "expanded" : "collapsed"})
</div>
),
}));
vi.mock("@/modules/ui/components/array-response", () => ({
ArrayResponse: ({ value }: any) => <div data-testid="ArrayResponse">{value.join(",")}</div>,
}));
vi.mock("@/modules/ui/components/response-badges", () => ({
ResponseBadges: ({ items }: any) => <div data-testid="ResponseBadges">{items.join(",")}</div>,
}));
vi.mock("@/modules/ui/components/ranking-response", () => ({
RankingRespone: ({ value }: any) => <div data-testid="RankingRespone">{value.join(",")}</div>,
}));
vi.mock("@/modules/analysis/utils", () => ({
renderHyperlinkedContent: vi.fn((text: string) => "hyper:" + text),
}));
vi.mock("@formbricks/lib/responses", () => ({
processResponseData: (val: any) => "processed:" + val,
}));
vi.mock("@formbricks/lib/utils/datetime", () => ({
formatDateWithOrdinal: (d: Date) => "formatted_" + d.toISOString(),
}));
vi.mock("@formbricks/lib/cn", () => ({
cn: (...classes: (string | boolean | undefined)[]) => classes.filter(Boolean).join(" "),
}));
vi.mock("@formbricks/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn((val, _) => val),
getLanguageCode: vi.fn().mockReturnValue("default"),
}));
describe("RenderResponse", () => {
afterEach(() => {
cleanup();
});
const defaultSurvey = { languages: [] } as any;
const defaultQuestion = { id: "q1", type: "Unknown" } as any;
const dummyLanguage = "default";
test("returns '-' for empty responseData (string)", () => {
const { container } = render(
<RenderResponse
responseData={""}
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(container.textContent).toBe("-");
});
test("returns '-' for empty responseData (array)", () => {
const { container } = render(
<RenderResponse
responseData={[]}
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(container.textContent).toBe("-");
});
test("returns '-' for empty responseData (object)", () => {
const { container } = render(
<RenderResponse
responseData={{}}
question={defaultQuestion}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(container.textContent).toBe("-");
});
test("renders RatingResponse for 'Rating' question with number", () => {
const question = { ...defaultQuestion, type: "rating", scale: 5, range: [1, 5] };
render(
<RenderResponse responseData={4} question={question} survey={defaultSurvey} language={dummyLanguage} />
);
expect(screen.getByTestId("RatingResponse")).toHaveTextContent("Rating: 4");
});
test("renders formatted date for 'Date' question", () => {
const question = { ...defaultQuestion, type: "date" };
const dateStr = new Date("2023-01-01T12:00:00Z").toISOString();
render(
<RenderResponse
responseData={dateStr}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByText(/formatted_/)).toBeInTheDocument();
});
test("renders PictureSelectionResponse for 'PictureSelection' question", () => {
const question = { ...defaultQuestion, type: "pictureSelection", choices: ["a", "b"] };
render(
<RenderResponse
responseData={["choice1", "choice2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("PictureSelectionResponse")).toHaveTextContent(
"PictureSelection: choice1,choice2"
);
});
test("renders FileUploadResponse for 'FileUpload' question", () => {
const question = { ...defaultQuestion, type: "fileUpload" };
render(
<RenderResponse
responseData={["file1", "file2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("FileUploadResponse")).toHaveTextContent("FileUpload: file1,file2");
});
test("renders Matrix response", () => {
const question = { id: "q1", type: "matrix", rows: ["row1", "row2"] } as any;
// getLocalizedValue returns the row value itself
const responseData = { row1: "answer1", row2: "answer2" };
render(
<RenderResponse
responseData={responseData}
question={question}
survey={{ languages: [] } as any}
language={dummyLanguage}
/>
);
expect(screen.getByText("row1:processed:answer1")).toBeInTheDocument();
expect(screen.getByText("row2:processed:answer2")).toBeInTheDocument();
});
test("renders ArrayResponse for 'Address' question", () => {
const question = { ...defaultQuestion, type: "address" };
render(
<RenderResponse
responseData={["addr1", "addr2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("ArrayResponse")).toHaveTextContent("addr1,addr2");
});
test("renders ResponseBadges for 'Cal' question (string)", () => {
const question = { ...defaultQuestion, type: "cal" };
render(
<RenderResponse
responseData={"value"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Value");
});
test("renders ResponseBadges for 'Consent' question (number)", () => {
const question = { ...defaultQuestion, type: "consent" };
render(
<RenderResponse responseData={5} question={question} survey={defaultSurvey} language={dummyLanguage} />
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("5");
});
test("renders ResponseBadges for 'CTA' question (string)", () => {
const question = { ...defaultQuestion, type: "cta" };
render(
<RenderResponse
responseData={"click"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("Click");
});
test("renders ResponseBadges for 'MultipleChoiceSingle' question (string)", () => {
const question = { ...defaultQuestion, type: "multipleChoiceSingle" };
render(
<RenderResponse
responseData={"option1"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("option1");
});
test("renders ResponseBadges for 'MultipleChoiceMulti' question (array)", () => {
const question = { ...defaultQuestion, type: "multipleChoiceMulti" };
render(
<RenderResponse
responseData={["opt1", "opt2"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("opt1,opt2");
});
test("renders ResponseBadges for 'NPS' question (number)", () => {
const question = { ...defaultQuestion, type: "nps" };
render(
<RenderResponse responseData={9} question={question} survey={defaultSurvey} language={dummyLanguage} />
);
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("9");
});
test("renders RankingRespone for 'Ranking' question", () => {
const question = { ...defaultQuestion, type: "ranking" };
render(
<RenderResponse
responseData={["first", "second"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByTestId("RankingRespone")).toHaveTextContent("first,second");
});
test("renders default branch for unknown question type with string", () => {
const question = { ...defaultQuestion, type: "unknown" };
render(
<RenderResponse
responseData={"some text"}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByText("hyper:some text")).toBeInTheDocument();
});
test("renders default branch for unknown question type with array", () => {
const question = { ...defaultQuestion, type: "unknown" };
render(
<RenderResponse
responseData={["a", "b"]}
question={question}
survey={defaultSurvey}
language={dummyLanguage}
/>
);
expect(screen.getByText("a, b")).toBeInTheDocument();
});
});

View File

@@ -67,10 +67,11 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
break;
case TSurveyQuestionTypeEnum.Date:
if (typeof responseData === "string") {
const formattedDateString = formatDateWithOrdinal(new Date(responseData));
return (
<p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDateString}</p>
);
const parsedDate = new Date(responseData);
const formattedDate = isNaN(parsedDate.getTime()) ? responseData : formatDateWithOrdinal(parsedDate);
return <p className="ph-no-capture my-1 truncate font-normal text-slate-700">{formattedDate}</p>;
}
break;
case TSurveyQuestionTypeEnum.PictureSelection:
@@ -100,7 +101,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
return (
<p
key={rowValueInSelectedLanguage}
className="ph-no-capture my-1 font-normal capitalize text-slate-700">
className="ph-no-capture my-1 font-normal text-slate-700 capitalize">
{rowValueInSelectedLanguage}:{processResponseData(responseData[rowValueInSelectedLanguage])}
</p>
);

View File

@@ -0,0 +1,192 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TResponseNote } from "@formbricks/types/responses";
import { TUser } from "@formbricks/types/user";
import { createResponseNoteAction, resolveResponseNoteAction, updateResponseNoteAction } from "../actions";
import { ResponseNotes } from "./ResponseNote";
const dummyUser = { id: "user1", name: "User One" } as TUser;
const dummyResponseId = "resp1";
const dummyLocale = "en-US";
const dummyNote = {
id: "note1",
text: "Initial note",
isResolved: true,
isEdited: false,
updatedAt: new Date(),
user: { id: "user1", name: "User One" },
} as TResponseNote;
const dummyUnresolvedNote = {
id: "note1",
text: "Initial note",
isResolved: false,
isEdited: false,
updatedAt: new Date(),
user: { id: "user1", name: "User One" },
} as TResponseNote;
const updateFetchedResponses = vi.fn();
const setIsOpen = vi.fn();
vi.mock("../actions", () => ({
createResponseNoteAction: vi.fn().mockResolvedValue("createdNote"),
updateResponseNoteAction: vi.fn().mockResolvedValue("updatedNote"),
resolveResponseNoteAction: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: (props: any) => <button {...props}>{props.children}</button>,
}));
// Mock icons for edit and resolve buttons with test ids
vi.mock("lucide-react", () => {
const actual = vi.importActual("lucide-react");
return {
...actual,
PencilIcon: (props: any) => (
<button data-testid="pencil-button" {...props}>
Pencil
</button>
),
CheckIcon: (props: any) => (
<button data-testid="check-button" {...props}>
Check
</button>
),
PlusIcon: (props: any) => (
<span data-testid="plus-icon" {...props}>
Plus
</span>
),
Maximize2Icon: (props: any) => (
<span data-testid="maximize-icon" {...props}>
Maximize
</span>
),
Minimize2Icon: (props: any) => (
<button data-testid="minimize-button" {...props}>
Minimize
</button>
),
};
});
// Mock tooltip components
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: any) => <div>{children}</div>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
TooltipProvider: ({ children }: any) => <div>{children}</div>,
TooltipTrigger: ({ children }: any) => <div>{children}</div>,
}));
describe("ResponseNotes", () => {
afterEach(() => {
cleanup();
});
test("renders collapsed view when isOpen is false", () => {
render(
<ResponseNotes
user={dummyUser}
responseId={dummyResponseId}
notes={[dummyNote]}
isOpen={false}
setIsOpen={setIsOpen}
updateFetchedResponses={updateFetchedResponses}
locale={dummyLocale}
/>
);
expect(screen.getByText(/note/i)).toBeInTheDocument();
});
test("opens panel on click when collapsed", async () => {
render(
<ResponseNotes
user={dummyUser}
responseId={dummyResponseId}
notes={[dummyNote]}
isOpen={false}
setIsOpen={setIsOpen}
updateFetchedResponses={updateFetchedResponses}
locale={dummyLocale}
/>
);
await userEvent.click(screen.getByText(/note/i));
expect(setIsOpen).toHaveBeenCalledWith(true);
});
test("submits a new note", async () => {
vi.mocked(createResponseNoteAction).mockResolvedValueOnce("createdNote" as any);
render(
<ResponseNotes
user={dummyUser}
responseId={dummyResponseId}
notes={[]}
isOpen={true}
setIsOpen={setIsOpen}
updateFetchedResponses={updateFetchedResponses}
locale={dummyLocale}
/>
);
const textarea = screen.getByRole("textbox");
await userEvent.type(textarea, "New note");
await userEvent.type(textarea, "{enter}");
await waitFor(() => {
expect(createResponseNoteAction).toHaveBeenCalledWith({
responseId: dummyResponseId,
text: "New note",
});
expect(updateFetchedResponses).toHaveBeenCalled();
});
});
test("edits an existing note", async () => {
vi.mocked(updateResponseNoteAction).mockResolvedValueOnce("updatedNote" as any);
render(
<ResponseNotes
user={dummyUser}
responseId={dummyResponseId}
notes={[dummyUnresolvedNote]}
isOpen={true}
setIsOpen={setIsOpen}
updateFetchedResponses={updateFetchedResponses}
locale={dummyLocale}
/>
);
const pencilButton = screen.getByTestId("pencil-button");
await userEvent.click(pencilButton);
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveValue("Initial note");
await userEvent.clear(textarea);
await userEvent.type(textarea, "Updated note");
await userEvent.type(textarea, "{enter}");
await waitFor(() => {
expect(updateResponseNoteAction).toHaveBeenCalledWith({
responseNoteId: dummyNote.id,
text: "Updated note",
});
expect(updateFetchedResponses).toHaveBeenCalled();
});
});
test("resolves a note", async () => {
vi.mocked(resolveResponseNoteAction).mockResolvedValueOnce(undefined);
render(
<ResponseNotes
user={dummyUser}
responseId={dummyResponseId}
notes={[dummyUnresolvedNote]}
isOpen={true}
setIsOpen={setIsOpen}
updateFetchedResponses={updateFetchedResponses}
locale={dummyLocale}
/>
);
const checkButton = screen.getByTestId("check-button");
userEvent.click(checkButton);
await waitFor(() => {
expect(resolveResponseNoteAction).toHaveBeenCalledWith({ responseNoteId: dummyNote.id });
expect(updateFetchedResponses).toHaveBeenCalled();
});
});
});

View File

@@ -4,8 +4,7 @@ import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import clsx from "clsx";
import { CheckIcon, PencilIcon, PlusIcon } from "lucide-react";
import { Maximize2Icon, Minimize2Icon } from "lucide-react";
import { CheckIcon, Maximize2Icon, Minimize2Icon, PencilIcon, PlusIcon } from "lucide-react";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { cn } from "@formbricks/lib/cn";
@@ -105,10 +104,10 @@ export const ResponseNotes = ({
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
isOpen
? "-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
? "top-0 -right-2 h-5/6 max-h-[600px] w-1/4 bg-white"
: unresolvedNotes.length
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
? "top-[8.33%] right-0 h-5/6 max-h-[600px] w-1/12"
: "top-[8.333%] right-[120px] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
)}
onClick={() => {
if (!isOpen) setIsOpen(true);
@@ -117,7 +116,7 @@ export const ResponseNotes = ({
<div className="flex h-full flex-col">
<div
className={clsx(
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
"space-y-2 rounded-t-lg px-2 pt-2 pb-2",
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
)}>
{!unresolvedNotes.length ? (
@@ -128,7 +127,7 @@ export const ResponseNotes = ({
</div>
) : (
<div className="float-left mr-1.5">
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
<Maximize2Icon className="h-4 w-4 text-amber-500 group-hover/hint:scale-110 hover:text-amber-600" />
</div>
)}
</div>
@@ -142,7 +141,7 @@ export const ResponseNotes = ({
</div>
) : (
<div className="relative flex h-full flex-col">
<div className="rounded-t-lg bg-amber-50 px-4 pb-3 pt-4">
<div className="rounded-t-lg bg-amber-50 px-4 pt-4 pb-3">
<div className="flex items-center justify-between">
<div className="group flex items-center">
<h3 className="pb-1 text-sm text-amber-500">{t("common.note")}</h3>
@@ -228,9 +227,7 @@ export const ResponseNotes = ({
onKeyDown={(e) => {
if (e.key === "Enter" && noteText) {
e.preventDefault();
{
isUpdatingNote ? handleNoteUpdate(e) : handleNoteSubmission(e);
}
isUpdatingNote ? handleNoteUpdate(e) : handleNoteSubmission(e);
}
}}
required></textarea>

View File

@@ -0,0 +1,245 @@
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TTag } from "@formbricks/types/tags";
import { createTagAction, createTagToResponseAction, deleteTagOnResponseAction } from "../actions";
import { ResponseTagsWrapper } from "./ResponseTagsWrapper";
const dummyTags = [
{ tagId: "tag1", tagName: "Tag One" },
{ tagId: "tag2", tagName: "Tag Two" },
];
const dummyEnvironmentId = "env1";
const dummyResponseId = "resp1";
const dummyEnvironmentTags = [
{ id: "tag1", name: "Tag One" },
{ id: "tag2", name: "Tag Two" },
{ id: "tag3", name: "Tag Three" },
] as TTag[];
const dummyUpdateFetchedResponses = vi.fn();
const dummyRouterPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: dummyRouterPush,
}),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((res) => res.error?.details[0].issue || "error"),
}));
vi.mock("../actions", () => ({
createTagAction: vi.fn(),
createTagToResponseAction: vi.fn(),
deleteTagOnResponseAction: vi.fn(),
}));
// Mock Button, Tag and TagsCombobox components
vi.mock("@/modules/ui/components/button", () => ({
Button: (props: any) => <button {...props}>{props.children}</button>,
}));
vi.mock("@/modules/ui/components/tag", () => ({
Tag: (props: any) => (
<div data-testid="tag">
{props.tagName}
{props.allowDelete && <button onClick={() => props.onDelete(props.tagId)}>Delete</button>}
</div>
),
}));
vi.mock("@/modules/ui/components/tags-combobox", () => ({
TagsCombobox: (props: any) => (
<div data-testid="tags-combobox">
<button onClick={() => props.createTag("NewTag")}>CreateTag</button>
<button onClick={() => props.addTag("tag3")}>AddTag</button>
</div>
),
}));
describe("ResponseTagsWrapper", () => {
afterEach(() => {
cleanup();
});
test("renders settings button when not readOnly and navigates on click", async () => {
render(
<ResponseTagsWrapper
tags={dummyTags}
environmentId={dummyEnvironmentId}
responseId={dummyResponseId}
environmentTags={dummyEnvironmentTags}
updateFetchedResponses={dummyUpdateFetchedResponses}
isReadOnly={false}
/>
);
const settingsButton = screen.getByRole("button", { name: "" });
await userEvent.click(settingsButton);
expect(dummyRouterPush).toHaveBeenCalledWith(`/environments/${dummyEnvironmentId}/project/tags`);
});
test("does not render settings button when readOnly", () => {
render(
<ResponseTagsWrapper
tags={dummyTags}
environmentId={dummyEnvironmentId}
responseId={dummyResponseId}
environmentTags={dummyEnvironmentTags}
updateFetchedResponses={dummyUpdateFetchedResponses}
isReadOnly={true}
/>
);
expect(screen.queryByRole("button")).toBeNull();
});
test("renders provided tags", () => {
render(
<ResponseTagsWrapper
tags={dummyTags}
environmentId={dummyEnvironmentId}
responseId={dummyResponseId}
environmentTags={dummyEnvironmentTags}
updateFetchedResponses={dummyUpdateFetchedResponses}
isReadOnly={false}
/>
);
expect(screen.getAllByTestId("tag").length).toBe(2);
expect(screen.getByText("Tag One")).toBeInTheDocument();
expect(screen.getByText("Tag Two")).toBeInTheDocument();
});
test("calls deleteTagOnResponseAction on tag delete success", async () => {
vi.mocked(deleteTagOnResponseAction).mockResolvedValueOnce({ data: "deleted" } as any);
render(
<ResponseTagsWrapper
tags={dummyTags}
environmentId={dummyEnvironmentId}
responseId={dummyResponseId}
environmentTags={dummyEnvironmentTags}
updateFetchedResponses={dummyUpdateFetchedResponses}
isReadOnly={false}
/>
);
const deleteButtons = screen.getAllByText("Delete");
await userEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(deleteTagOnResponseAction).toHaveBeenCalledWith({ responseId: dummyResponseId, tagId: "tag1" });
expect(dummyUpdateFetchedResponses).toHaveBeenCalled();
});
});
test("shows toast error on deleteTagOnResponseAction error", async () => {
vi.mocked(deleteTagOnResponseAction).mockRejectedValueOnce(new Error("delete error"));
render(
<ResponseTagsWrapper
tags={dummyTags}
environmentId={dummyEnvironmentId}
responseId={dummyResponseId}
environmentTags={dummyEnvironmentTags}
updateFetchedResponses={dummyUpdateFetchedResponses}
isReadOnly={false}
/>
);
const deleteButtons = screen.getAllByText("Delete");
await userEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.responses.an_error_occurred_deleting_the_tag"
);
});
});
test("creates a new tag via TagsCombobox and calls updateFetchedResponses on success", async () => {
vi.mocked(createTagAction).mockResolvedValueOnce({ data: { id: "newTagId", name: "NewTag" } } as any);
vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any);
render(
<ResponseTagsWrapper
tags={dummyTags}
environmentId={dummyEnvironmentId}
responseId={dummyResponseId}
environmentTags={dummyEnvironmentTags}
updateFetchedResponses={dummyUpdateFetchedResponses}
isReadOnly={false}
/>
);
const createButton = screen.getByTestId("tags-combobox").querySelector("button");
await userEvent.click(createButton!);
await waitFor(() => {
expect(createTagAction).toHaveBeenCalledWith({ environmentId: dummyEnvironmentId, tagName: "NewTag" });
expect(createTagToResponseAction).toHaveBeenCalledWith({
responseId: dummyResponseId,
tagId: "newTagId",
});
expect(dummyUpdateFetchedResponses).toHaveBeenCalled();
});
});
test("handles createTagAction failure and shows toast error", async () => {
vi.mocked(createTagAction).mockResolvedValueOnce({
error: { details: [{ issue: "Unique constraint failed on the fields" }] },
} as any);
render(
<ResponseTagsWrapper
tags={dummyTags}
environmentId={dummyEnvironmentId}
responseId={dummyResponseId}
environmentTags={dummyEnvironmentTags}
updateFetchedResponses={dummyUpdateFetchedResponses}
isReadOnly={false}
/>
);
const createButton = screen.getByTestId("tags-combobox").querySelector("button");
await userEvent.click(createButton!);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.tag_already_exists", {
duration: 2000,
icon: expect.anything(),
});
});
});
test("calls addTag correctly via TagsCombobox", async () => {
vi.mocked(createTagToResponseAction).mockResolvedValueOnce({ data: "tagAdded" } as any);
render(
<ResponseTagsWrapper
tags={dummyTags}
environmentId={dummyEnvironmentId}
responseId={dummyResponseId}
environmentTags={dummyEnvironmentTags}
updateFetchedResponses={dummyUpdateFetchedResponses}
isReadOnly={false}
/>
);
const addButton = screen.getByTestId("tags-combobox").querySelectorAll("button")[1];
await userEvent.click(addButton);
await waitFor(() => {
expect(createTagToResponseAction).toHaveBeenCalledWith({ responseId: dummyResponseId, tagId: "tag3" });
expect(dummyUpdateFetchedResponses).toHaveBeenCalled();
});
});
test("clears tagIdToHighlight after timeout", async () => {
vi.useFakeTimers();
render(
<ResponseTagsWrapper
tags={dummyTags}
environmentId={dummyEnvironmentId}
responseId={dummyResponseId}
environmentTags={dummyEnvironmentTags}
updateFetchedResponses={dummyUpdateFetchedResponses}
isReadOnly={false}
/>
);
// We simulate that tagIdToHighlight is set (simulate via setState if possible)
// Here we directly invoke the effect by accessing component instance is not trivial in RTL;
// Instead, we manually advance timers to ensure cleanup timeout is executed.
await act(async () => {
vi.advanceTimersByTime(2000);
});
// No error expected; test passes if timer runs without issue.
expect(true).toBe(true);
});
});

View File

@@ -8,7 +8,7 @@ import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon, SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import toast from "react-hot-toast";
import { TTag } from "@formbricks/types/tags";
import { createTagAction, createTagToResponseAction, deleteTagOnResponseAction } from "../actions";

View File

@@ -0,0 +1,80 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TResponseVariables } from "@formbricks/types/responses";
import { TSurveyVariables } from "@formbricks/types/surveys/types";
import { ResponseVariables } from "./ResponseVariables";
const dummyVariables = [
{ id: "v1", name: "Variable One", type: "number" },
{ id: "v2", name: "Variable Two", type: "string" },
{ id: "v3", name: "Variable Three", type: "object" },
] as unknown as TSurveyVariables;
const dummyVariablesData = {
v1: 123,
v2: "abc",
v3: { not: "valid" },
} as unknown as TResponseVariables;
// Mock tooltip components
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: any) => <div>{children}</div>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
TooltipProvider: ({ children }: any) => <div>{children}</div>,
TooltipTrigger: ({ children }: any) => <div>{children}</div>,
}));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
}));
// Mock i18n utils
vi.mock("@/modules/i18n/utils", () => ({
getLocalizedValue: vi.fn((val, _) => val),
getLanguageCode: vi.fn().mockReturnValue("default"),
}));
// Mock lucide-react icons to render identifiable elements
vi.mock("lucide-react", () => ({
FileDigitIcon: () => <div data-testid="FileDigitIcon" />,
FileType2Icon: () => <div data-testid="FileType2Icon" />,
}));
describe("ResponseVariables", () => {
afterEach(() => {
cleanup();
});
test("renders nothing when no variable in variablesData meets type check", () => {
render(
<ResponseVariables
variables={dummyVariables}
variablesData={{ v3: { not: "valid" } } as unknown as TResponseVariables}
/>
);
expect(screen.queryByText("Variable One")).toBeNull();
expect(screen.queryByText("Variable Two")).toBeNull();
});
test("renders variables with valid response data", () => {
render(<ResponseVariables variables={dummyVariables} variablesData={dummyVariablesData} />);
expect(screen.getByText("Variable One")).toBeInTheDocument();
expect(screen.getByText("Variable Two")).toBeInTheDocument();
// Check that the value is rendered
expect(screen.getByText("123")).toBeInTheDocument();
expect(screen.getByText("abc")).toBeInTheDocument();
});
test("renders FileDigitIcon for number type and FileType2Icon for string type", () => {
render(<ResponseVariables variables={dummyVariables} variablesData={dummyVariablesData} />);
expect(screen.getByTestId("FileDigitIcon")).toBeInTheDocument();
expect(screen.getByTestId("FileType2Icon")).toBeInTheDocument();
});
test("displays tooltip content with 'common.variable'", () => {
render(<ResponseVariables variables={dummyVariables} variablesData={dummyVariablesData} />);
// TooltipContent mock always renders its children directly.
expect(screen.getAllByText("common.variable")[0]).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,125 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { SingleResponseCardBody } from "./SingleResponseCardBody";
// Mocks for imported components to return identifiable elements
vi.mock("./QuestionSkip", () => ({
QuestionSkip: (props: any) => <div data-testid="QuestionSkip">{props.status}</div>,
}));
vi.mock("./RenderResponse", () => ({
RenderResponse: (props: any) => <div data-testid="RenderResponse">{props.responseData.toString()}</div>,
}));
vi.mock("./ResponseVariables", () => ({
ResponseVariables: (props: any) => <div data-testid="ResponseVariables">Variables</div>,
}));
vi.mock("./HiddenFields", () => ({
HiddenFields: (props: any) => <div data-testid="HiddenFields">Hidden</div>,
}));
vi.mock("./VerifiedEmail", () => ({
VerifiedEmail: (props: any) => <div data-testid="VerifiedEmail">VerifiedEmail</div>,
}));
// Mocks for utility functions used inside component
vi.mock("@formbricks/lib/utils/recall", () => ({
parseRecallInfo: vi.fn((headline, data) => "parsed:" + headline),
}));
vi.mock("@formbricks/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn((headline) => headline),
}));
vi.mock("../util", () => ({
isValidValue: (val: any) => {
if (typeof val === "string") return val.trim() !== "";
if (Array.isArray(val)) return val.length > 0;
if (typeof val === "number") return true;
if (typeof val === "object") return Object.keys(val).length > 0;
return false;
},
}));
// Mock CheckCircle2Icon from lucide-react
vi.mock("lucide-react", () => ({
CheckCircle2Icon: () => <div data-testid="CheckCircle2Icon">CheckCircle</div>,
}));
describe("SingleResponseCardBody", () => {
afterEach(() => {
cleanup();
});
const dummySurvey = {
welcomeCard: { enabled: true },
isVerifyEmailEnabled: true,
questions: [
{ id: "q1", headline: "headline1" },
{ id: "q2", headline: "headline2" },
],
variables: [{ id: "var1", name: "Variable1", type: "string" }],
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
} as unknown as TSurvey;
const dummyResponse = {
id: "resp1",
finished: true,
data: { q1: "answer1", q2: "", verifiedEmail: true, hf1: "hiddenVal" },
variables: { var1: "varValue" },
language: "en",
} as unknown as TResponse;
test("renders welcomeCard branch when enabled", () => {
render(<SingleResponseCardBody survey={dummySurvey} response={dummyResponse} skippedQuestions={[]} />);
expect(screen.getAllByTestId("QuestionSkip")[0]).toHaveTextContent("welcomeCard");
});
test("renders VerifiedEmail when enabled and response verified", () => {
render(<SingleResponseCardBody survey={dummySurvey} response={dummyResponse} skippedQuestions={[]} />);
expect(screen.getByTestId("VerifiedEmail")).toBeInTheDocument();
});
test("renders RenderResponse for valid answer", () => {
const surveyCopy = { ...dummySurvey, welcomeCard: { enabled: false } } as TSurvey;
const responseCopy = { ...dummyResponse, data: { q1: "answer1", q2: "" } };
render(<SingleResponseCardBody survey={surveyCopy} response={responseCopy} skippedQuestions={[]} />);
// For question q1 answer is valid so RenderResponse is rendered
expect(screen.getByTestId("RenderResponse")).toHaveTextContent("answer1");
});
test("renders QuestionSkip for invalid answer", () => {
const surveyCopy = { ...dummySurvey, welcomeCard: { enabled: false } } as TSurvey;
const responseCopy = { ...dummyResponse, data: { q1: "", q2: "" } };
render(
<SingleResponseCardBody survey={surveyCopy} response={responseCopy} skippedQuestions={[["q1"]]} />
);
// Renders QuestionSkip for q1 or q2 branch
expect(screen.getAllByTestId("QuestionSkip")[1]).toBeInTheDocument();
});
test("renders ResponseVariables when variables exist", () => {
render(<SingleResponseCardBody survey={dummySurvey} response={dummyResponse} skippedQuestions={[]} />);
expect(screen.getByTestId("ResponseVariables")).toBeInTheDocument();
});
test("renders HiddenFields when hiddenFields enabled", () => {
render(<SingleResponseCardBody survey={dummySurvey} response={dummyResponse} skippedQuestions={[]} />);
expect(screen.getByTestId("HiddenFields")).toBeInTheDocument();
});
test("renders completion indicator when response finished", () => {
render(<SingleResponseCardBody survey={dummySurvey} response={dummyResponse} skippedQuestions={[]} />);
expect(screen.getByTestId("CheckCircle2Icon")).toBeInTheDocument();
expect(screen.getByText("common.completed")).toBeInTheDocument();
});
test("processes question mapping correctly with skippedQuestions modification", () => {
// Provide one question valid and one not valid, with skippedQuestions for the invalid one.
const surveyCopy = { ...dummySurvey, welcomeCard: { enabled: false } } as TSurvey;
const responseCopy = { ...dummyResponse, data: { q1: "answer1", q2: "" } };
// Initially, skippedQuestions contains ["q2"].
render(
<SingleResponseCardBody survey={surveyCopy} response={responseCopy} skippedQuestions={[["q2"]]} />
);
// For q1, RenderResponse is rendered since answer valid.
expect(screen.getByTestId("RenderResponse")).toBeInTheDocument();
// For q2, QuestionSkip is rendered. Our mock for QuestionSkip returns text "skipped".
expect(screen.getByText("skipped")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,176 @@
import { isSubmissionTimeMoreThan5Minutes } from "@/modules/analysis/components/SingleResponseCard/util";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { SingleResponseCardHeader } from "./SingleResponseCardHeader";
// Mocks
vi.mock("@/modules/ui/components/avatars", () => ({
PersonAvatar: ({ personId }: any) => <div data-testid="PersonAvatar">Avatar: {personId}</div>,
}));
vi.mock("@/modules/ui/components/survey-status-indicator", () => ({
SurveyStatusIndicator: ({ status }: any) => <div data-testid="SurveyStatusIndicator">Status: {status}</div>,
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
Tooltip: ({ children }: any) => <div>{children}</div>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
TooltipProvider: ({ children }: any) => <div>{children}</div>,
TooltipTrigger: ({ children }: any) => <div>{children}</div>,
}));
vi.mock("@formbricks/lib/i18n/utils", () => ({
getLanguageLabel: vi.fn((lang, locale) => lang + "_" + locale),
}));
vi.mock("@/modules/lib/time", () => ({
timeSince: vi.fn(() => "5 minutes ago"),
}));
vi.mock("@/modules/lib/utils/contact", () => ({
getContactIdentifier: vi.fn((contact, attributes) => attributes?.email || contact?.userId || ""),
}));
vi.mock("../util", () => ({
isSubmissionTimeMoreThan5Minutes: vi.fn(),
}));
describe("SingleResponseCardHeader", () => {
afterEach(() => {
cleanup();
});
const dummySurvey = {
id: "survey1",
name: "Test Survey",
environmentId: "env1",
} as TSurvey;
const dummyResponse = {
id: "resp1",
finished: false,
updatedAt: new Date("2023-01-01T12:00:00Z"),
createdAt: new Date("2023-01-01T11:00:00Z"),
language: "en",
contact: { id: "contact1", name: "Alice" },
contactAttributes: { attr: "value" },
meta: {
userAgent: { browser: "Chrome", os: "Windows", device: "PC" },
url: "http://example.com",
action: "click",
source: "web",
country: "USA",
},
singleUseId: "su123",
} as unknown as TResponse;
const dummyEnvironment = { id: "env1" } as TEnvironment;
const dummyUser = { id: "user1", email: "user1@example.com" } as TUser;
const dummyLocale = "en-US";
test("renders response view with contact (user exists)", () => {
vi.mocked(isSubmissionTimeMoreThan5Minutes).mockReturnValue(true);
render(
<SingleResponseCardHeader
pageType="response"
response={dummyResponse}
survey={{ ...dummySurvey }}
environment={dummyEnvironment}
user={dummyUser}
isReadOnly={false}
setDeleteDialogOpen={vi.fn()}
locale={dummyLocale}
/>
);
// Expect Link wrapping PersonAvatar and display identifier
expect(screen.getByTestId("PersonAvatar")).toHaveTextContent("Avatar: contact1");
expect(screen.getByRole("link")).toBeInTheDocument();
});
test("renders response view with no contact (anonymous)", () => {
const responseNoContact = { ...dummyResponse, contact: null };
render(
<SingleResponseCardHeader
pageType="response"
response={responseNoContact}
survey={{ ...dummySurvey }}
environment={dummyEnvironment}
user={dummyUser}
isReadOnly={false}
setDeleteDialogOpen={vi.fn()}
locale={dummyLocale}
/>
);
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
});
test("renders people view", () => {
render(
<SingleResponseCardHeader
pageType="people"
response={dummyResponse}
survey={{ ...dummySurvey, type: "link" }}
environment={dummyEnvironment}
user={dummyUser}
isReadOnly={false}
setDeleteDialogOpen={vi.fn()}
locale={dummyLocale}
/>
);
expect(screen.getByRole("link")).toBeInTheDocument();
expect(screen.getByText("Test Survey")).toBeInTheDocument();
expect(screen.getByTestId("SurveyStatusIndicator")).toBeInTheDocument();
});
test("renders language label when response.language is not default", () => {
const modifiedResponse = { ...dummyResponse, language: "fr" };
render(
<SingleResponseCardHeader
pageType="response"
response={modifiedResponse}
survey={{ ...dummySurvey }}
environment={dummyEnvironment}
user={dummyUser}
isReadOnly={false}
setDeleteDialogOpen={vi.fn()}
locale={dummyLocale}
/>
);
expect(screen.getByText("fr_en-US")).toBeInTheDocument();
});
test("renders enabled trash icon and handles click", async () => {
vi.mocked(isSubmissionTimeMoreThan5Minutes).mockReturnValue(true);
const setDeleteDialogOpen = vi.fn();
render(
<SingleResponseCardHeader
pageType="response"
response={dummyResponse}
survey={{ ...dummySurvey }}
environment={dummyEnvironment}
user={dummyUser}
isReadOnly={false}
setDeleteDialogOpen={setDeleteDialogOpen}
locale={dummyLocale}
/>
);
const trashIcon = screen.getByLabelText("Delete response");
await userEvent.click(trashIcon);
expect(setDeleteDialogOpen).toHaveBeenCalledWith(true);
});
test("renders disabled trash icon when deletion not allowed", async () => {
vi.mocked(isSubmissionTimeMoreThan5Minutes).mockReturnValue(false);
render(
<SingleResponseCardHeader
pageType="response"
response={dummyResponse}
survey={{ ...dummySurvey }}
environment={dummyEnvironment}
user={dummyUser}
isReadOnly={false}
setDeleteDialogOpen={vi.fn()}
locale={dummyLocale}
/>
);
const disabledTrash = screen.getByLabelText("Cannot delete response in progress");
expect(disabledTrash).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,60 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import {
ConfusedFace,
FrowningFace,
GrinningFaceWithSmilingEyes,
GrinningSquintingFace,
NeutralFace,
PerseveringFace,
SlightlySmilingFace,
SmilingFaceWithSmilingEyes,
TiredFace,
WearyFace,
} from "./Smileys";
const checkSvg = (Component: React.FC<React.SVGProps<SVGElement>>) => {
const { container } = render(<Component />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg).toHaveAttribute("viewBox", "0 0 72 72");
expect(svg).toHaveAttribute("width", "36");
expect(svg).toHaveAttribute("height", "36");
};
describe("Smileys", () => {
afterEach(() => {
cleanup();
});
test("renders TiredFace", () => {
checkSvg(TiredFace);
});
test("renders WearyFace", () => {
checkSvg(WearyFace);
});
test("renders PerseveringFace", () => {
checkSvg(PerseveringFace);
});
test("renders FrowningFace", () => {
checkSvg(FrowningFace);
});
test("renders ConfusedFace", () => {
checkSvg(ConfusedFace);
});
test("renders NeutralFace", () => {
checkSvg(NeutralFace);
});
test("renders SlightlySmilingFace", () => {
checkSvg(SlightlySmilingFace);
});
test("renders SmilingFaceWithSmilingEyes", () => {
checkSvg(SmilingFaceWithSmilingEyes);
});
test("renders GrinningFaceWithSmilingEyes", () => {
checkSvg(GrinningFaceWithSmilingEyes);
});
test("renders GrinningSquintingFace", () => {
checkSvg(GrinningSquintingFace);
});
});

View File

@@ -0,0 +1,31 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { VerifiedEmail } from "./VerifiedEmail";
vi.mock("lucide-react", () => ({
MailIcon: (props: any) => (
<div data-testid="MailIcon" {...props}>
MailIcon
</div>
),
}));
describe("VerifiedEmail", () => {
afterEach(() => {
cleanup();
});
test("renders verified email text and value when provided", () => {
render(<VerifiedEmail responseData={{ verifiedEmail: "test@example.com" }} />);
expect(screen.getByText("common.verified_email")).toBeInTheDocument();
expect(screen.getByText("test@example.com")).toBeInTheDocument();
expect(screen.getByTestId("MailIcon")).toBeInTheDocument();
});
test("renders empty value when verifiedEmail is not a string", () => {
render(<VerifiedEmail responseData={{ verifiedEmail: 123 }} />);
expect(screen.getByText("common.verified_email")).toBeInTheDocument();
const emptyParagraph = screen.getByText("", { selector: "p.ph-no-capture" });
expect(emptyParagraph.textContent).toBe("");
});
});

View File

@@ -0,0 +1,190 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { deleteResponseAction, getResponseAction } from "./actions";
import { SingleResponseCard } from "./index";
// Dummy data for props
const dummySurvey = {
id: "survey1",
environmentId: "env1",
name: "Test Survey",
status: "completed",
type: "link",
questions: [{ id: "q1" }, { id: "q2" }],
responseCount: 10,
notes: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as unknown as TSurvey;
const dummyResponse = {
id: "resp1",
finished: true,
data: { q1: "answer1", q2: null },
notes: [],
tags: [],
} as unknown as TResponse;
const dummyEnvironment = { id: "env1" } as TEnvironment;
const dummyUser = { id: "user1", email: "user1@example.com", name: "User One" } as TUser;
const dummyLocale = "en-US";
const dummyDeleteResponses = vi.fn();
const dummyUpdateResponse = vi.fn();
const dummySetSelectedResponseId = vi.fn();
// Mock internal components to return identifiable elements
vi.mock("./components/SingleResponseCardHeader", () => ({
SingleResponseCardHeader: (props: any) => (
<div data-testid="SingleResponseCardHeader">
<button onClick={() => props.setDeleteDialogOpen(true)}>Open Delete</button>
</div>
),
}));
vi.mock("./components/SingleResponseCardBody", () => ({
SingleResponseCardBody: () => <div data-testid="SingleResponseCardBody">Body Content</div>,
}));
vi.mock("./components/ResponseTagsWrapper", () => ({
ResponseTagsWrapper: (props: any) => (
<div data-testid="ResponseTagsWrapper">
<button onClick={() => props.updateFetchedResponses()}>Update Responses</button>
</div>
),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, onDelete }: any) =>
open ? (
<button data-testid="DeleteDialog" onClick={() => onDelete()}>
Confirm Delete
</button>
) : null,
}));
vi.mock("./components/ResponseNote", () => ({
ResponseNotes: (props: any) => <div data-testid="ResponseNotes">Notes ({props.notes.length})</div>,
}));
vi.mock("./actions", () => ({
deleteResponseAction: vi.fn().mockResolvedValue("deletedResponse"),
getResponseAction: vi.fn(),
}));
vi.mock("./util", () => ({
isValidValue: (value: any) => value !== null && value !== undefined,
}));
describe("SingleResponseCard", () => {
afterEach(() => {
cleanup();
});
test("renders as a plain div when survey is draft and isReadOnly", () => {
const draftSurvey = { ...dummySurvey, status: "draft" } as TSurvey;
render(
<SingleResponseCard
survey={draftSurvey}
response={dummyResponse}
user={dummyUser}
pageType="response"
environmentTags={[]}
environment={dummyEnvironment}
updateResponse={dummyUpdateResponse}
deleteResponses={dummyDeleteResponses}
isReadOnly={true}
setSelectedResponseId={dummySetSelectedResponseId}
locale={dummyLocale}
/>
);
expect(screen.getByTestId("SingleResponseCardHeader")).toBeInTheDocument();
expect(screen.queryByRole("link")).toBeNull();
});
test("calls deleteResponseAction and refreshes router on successful deletion", async () => {
render(
<SingleResponseCard
survey={dummySurvey}
response={dummyResponse}
user={dummyUser}
pageType="response"
environmentTags={[]}
environment={dummyEnvironment}
updateResponse={dummyUpdateResponse}
deleteResponses={dummyDeleteResponses}
isReadOnly={false}
setSelectedResponseId={dummySetSelectedResponseId}
locale={dummyLocale}
/>
);
userEvent.click(screen.getByText("Open Delete"));
const deleteButton = await screen.findByTestId("DeleteDialog");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(deleteResponseAction).toHaveBeenCalledWith({ responseId: dummyResponse.id });
});
expect(dummyDeleteResponses).toHaveBeenCalledWith([dummyResponse.id]);
});
test("calls toast.error when deleteResponseAction throws error", async () => {
vi.mocked(deleteResponseAction).mockRejectedValueOnce(new Error("Delete failed"));
render(
<SingleResponseCard
survey={dummySurvey}
response={dummyResponse}
user={dummyUser}
pageType="response"
environmentTags={[]}
environment={dummyEnvironment}
updateResponse={dummyUpdateResponse}
deleteResponses={dummyDeleteResponses}
isReadOnly={false}
setSelectedResponseId={dummySetSelectedResponseId}
locale={dummyLocale}
/>
);
await userEvent.click(screen.getByText("Open Delete"));
const deleteButton = await screen.findByTestId("DeleteDialog");
await userEvent.click(deleteButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Delete failed");
});
});
test("calls updateResponse when getResponseAction returns updated response", async () => {
vi.mocked(getResponseAction).mockResolvedValueOnce({ data: { updated: true } as any });
render(
<SingleResponseCard
survey={dummySurvey}
response={dummyResponse}
user={dummyUser}
pageType="response"
environmentTags={[]}
environment={dummyEnvironment}
updateResponse={dummyUpdateResponse}
deleteResponses={dummyDeleteResponses}
isReadOnly={false}
setSelectedResponseId={dummySetSelectedResponseId}
locale={dummyLocale}
/>
);
expect(screen.getByTestId("ResponseTagsWrapper")).toBeInTheDocument();
await userEvent.click(screen.getByText("Update Responses"));
await waitFor(() => {
expect(getResponseAction).toHaveBeenCalledWith({ responseId: dummyResponse.id });
});
await waitFor(() => {
expect(dummyUpdateResponse).toHaveBeenCalledWith(dummyResponse.id, { updated: true });
});
});
});

View File

@@ -11,8 +11,7 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser } from "@formbricks/types/user";
import { TUserLocale } from "@formbricks/types/user";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { deleteResponseAction, getResponseAction } from "./actions";
import { ResponseNotes } from "./components/ResponseNote";
import { ResponseTagsWrapper } from "./components/ResponseTagsWrapper";
@@ -61,28 +60,24 @@ export const SingleResponseCard = ({
survey.questions.forEach((question) => {
if (!isValidValue(response.data[question.id])) {
temp.push(question.id);
} else {
if (temp.length > 0) {
skippedQuestions.push([...temp]);
temp = [];
}
} else if (temp.length > 0) {
skippedQuestions.push([...temp]);
temp = [];
}
});
} else {
for (let index = survey.questions.length - 1; index >= 0; index--) {
const question = survey.questions[index];
if (!response.data[question.id]) {
if (skippedQuestions.length === 0) {
temp.push(question.id);
} else if (skippedQuestions.length > 0 && !isValidValue(response.data[question.id])) {
temp.push(question.id);
}
} else {
if (temp.length > 0) {
temp.reverse();
skippedQuestions.push([...temp]);
temp = [];
}
if (
!response.data[question.id] &&
(skippedQuestions.length === 0 ||
(skippedQuestions.length > 0 && !isValidValue(response.data[question.id])))
) {
temp.push(question.id);
} else if (temp.length > 0) {
temp.reverse();
skippedQuestions.push([...temp]);
temp = [];
}
}
}

View File

@@ -0,0 +1,54 @@
import { describe, expect, test } from "vitest";
import { isSubmissionTimeMoreThan5Minutes, isValidValue } from "./util";
describe("isValidValue", () => {
test("returns false for an empty string", () => {
expect(isValidValue("")).toBe(false);
});
test("returns false for a blank string", () => {
expect(isValidValue(" ")).toBe(false);
});
test("returns true for a non-empty string", () => {
expect(isValidValue("hello")).toBe(true);
});
test("returns true for numbers", () => {
expect(isValidValue(0)).toBe(true);
expect(isValidValue(42)).toBe(true);
});
test("returns false for an empty array", () => {
expect(isValidValue([])).toBe(false);
});
test("returns true for a non-empty array", () => {
expect(isValidValue(["item"])).toBe(true);
});
test("returns false for an empty object", () => {
expect(isValidValue({})).toBe(false);
});
test("returns true for a non-empty object", () => {
expect(isValidValue({ key: "value" })).toBe(true);
});
});
describe("isSubmissionTimeMoreThan5Minutes", () => {
test("returns true if submission time is more than 5 minutes ago", () => {
const currentTime = new Date();
const oldTime = new Date(currentTime.getTime() - 6 * 60 * 1000); // 6 minutes ago
expect(isSubmissionTimeMoreThan5Minutes(oldTime)).toBe(true);
});
test("returns false if submission time is less than or equal to 5 minutes ago", () => {
const currentTime = new Date();
const recentTime = new Date(currentTime.getTime() - 4 * 60 * 1000); // 4 minutes ago
expect(isSubmissionTimeMoreThan5Minutes(recentTime)).toBe(false);
const exact5Minutes = new Date(currentTime.getTime() - 5 * 60 * 1000); // exactly 5 minutes ago
expect(isSubmissionTimeMoreThan5Minutes(exact5Minutes)).toBe(false);
});
});

View File

@@ -0,0 +1,67 @@
import { cleanup } from "@testing-library/react";
import { isValidElement } from "react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { renderHyperlinkedContent } from "./utils";
describe("renderHyperlinkedContent", () => {
afterEach(() => {
cleanup();
});
test("returns a single span element when input has no url", () => {
const input = "Hello world";
const elements = renderHyperlinkedContent(input);
expect(elements).toHaveLength(1);
const element = elements[0];
expect(isValidElement(element)).toBe(true);
// element.type should be "span"
expect(element.type).toBe("span");
expect(element.props.children).toEqual("Hello world");
});
test("splits input with a valid url into span, anchor, span", () => {
const input = "Visit https://example.com for info";
const elements = renderHyperlinkedContent(input);
// Expect three elements: before text, URL link, after text.
expect(elements).toHaveLength(3);
// First element should be span with "Visit "
expect(elements[0].type).toBe("span");
expect(elements[0].props.children).toEqual("Visit ");
// Second element should be an anchor with the URL.
expect(elements[1].type).toBe("a");
expect(elements[1].props.href).toEqual("https://example.com");
expect(elements[1].props.className).toContain("text-blue-500");
// Third element: span with " for info"
expect(elements[2].type).toBe("span");
expect(elements[2].props.children).toEqual(" for info");
});
test("handles multiple valid urls in the input", () => {
const input = "Link1: https://example.com and Link2: https://vitejs.dev";
const elements = renderHyperlinkedContent(input);
// Expected parts: "Link1: ", "https://example.com", " and Link2: ", "https://vitejs.dev", ""
expect(elements).toHaveLength(5);
expect(elements[1].type).toBe("a");
expect(elements[1].props.href).toEqual("https://example.com");
expect(elements[3].type).toBe("a");
expect(elements[3].props.href).toEqual("https://vitejs.dev");
});
test("renders a span instead of anchor when URL constructor throws", () => {
// Force global.URL to throw for this test.
const originalURL = global.URL;
vi.spyOn(global, "URL").mockImplementation(() => {
throw new Error("Invalid URL");
});
const input = "Visit https://broken-url.com now";
const elements = renderHyperlinkedContent(input);
// Expect the URL not to be rendered as anchor because isValidUrl returns false
// The split will still occur, but the element corresponding to the URL should be a span.
expect(elements).toHaveLength(3);
// Check the element that would have been an anchor is now a span.
expect(elements[1].type).toBe("span");
expect(elements[1].props.children).toEqual("https://broken-url.com");
// Restore original URL
global.URL = originalURL;
});
});

View File

@@ -3,7 +3,7 @@ import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request"
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { err, ok, okVoid } from "@formbricks/types/error-handlers";
@@ -25,7 +25,7 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
}));
describe("apiWrapper", () => {
it("should handle request and return response", async () => {
test("should handle request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
@@ -49,7 +49,7 @@ describe("apiWrapper", () => {
expect(handler).toHaveBeenCalled();
});
it("should handle errors and return error response", async () => {
test("should handle errors and return error response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
@@ -67,7 +67,7 @@ describe("apiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
it("should parse body schema correctly", async () => {
test("should parse body schema correctly", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: JSON.stringify({ key: "value" }),
@@ -100,7 +100,7 @@ describe("apiWrapper", () => {
);
});
it("should handle body schema errors", async () => {
test("should handle body schema errors", async () => {
const request = new Request("http://localhost", {
method: "POST",
body: JSON.stringify({ key: 123 }),
@@ -131,7 +131,7 @@ describe("apiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
it("should parse query schema correctly", async () => {
test("should parse query schema correctly", async () => {
const request = new Request("http://localhost?key=value");
vi.mocked(authenticateRequest).mockResolvedValue(
@@ -160,7 +160,7 @@ describe("apiWrapper", () => {
);
});
it("should handle query schema errors", async () => {
test("should handle query schema errors", async () => {
const request = new Request("http://localhost?foo%ZZ=abc");
vi.mocked(authenticateRequest).mockResolvedValue(
@@ -187,7 +187,7 @@ describe("apiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
it("should parse params schema correctly", async () => {
test("should parse params schema correctly", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
@@ -217,7 +217,7 @@ describe("apiWrapper", () => {
);
});
it("should handle no external params", async () => {
test("should handle no external params", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
@@ -245,7 +245,7 @@ describe("apiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
it("should handle params schema errors", async () => {
test("should handle params schema errors", async () => {
const request = new Request("http://localhost");
vi.mocked(authenticateRequest).mockResolvedValue(
@@ -273,7 +273,7 @@ describe("apiWrapper", () => {
expect(handler).not.toHaveBeenCalled();
});
it("should handle rate limit errors", async () => {
test("should handle rate limit errors", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});

View File

@@ -1,5 +1,5 @@
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { authenticateRequest } from "../authenticate-request";
@@ -17,7 +17,7 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({
}));
describe("authenticateRequest", () => {
it("should return authentication data if apiKey is valid", async () => {
test("should return authentication data if apiKey is valid", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
@@ -87,7 +87,7 @@ describe("authenticateRequest", () => {
}
});
it("should return unauthorized error if apiKey is not found", async () => {
test("should return unauthorized error if apiKey is not found", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "invalid-api-key" },
});
@@ -101,7 +101,7 @@ describe("authenticateRequest", () => {
}
});
it("should return unauthorized error if apiKey is missing", async () => {
test("should return unauthorized error if apiKey is missing", async () => {
const request = new Request("http://localhost");
const result = await authenticateRequest(request);

View File

@@ -1,5 +1,5 @@
import { logApiRequest } from "@/modules/api/v2/lib/utils";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { apiWrapper } from "../api-wrapper";
import { authenticatedApiClient } from "../authenticated-api-client";
@@ -12,7 +12,7 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
}));
describe("authenticatedApiClient", () => {
it("should log request and return response", async () => {
test("should log request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});

View File

@@ -7,6 +7,7 @@ import {
ZContactAttributeKeyInput,
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/types/contact-attribute-key";
@@ -54,10 +55,12 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
export const contactAttributeKeyPaths: ZodOpenApiPathsObject = {
"/contact-attribute-keys": {
servers: managementServer,
get: getContactAttributeKeysEndpoint,
post: createContactAttributeKeyEndpoint,
},
"/contact-attribute-keys/{id}": {
servers: managementServer,
get: getContactAttributeKeyEndpoint,
put: updateContactAttributeKeyEndpoint,
delete: deleteContactAttributeKeyEndpoint,

View File

@@ -1,6 +1,9 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
extendZodWithOpenApi(z);
export const ZGetContactAttributeKeysFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),

View File

@@ -7,6 +7,7 @@ import {
ZContactAttributeInput,
ZGetContactAttributesFilter,
} from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttribute } from "@formbricks/types/contact-attribute";
@@ -54,10 +55,12 @@ export const createContactAttributeEndpoint: ZodOpenApiOperationObject = {
export const contactAttributePaths: ZodOpenApiPathsObject = {
"/contact-attributes": {
servers: managementServer,
get: getContactAttributesEndpoint,
post: createContactAttributeEndpoint,
},
"/contact-attributes/{id}": {
servers: managementServer,
get: getContactAttributeEndpoint,
put: updateContactAttributeEndpoint,
delete: deleteContactAttributeEndpoint,

View File

@@ -4,6 +4,7 @@ import {
updateContactEndpoint,
} from "@/modules/api/v2/management/contacts/[contactId]/lib/openapi";
import { ZContactInput, ZGetContactsFilter } from "@/modules/api/v2/management/contacts/types/contacts";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
@@ -56,10 +57,12 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
export const contactPaths: ZodOpenApiPathsObject = {
"/contacts": {
servers: managementServer,
get: getContactsEndpoint,
post: createContactEndpoint,
},
"/contacts/{id}": {
servers: managementServer,
get: getContactEndpoint,
put: updateContactEndpoint,
delete: deleteContactEndpoint,

View File

@@ -1,6 +1,9 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContact } from "@formbricks/database/zod/contact";
extendZodWithOpenApi(z);
export const ZGetContactsFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),

View File

@@ -0,0 +1,6 @@
export const managementServer = [
{
url: `https://app.formbricks.com/api/v2/management`,
description: "Formbricks Management API",
},
];

View File

@@ -1,7 +1,7 @@
import { fetchEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/services";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { err, ok } from "@formbricks/types/error-handlers";
import { getEnvironmentId, getEnvironmentIdFromSurveyIds } from "../helper";
import { fetchEnvironmentId } from "../services";
@@ -12,7 +12,7 @@ vi.mock("../services", () => ({
}));
describe("Tests for getEnvironmentId", () => {
it("should return environmentId for surveyId", async () => {
test("should return environmentId for surveyId", async () => {
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
const result = await getEnvironmentId("survey-id", false);
@@ -22,7 +22,7 @@ describe("Tests for getEnvironmentId", () => {
}
});
it("should return environmentId for responseId", async () => {
test("should return environmentId for responseId", async () => {
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
const result = await getEnvironmentId("response-id", true);
@@ -32,7 +32,7 @@ describe("Tests for getEnvironmentId", () => {
}
});
it("should return error if getSurveyAndEnvironmentId fails", async () => {
test("should return error if getSurveyAndEnvironmentId fails", async () => {
vi.mocked(fetchEnvironmentId).mockResolvedValue(
err({ type: "not_found" } as unknown as ApiErrorResponseV2)
);
@@ -49,7 +49,7 @@ describe("getEnvironmentIdFromSurveyIds", () => {
const envId1 = createId();
const envId2 = createId();
it("returns the common environment id when all survey ids are in the same environment", async () => {
test("returns the common environment id when all survey ids are in the same environment", async () => {
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
ok: true,
data: [envId1, envId1],
@@ -58,7 +58,7 @@ describe("getEnvironmentIdFromSurveyIds", () => {
expect(result).toEqual(ok(envId1));
});
it("returns error when surveys are not in the same environment", async () => {
test("returns error when surveys are not in the same environment", async () => {
vi.mocked(fetchEnvironmentIdFromSurveyIds).mockResolvedValueOnce({
ok: true,
data: [envId1, envId2],
@@ -73,7 +73,7 @@ describe("getEnvironmentIdFromSurveyIds", () => {
}
});
it("returns error when API call fails", async () => {
test("returns error when API call fails", async () => {
const apiError = {
type: "server_error",
details: [{ field: "api", issue: "failed" }],

View File

@@ -1,3 +1,4 @@
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import {
deleteResponseEndpoint,
getResponseEndpoint,
@@ -56,10 +57,12 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
export const responsePaths: ZodOpenApiPathsObject = {
"/responses": {
servers: managementServer,
get: getResponsesEndpoint,
post: createResponseEndpoint,
},
"/responses/{id}": {
servers: managementServer,
get: getResponseEndpoint,
put: updateResponseEndpoint,
delete: deleteResponseEndpoint,

View File

@@ -1,7 +1,7 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
import { Prisma } from "@prisma/client";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { getResponsesQuery } from "../utils";
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
@@ -10,17 +10,17 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({
}));
describe("getResponsesQuery", () => {
it("adds surveyId to where clause if provided", () => {
test("adds surveyId to where clause if provided", () => {
const result = getResponsesQuery(["env-id"], { surveyId: "survey123" } as TGetResponsesFilter);
expect(result?.where?.surveyId).toBe("survey123");
});
it("adds contactId to where clause if provided", () => {
test("adds contactId to where clause if provided", () => {
const result = getResponsesQuery(["env-id"], { contactId: "contact123" } as TGetResponsesFilter);
expect(result?.where?.contactId).toBe("contact123");
});
it("calls pickCommonFilter & buildCommonFilterQuery with correct arguments", () => {
test("calls pickCommonFilter & buildCommonFilterQuery with correct arguments", () => {
vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any);
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any });

View File

@@ -0,0 +1,30 @@
import { ZContactLinkParams } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
operationId: "getPersonalizedSurveyLink",
summary: "Get personalized survey link for a contact",
description: "Retrieves a personalized link for a specific survey.",
requestParams: {
path: ZContactLinkParams,
},
tags: ["Management API > Surveys > Contact Links"],
responses: {
"200": {
description: "Personalized survey link retrieved successfully.",
content: {
"application/json": {
schema: makePartialSchema(
z.object({
data: z.object({
surveyUrl: z.string().url(),
}),
})
),
},
},
},
},
};

View File

@@ -5,20 +5,14 @@ import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getContact } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts";
import { getResponse } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response";
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys";
import {
TContactLinkParams,
ZContactLinkParams,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/types/survey";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
const ZContactLinkParams = z.object({
surveyId: ZId,
contactId: ZId,
});
export const GET = async (
request: Request,
props: { params: Promise<{ surveyId: string; contactId: string }> }
) =>
export const GET = async (request: Request, props: { params: Promise<TContactLinkParams> }) =>
authenticatedApiClient({
request,
externalParams: props.params,

View File

@@ -0,0 +1,23 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZContactLinkParams = z.object({
surveyId: z
.string()
.cuid2()
.openapi({
description: "The ID of the survey",
param: { name: "surveyId", in: "path" },
}),
contactId: z
.string()
.cuid2()
.openapi({
description: "The ID of the contact",
param: { name: "contactId", in: "path" },
}),
});
export type TContactLinkParams = z.infer<typeof ZContactLinkParams>;

View File

@@ -4,7 +4,6 @@ import {
ZContactLinksBySegmentQuery,
} from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/types/contact";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
export const getContactLinksBySegmentEndpoint: ZodOpenApiOperationObject = {
@@ -21,7 +20,7 @@ export const getContactLinksBySegmentEndpoint: ZodOpenApiOperationObject = {
description: "Contact links generated successfully.",
content: {
"application/json": {
schema: z.array(responseWithMetaSchema(makePartialSchema(ZContactLinkResponse))),
schema: responseWithMetaSchema(makePartialSchema(ZContactLinkResponse)),
},
},
},

View File

@@ -1,9 +1,24 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZContactLinksBySegmentParams = z.object({
surveyId: z.string().cuid2().describe("The ID of the survey"),
segmentId: z.string().cuid2().describe("The ID of the segment"),
surveyId: z
.string()
.cuid2()
.openapi({
description: "The ID of the survey",
param: { name: "surveyId", in: "path" },
}),
segmentId: z
.string()
.cuid2()
.openapi({
description: "The ID of the segment",
param: { name: "segmentId", in: "path" },
}),
});
export const ZContactLinksBySegmentQuery = ZGetFilter.pick({

View File

@@ -1,8 +1,10 @@
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { getContactLinksBySegmentEndpoint } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi";
import { ZodOpenApiPathsObject } from "zod-openapi";
export const surveyContactLinksBySegmentPaths: ZodOpenApiPathsObject = {
"/surveys/{surveyId}/contact-links/segments/{segmentId}": {
servers: managementServer,
get: getContactLinksBySegmentEndpoint,
},
};

View File

@@ -1,8 +1,10 @@
import {
deleteSurveyEndpoint,
getSurveyEndpoint,
updateSurveyEndpoint,
} from "@/modules/api/v2/management/surveys/[surveyId]/lib/openapi";
// import {
// deleteSurveyEndpoint,
// getSurveyEndpoint,
// updateSurveyEndpoint,
// } from "@/modules/api/v2/management/surveys/[surveyId]/lib/openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { getPersonalizedSurveyLink } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi";
import { ZGetSurveysFilter, ZSurveyInput } from "@/modules/api/v2/management/surveys/types/surveys";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
@@ -55,13 +57,19 @@ export const createSurveyEndpoint: ZodOpenApiOperationObject = {
};
export const surveyPaths: ZodOpenApiPathsObject = {
"/surveys": {
get: getSurveysEndpoint,
post: createSurveyEndpoint,
},
"/surveys/{id}": {
get: getSurveyEndpoint,
put: updateSurveyEndpoint,
delete: deleteSurveyEndpoint,
// "/surveys": {
// servers: managementServer,
// get: getSurveysEndpoint,
// post: createSurveyEndpoint,
// },
// "/surveys/{id}": {
// servers: managementServer,
// get: getSurveyEndpoint,
// put: updateSurveyEndpoint,
// delete: deleteSurveyEndpoint,
// },
"/surveys/{surveyId}/contact-links/contacts/{contactId}/": {
servers: managementServer,
get: getPersonalizedSurveyLink,
},
};

View File

@@ -1,6 +1,9 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
extendZodWithOpenApi(z);
export const ZGetSurveysFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),

View File

@@ -1,3 +1,4 @@
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import {
deleteWebhookEndpoint,
getWebhookEndpoint,
@@ -56,10 +57,12 @@ export const createWebhookEndpoint: ZodOpenApiOperationObject = {
export const webhookPaths: ZodOpenApiPathsObject = {
"/webhooks": {
servers: managementServer,
get: getWebhooksEndpoint,
post: createWebhookEndpoint,
},
"/webhooks/{id}": {
servers: managementServer,
get: getWebhookEndpoint,
put: updateWebhookEndpoint,
delete: deleteWebhookEndpoint,

View File

@@ -1,6 +1,6 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetWebhooksFilter } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { getWebhooksQuery } from "../utils";
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
@@ -11,7 +11,7 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({
describe("getWebhooksQuery", () => {
const environmentId = "env-123";
it("adds surveyIds condition when provided", () => {
test("adds surveyIds condition when provided", () => {
const params = { surveyIds: ["survey1"] } as TGetWebhooksFilter;
const result = getWebhooksQuery([environmentId], params);
expect(result).toBeDefined();
@@ -21,14 +21,14 @@ describe("getWebhooksQuery", () => {
});
});
it("calls pickCommonFilter and buildCommonFilterQuery when baseFilter is present", () => {
test("calls pickCommonFilter and buildCommonFilterQuery when baseFilter is present", () => {
vi.mocked(pickCommonFilter).mockReturnValue({ someFilter: "test" } as any);
getWebhooksQuery([environmentId], { surveyIds: ["survey1"] } as TGetWebhooksFilter);
expect(pickCommonFilter).toHaveBeenCalled();
expect(buildCommonFilterQuery).toHaveBeenCalled();
});
it("buildCommonFilterQuery is not called if no baseFilter is picked", () => {
test("buildCommonFilterQuery is not called if no baseFilter is picked", () => {
vi.mocked(pickCommonFilter).mockReturnValue(undefined as any);
getWebhooksQuery([environmentId], {} as any);
expect(buildCommonFilterQuery).not.toHaveBeenCalled();

Some files were not shown because too many files have changed in this diff Show More