Compare commits

...

44 Commits

Author SHA1 Message Date
pandeymangg
1fba692626 fix: android sdk segment bug 2025-05-13 19:24:06 +05:30
pandeymangg
3ace91cdd5 fix: android sdk segment bug 2025-05-13 18:39:50 +05:30
pandeymangg
4ba7bf5b3c fix 2025-05-13 16:44:45 +05:30
pandeymangg
bd1402a58b fixes android sdk issues: 2025-05-13 15:55:54 +05:30
pandeymangg
c2af0c3fb6 fixes ios sdk issues and removes callbacks 2025-05-13 14:48:23 +05:30
Piyush Gupta
dde5a55446 fix: CTA and consent question breaking the survey editor (#5745) 2025-05-13 04:18:34 +00:00
Piyush Gupta
13e615a798 fix: duplicate switch cases (#5752)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-12 15:00:55 +00:00
victorvhs017
9c81961b0b chore: remove redundant code (#5751) 2025-05-12 12:23:05 +00:00
Matti Nannt
c1a35e2d75 chore: introduce new reliable cache for enterprise license check (#5740)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-12 10:41:53 +02:00
Piyush Gupta
13415c75c2 docs: adds auth-behaviour docs (#5743) 2025-05-12 05:28:12 +00:00
Johannes
300557a0e6 docs: update ee feature table (#5744) 2025-05-11 22:10:40 -07:00
Anshuman Pandey
fcbb97010c fix: follow ups ending card (#5732) 2025-05-10 10:30:49 +02:00
Matti Nannt
6be46b16b2 fix: limit number of surveys in environment state (#5715) 2025-05-09 16:10:01 +00:00
Matti Nannt
35b2356a31 fix: nextjs cache handler for next 15 (#5717)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-05-09 17:51:57 +02:00
Johannes
53ef756723 test: last round (#5731) 2025-05-09 10:09:09 +02:00
Johannes
0f0b743a10 test: backfill variety of test files (#5729)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-09 07:26:41 +00:00
Matti Nannt
3f7dafb65c fix: failing authOptions test because of missing mock (#5727) 2025-05-09 02:28:55 +02:00
Matti Nannt
9df791b5ff chore: add tests for apps/web/lib files (#5725) (#5726) 2025-05-09 01:35:50 +02:00
Dhruwang Jariwala
dea40d9757 test: unit test for survey.ts (#5724) 2025-05-09 00:54:19 +02:00
Matti Nannt
dd12a589d6 chore: add tests for apps/web/lib files (#5725) 2025-05-08 20:54:50 +00:00
Harsh Bhat
af6e5ba31e chore: add tests for packages/surveys/lib (#5722)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-05-08 20:31:52 +00:00
victorvhs017
2b57b2080b chore: fixed included files on surveys package tests (#5723) 2025-05-08 19:49:02 +00:00
Johannes
154c85a0f7 test: add test to multiple files (#5719)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-08 19:24:50 +00:00
Piyush Gupta
3f465d4594 chore: adds response utils tests (#5721) 2025-05-08 18:44:09 +00:00
Anshuman Pandey
94e883f4c3 chore: adds tests for apps/web/modules/survey/editor/lib (#5720) 2025-05-08 18:42:40 +00:00
victorvhs017
38622101f1 chore: add tests to apps/web/modules/survey/components and apps/web/modules/survey/link (#5707) 2025-05-08 17:24:46 +00:00
Dhruwang Jariwala
0eb64c0084 test: ui module test part 2 (#5716) 2025-05-08 15:37:59 +00:00
Johannes
409f5b1791 test: onboarding unit tests (#5712)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-05-08 14:57:20 +00:00
Harsh Bhat
14398a9c4f chore: add tests for package/surveys/src/components/general (#5699) 2025-05-08 13:23:36 +00:00
Piyush Gupta
d1cdf6e216 chore: adds unit tests for ee/contact/lib and modules/organization (#5713) 2025-05-08 13:06:39 +00:00
Dhruwang Jariwala
65da25a626 fix: rate limiting on login page (#5710) 2025-05-08 12:29:58 +00:00
Anshuman Pandey
ce8b019e93 chore: modules/survey/lib and modules/ee/contacts/api/v1 (#5711) 2025-05-08 10:52:55 +00:00
Dhruwang Jariwala
67d7fe016d test: Test for UI module (Part 1) (#5703) 2025-05-08 08:16:24 +00:00
Dhruwang Jariwala
47583b5a32 fix: unauthorized email address change (#5709) 2025-05-08 06:34:04 +00:00
Jakob Schott
03c9a6aaae chore: 576 test coverage: apps/web/modules/survey/list/lib (#5706) 2025-05-07 21:32:52 +00:00
Jakob Schott
4dcf9b093b chore: 576 test coverage components wrappers (#5702) 2025-05-07 21:31:43 +00:00
Jakob Schott
5ba5ebf63d chore: 576 test coverage apps web modules survey list components (#5704) 2025-05-07 19:24:15 +00:00
victorvhs017
115bea2792 chore: add tests to package/surveys/src/components/questions (#5694) 2025-05-07 18:42:25 +00:00
Piyush Gupta
b0495a8a42 chore: adds unit tests in module/projects (#5701) 2025-05-07 16:34:06 +00:00
Johannes
faabd371f5 fix: infinite loop and freeze (#5622)
Co-authored-by: Jakob Schott <jakob@formbricks.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-05-07 14:19:26 +00:00
Johannes
f0be6de0b3 chore: remove unused code (#5697) 2025-05-07 14:16:27 +00:00
Matti Nannt
b338c6d28d chore: remove unused cache files (#5700) 2025-05-07 15:26:13 +02:00
Anshuman Pandey
07e9a7c007 chore: tests for lib/utils and lib/survey (#5676) 2025-05-07 12:27:48 +00:00
victorvhs017
928bb3f8bc chore: updated sonar qube and vite config (#5695) 2025-05-07 11:13:07 +00:00
616 changed files with 68606 additions and 3950 deletions

View File

@@ -172,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
# Automatically assign new users to a specific organization and role within that organization
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# (Role Management is an Enterprise feature)
# DEFAULT_ORGANIZATION_ROLE=owner
# AUTH_SSO_DEFAULT_TEAM_ID=
# AUTH_SKIP_INVITE_FOR_SSO=
@@ -191,8 +190,7 @@ UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379
REDIS_DEFAULT_TTL=86400 # 1 day
# REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
@@ -200,9 +198,6 @@ REDIS_DEFAULT_TTL=86400 # 1 day
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
# CUSTOM_CACHE_DISABLED=1
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=

View File

@@ -11,7 +11,7 @@ When generating test files inside the "/app/web" path, follow these rules:
- Follow the same test pattern used for other files in the package where the file is located
- All imports should be at the top of the file, not inside individual tests
- For mocking inside "test" blocks use "vi.mocked"
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
- If the file is located in the "packages/survey" path, use "@testing-library/preact" instead of "@testing-library/react"
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
- The types for mocked variables can be found in the "packages/types" path. Be sure that every imported type exists before using it. Don't create types that are not already in the codebase.
@@ -28,4 +28,5 @@ afterEach(() => {
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
- For click events, import userEvent from "@testing-library/user-event"
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
- You don't need to mock @tolgee/react
- You don't need to mock @tolgee/react
- Use "import "@testing-library/jest-dom/vitest";"

View File

@@ -11,6 +11,8 @@ on:
required: false
PLAYWRIGHT_SERVICE_URL:
required: false
ENTERPRISE_LICENSE_KEY:
required: true
# Add other secrets if necessary
workflow_dispatch:
@@ -48,15 +50,17 @@ jobs:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
egress-policy: allow
allowed-endpoints: |
ee.formbricks.com:443
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
- name: Setup Node.js 22.x
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 20.x
node-version: 22.x
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
@@ -75,7 +79,7 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
echo "" >> .env
echo "E2E_TESTING=1" >> .env
shell: bash
@@ -89,8 +93,18 @@ jobs:
# pnpm prisma migrate deploy
pnpm db:migrate:dev
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
if [ -z "$LICENSE_KEY" ]; then
echo "::error::ENTERPRISE_LICENSE_KEY in .env is empty. Please check your secret configuration."
exit 1
fi
echo "License key length: ${#LICENSE_KEY}"
- name: Run App
run: |
echo "Starting app with enterprise license..."
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
sleep 10 # Optional: gives some buffer for the app to start
for attempt in {1..10}; do

View File

@@ -0,0 +1,79 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ConnectWithFormbricks } from "./ConnectWithFormbricks";
// Mocks before import
const pushMock = vi.fn();
const refreshMock = vi.fn();
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock, refresh: refreshMock })) }));
vi.mock("./OnboardingSetupInstructions", () => ({
OnboardingSetupInstructions: () => <div data-testid="instructions" />,
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("ConnectWithFormbricks", () => {
const environment = { id: "env1" } as any;
const webAppUrl = "http://app";
const channel = {} as any;
test("renders waiting state when widgetSetupCompleted is false", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={false}
channel={channel}
/>
);
expect(screen.getByTestId("instructions")).toBeInTheDocument();
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
});
test("renders success state when widgetSetupCompleted is true", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={true}
channel={channel}
/>
);
expect(screen.getByText("environments.connect.congrats")).toBeInTheDocument();
expect(screen.getByText("environments.connect.connection_successful_message")).toBeInTheDocument();
});
test("clicking finish button navigates to surveys", async () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={true}
channel={channel}
/>
);
const button = screen.getByRole("button", { name: "environments.connect.finish_onboarding" });
await userEvent.click(button);
expect(pushMock).toHaveBeenCalledWith(`/environments/${environment.id}/surveys`);
});
test("refresh is called on visibilitychange to visible", () => {
render(
<ConnectWithFormbricks
environment={environment}
webAppUrl={webAppUrl}
widgetSetupCompleted={false}
channel={channel}
/>
);
Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true });
document.dispatchEvent(new Event("visibilitychange"));
expect(refreshMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,144 @@
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { cleanup, render, screen } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import OnboardingLayout from "./layout";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
PRIVACY_URL: "http://localhost:3000/privacy",
TERMS_URL: "http://localhost:3000/terms",
IMPRINT_URL: "http://localhost:3000/imprint",
IMPRINT_ADDRESS: "Mock Address",
PASSWORD_RESET_DISABLED: false,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: false,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
SAML_XML_DIR: "./mock-saml-connection",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
INVITE_DISABLED: false,
SLACK_CLIENT_SECRET: "mock-slack-secret",
SLACK_CLIENT_ID: "mock-slack-id",
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
AIRTABLE_CLIENT_ID: "mock-airtable-id",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "587",
SMTP_SECURE_ENABLED: false,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SMTP_AUTHENTICATED: true,
SMTP_REJECT_UNAUTHORIZED_TLS: true,
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
NEXTAUTH_SECRET: "mock-nextauth-secret",
ITEMS_PER_PAGE: 30,
SURVEYS_PER_PAGE: 12,
RESPONSES_PER_PAGE: 25,
TEXT_RESPONSES_PER_PAGE: 5,
INSIGHTS_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
MAX_OTHER_OPTION_LENGTH: 250,
ENTERPRISE_LICENSE_KEY: "ABC",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
AZURE_ID: "mock-azure-id",
AZUREAD_CLIENT_ID: "mock-azure-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
OIDC_ID: "mock-oidc-id",
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
SAML_ID: "mock-saml-id",
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
OIDC_DISPLAY_NAME: "Mock OIDC",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/lib/environment/auth", () => ({
hasUserEnvironmentAccess: vi.fn(),
}));
describe("OnboardingLayout", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("redirects to login if session is missing", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
await OnboardingLayout({
params: { environmentId: "env1" },
children: <div>Test Content</div>,
});
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("throws AuthorizationError if user lacks access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
await expect(
OnboardingLayout({
params: { environmentId: "env1" },
children: <div>Test Content</div>,
})
).rejects.toThrow("User is not authorized to access this environment");
});
test("renders children if user has access", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
const result = await OnboardingLayout({
params: { environmentId: "env1" },
children: <div data-testid="child">Test Content</div>,
});
render(result);
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
});
});

View File

@@ -16,7 +16,7 @@ const OnboardingLayout = async (props) => {
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!isAuthorized) {
throw AuthorizationError;
throw new AuthorizationError("User is not authorized to access this environment");
}
return <div className="flex-1 bg-slate-50">{children}</div>;

View File

@@ -0,0 +1,76 @@
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { XMTemplateList } from "./XMTemplateList";
// Prepare push mock and module mocks before importing component
const pushMock = vi.fn();
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock })) }));
vi.mock("react-hot-toast", () => ({ default: { error: vi.fn() } }));
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates", () => ({
getXMTemplates: (t: any) => [
{ id: 1, name: "tmpl1" },
{ id: 2, name: "tmpl2" },
],
}));
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils", () => ({
replacePresetPlaceholders: (template: any, project: any) => ({ ...template, projectId: project.id }),
}));
vi.mock("@/modules/survey/components/template-list/actions", () => ({ createSurveyAction: vi.fn() }));
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
<div>
{options.map((opt, idx) => (
<button key={idx} data-testid={`option-${idx}`} onClick={opt.onClick}>
{opt.title}
</button>
))}
</div>
),
}));
// Reset mocks between tests
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("XMTemplateList component", () => {
const project = { id: "proj1" } as any;
const user = { id: "user1" } as any;
const environmentId = "env1";
test("creates survey and navigates on success", async () => {
// Mock successful survey creation
vi.mocked(createSurveyAction).mockResolvedValue({ data: { id: "survey1" } } as any);
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
const option0 = screen.getByTestId("option-0");
await userEvent.click(option0);
expect(createSurveyAction).toHaveBeenCalledWith({
environmentId,
surveyBody: expect.objectContaining({ id: 1, projectId: "proj1", type: "link", createdBy: "user1" }),
});
expect(pushMock).toHaveBeenCalledWith(`/environments/${environmentId}/surveys/survey1/edit?mode=cx`);
});
test("shows error toast on failure", async () => {
// Mock failed survey creation
vi.mocked(createSurveyAction).mockResolvedValue({ error: "err" } as any);
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
const option1 = screen.getByTestId("option-1");
await userEvent.click(option1);
expect(createSurveyAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("formatted-error");
});
});

View File

@@ -0,0 +1,80 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TXMTemplate } from "@formbricks/types/templates";
import { replacePresetPlaceholders } from "./utils";
// Mock data
const mockProject: TProject = {
id: "project1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
organizationId: "org1",
styling: {
allowStyleOverwrite: true,
brandColor: { light: "#FFFFFF" },
},
recontactDays: 30,
inAppSurveyBranding: true,
linkSurveyBranding: true,
config: {
channel: "link" as const,
industry: "eCommerce" as "eCommerce" | "saas" | "other" | null,
},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
languages: [],
logo: null,
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
questions: [
{
id: "q1",
inputType: "text",
type: "email" as any,
headline: { default: "$[projectName] Question" },
required: false,
charLimit: { enabled: true, min: 400, max: 1000 },
},
],
endings: [
{
id: "e1",
type: "endScreen",
headline: { default: "Thank you for completing the survey!" },
},
],
styling: {
brandColor: { light: "#0000FF" },
questionColor: { light: "#00FF00" },
inputColor: { light: "#FF0000" },
},
};
describe("replacePresetPlaceholders", () => {
afterEach(() => {
cleanup();
});
test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Project Survey");
});
test("replaces projectName placeholder in question headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.questions[0].headline.default).toBe("Test Project Question");
});
test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate);
});
});

View File

@@ -0,0 +1,60 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/preact";
import { TFnType } from "@tolgee/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { getXMSurveyDefault, getXMTemplates } from "./xm-templates";
vi.mock("@formbricks/logger", () => ({
logger: { error: vi.fn() },
}));
describe("xm-templates", () => {
afterEach(() => {
cleanup();
});
test("getXMSurveyDefault returns default survey template", () => {
const tMock = vi.fn((key) => key) as TFnType;
const result = getXMSurveyDefault(tMock);
expect(result).toEqual({
name: "",
endings: expect.any(Array),
questions: [],
styling: {
overwriteThemeStyling: true,
},
});
expect(result.endings).toHaveLength(1);
});
test("getXMTemplates returns all templates", () => {
const tMock = vi.fn((key) => key) as TFnType;
const result = getXMTemplates(tMock);
expect(result).toHaveLength(6);
expect(result[0].name).toBe("templates.nps_survey_name");
expect(result[1].name).toBe("templates.star_rating_survey_name");
expect(result[2].name).toBe("templates.csat_survey_name");
expect(result[3].name).toBe("templates.cess_survey_name");
expect(result[4].name).toBe("templates.smileys_survey_name");
expect(result[5].name).toBe("templates.enps_survey_name");
});
test("getXMTemplates handles errors gracefully", async () => {
const tMock = vi.fn(() => {
throw new Error("Test error");
}) as TFnType;
const result = getXMTemplates(tMock);
// Dynamically import the mocked logger
const { logger } = await import("@formbricks/logger");
expect(result).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(
expect.any(Error),
"Unable to load XM templates, returning empty array"
);
});
});

View File

@@ -0,0 +1,58 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { getTeamsByOrganizationId } from "./onboarding";
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/cache", () => ({
cache: (fn: any) => fn,
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
tag: { byOrganizationId: vi.fn((id: string) => `organization-${id}-teams`) },
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
describe("getTeamsByOrganizationId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns mapped teams", async () => {
const mockTeams = [
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
];
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
const result = await getTeamsByOrganizationId("org1");
expect(result).toEqual([
{ id: "t1", name: "Team 1" },
{ id: "t2", name: "Team 2" },
]);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
test("throws error on unknown error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("fail"));
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow("fail");
});
});

View File

@@ -0,0 +1,75 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { signOut } from "next-auth/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { LandingSidebar } from "./landing-sidebar";
// Module mocks must be declared before importing the component
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
}));
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) => (
<div data-testid={open ? "modal-open" : "modal-closed"} />
),
}));
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: ({ userId }: { userId: string }) => <div data-testid="avatar">{userId}</div>,
}));
// Ensure mocks are reset between tests
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("LandingSidebar component", () => {
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
const organization = { id: "o1", name: "orgOne" } as any;
const organizations = [
{ id: "o2", name: "betaOrg" },
{ id: "o1", name: "alphaOrg" },
] as any;
test("renders logo, avatar, and initial modal closed", () => {
render(
<LandingSidebar
isMultiOrgEnabled={false}
user={user}
organization={organization}
organizations={organizations}
/>
);
// Formbricks logo
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
// Profile avatar
expect(screen.getByTestId("avatar")).toHaveTextContent("u1");
// CreateOrganizationModal should be closed initially
expect(screen.getByTestId("modal-closed")).toBeInTheDocument();
});
test("clicking logout triggers signOut", async () => {
render(
<LandingSidebar
isMultiOrgEnabled={false}
user={user}
organization={organization}
organizations={organizations}
/>
);
// Open user dropdown by clicking on avatar trigger
const trigger = screen.getByTestId("avatar").parentElement;
if (trigger) await userEvent.click(trigger);
// Click logout menu item
const logoutItem = await screen.findByText("common.logout");
await userEvent.click(logoutItem);
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
});
});

View File

@@ -0,0 +1,184 @@
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/preact";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import LandingLayout from "./layout";
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
PRIVACY_URL: "http://localhost:3000/privacy",
TERMS_URL: "http://localhost:3000/terms",
IMPRINT_URL: "http://localhost:3000/imprint",
IMPRINT_ADDRESS: "Mock Address",
PASSWORD_RESET_DISABLED: false,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: false,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
SAML_XML_DIR: "./mock-saml-connection",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
INVITE_DISABLED: false,
SLACK_CLIENT_SECRET: "mock-slack-secret",
SLACK_CLIENT_ID: "mock-slack-id",
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
AIRTABLE_CLIENT_ID: "mock-airtable-id",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "587",
SMTP_SECURE_ENABLED: false,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SMTP_AUTHENTICATED: true,
SMTP_REJECT_UNAUTHORIZED_TLS: true,
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
NEXTAUTH_SECRET: "mock-nextauth-secret",
ITEMS_PER_PAGE: 30,
SURVEYS_PER_PAGE: 12,
RESPONSES_PER_PAGE: 25,
TEXT_RESPONSES_PER_PAGE: 5,
INSIGHTS_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
MAX_OTHER_OPTION_LENGTH: 250,
ENTERPRISE_LICENSE_KEY: "ABC",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
AZURE_ID: "mock-azure-id",
AZUREAD_CLIENT_ID: "mock-azure-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
OIDC_ID: "mock-oidc-id",
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
SAML_ID: "mock-saml-id",
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
OIDC_DISPLAY_NAME: "Mock OIDC",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
}));
vi.mock("@/lib/environment/service");
vi.mock("@/lib/membership/service");
vi.mock("@/lib/project/service");
vi.mock("next-auth");
vi.mock("next/navigation");
afterEach(() => {
cleanup();
});
describe("LandingLayout", () => {
test("redirects to login if no session exists", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
await LandingLayout(props);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/auth/login");
});
test("returns notFound if no membership is found", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
await LandingLayout(props);
expect(vi.mocked(notFound)).toHaveBeenCalled();
});
test("redirects to production environment if available", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
organizationId: "org-123",
userId: "user-123",
accepted: true,
role: "owner",
});
vi.mocked(getUserProjects).mockResolvedValue([
{
id: "proj-123",
organizationId: "org-123",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-02"),
name: "Project 1",
styling: { allowStyleOverwrite: true },
recontactDays: 30,
inAppSurveyBranding: true,
linkSurveyBranding: true,
} as any,
]);
vi.mocked(getEnvironments).mockResolvedValue([
{
id: "env-123",
type: "production",
projectId: "proj-123",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-02"),
appSetupCompleted: true,
},
]);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
await LandingLayout(props);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/");
});
test("renders children if no projects or production environment exist", async () => {
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
organizationId: "org-123",
userId: "user-123",
accepted: true,
role: "owner",
});
vi.mocked(getUserProjects).mockResolvedValue([]);
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
const result = await LandingLayout(props);
expect(result).toEqual(
<>
<div>Child Content</div>
</>
);
});
});

View File

@@ -0,0 +1,197 @@
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { notFound, redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
WEBAPP_URL: "http://localhost:3000",
SURVEY_URL: "http://localhost:3000/survey",
ENCRYPTION_KEY: "mock-encryption-key",
CRON_SECRET: "mock-cron-secret",
DEFAULT_BRAND_COLOR: "#64748b",
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
PRIVACY_URL: "http://localhost:3000/privacy",
TERMS_URL: "http://localhost:3000/terms",
IMPRINT_URL: "http://localhost:3000/imprint",
IMPRINT_ADDRESS: "Mock Address",
PASSWORD_RESET_DISABLED: false,
EMAIL_VERIFICATION_DISABLED: false,
GOOGLE_OAUTH_ENABLED: false,
GITHUB_OAUTH_ENABLED: false,
AZURE_OAUTH_ENABLED: false,
OIDC_OAUTH_ENABLED: false,
SAML_OAUTH_ENABLED: false,
SAML_XML_DIR: "./mock-saml-connection",
SIGNUP_ENABLED: true,
EMAIL_AUTH_ENABLED: true,
INVITE_DISABLED: false,
SLACK_CLIENT_SECRET: "mock-slack-secret",
SLACK_CLIENT_ID: "mock-slack-id",
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
AIRTABLE_CLIENT_ID: "mock-airtable-id",
SMTP_HOST: "mock-smtp-host",
SMTP_PORT: "587",
SMTP_SECURE_ENABLED: false,
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SMTP_AUTHENTICATED: true,
SMTP_REJECT_UNAUTHORIZED_TLS: true,
MAIL_FROM: "mock@mail.com",
MAIL_FROM_NAME: "Mock Mail",
NEXTAUTH_SECRET: "mock-nextauth-secret",
ITEMS_PER_PAGE: 30,
SURVEYS_PER_PAGE: 12,
RESPONSES_PER_PAGE: 25,
TEXT_RESPONSES_PER_PAGE: 5,
INSIGHTS_PER_PAGE: 10,
DOCUMENTS_PER_PAGE: 10,
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
MAX_OTHER_OPTION_LENGTH: 250,
ENTERPRISE_LICENSE_KEY: "ABC",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "mock-github-secret",
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
AZURE_ID: "mock-azure-id",
AZUREAD_CLIENT_ID: "mock-azure-client-id",
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
GOOGLE_CLIENT_ID: "mock-google-client-id",
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
OIDC_ID: "mock-oidc-id",
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
SAML_ID: "mock-saml-id",
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
OIDC_DISPLAY_NAME: "Mock OIDC",
OIDC_CLIENT_ID: "mock-oidc-client-id",
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
}));
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
LandingSidebar: () => <div data-testid="landing-sidebar" />,
}));
vi.mock("@/modules/organization/lib/utils");
vi.mock("@/lib/user/service");
vi.mock("@/lib/organization/service");
vi.mock("@/tolgee/server");
vi.mock("next/navigation", () => ({
redirect: vi.fn(() => "REDIRECT_STUB"),
notFound: vi.fn(() => "NOT_FOUND_STUB"),
}));
// Mock the React cache function
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: (fn: any) => fn,
};
});
describe("Page component", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.resetModules();
});
test("redirects to login if no user session", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } });
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(result).toBe("REDIRECT_STUB");
});
test("returns notFound if user does not exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({
session: { user: { id: "user1" } },
organization: {},
} as any);
vi.mocked(getUser).mockResolvedValue(null);
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } });
expect(notFound).toHaveBeenCalled();
expect(result).toBe("NOT_FOUND_STUB");
});
test("renders header and sidebar for authenticated user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({
session: { user: { id: "user1" } },
organization: { id: "org1" },
} as any);
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
typeof props === "string" ? props : props.key || ""
);
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: true,
features: { isMultiOrgEnabled: true },
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "live",
}),
}));
const { default: Page } = await import("./page");
const element = await Page({ params: { organizationId: "org1" } });
render(element as React.ReactElement);
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";

View File

@@ -0,0 +1,88 @@
import { getUserProjects } from "@/lib/project/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
const mockTranslate = vi.fn((key) => key);
// Module mocks must be declared before importing the component
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn(() => "REDIRECT_STUB") }));
vi.mock("@/modules/ui/components/header", () => ({
Header: ({ title, subtitle }: { title: string; subtitle: string }) => (
<div>
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
),
}));
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
<div data-testid="options">{options.map((o) => o.title).join(",")}</div>
),
}));
vi.mock("next/link", () => ({
default: ({ href, children }: { href: string; children: React.ReactNode }) => <a href={href}>{children}</a>,
}));
describe("Page component", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const params = Promise.resolve({ organizationId: "org1" });
test("redirects to login if no user session", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {} } as any);
const result = await Page({ params });
expect(redirect).toHaveBeenCalledWith("/auth/login");
expect(result).toBe("REDIRECT_STUB");
});
test("renders header, options, and close button when projects exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValue([{ id: 1 }] as any);
const element = await Page({ params });
render(element as React.ReactElement);
// Header title and subtitle
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"organizations.projects.new.channel.channel_select_title"
);
expect(
screen.getByText("organizations.projects.new.channel.channel_select_subtitle")
).toBeInTheDocument();
// Options container with correct titles
expect(screen.getByTestId("options")).toHaveTextContent(
"organizations.projects.new.channel.link_and_email_surveys," +
"organizations.projects.new.channel.in_product_surveys"
);
// Close button link rendered when projects >=1
const closeLink = screen.getByRole("link");
expect(closeLink).toHaveAttribute("href", "/");
});
test("does not render close button when no projects", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValue([]);
const element = await Page({ params });
render(element as React.ReactElement);
expect(screen.queryByRole("link")).toBeNull();
});
});

View File

@@ -0,0 +1,220 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getOrganizationProjectsCount } from "@/lib/project/service";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import OnboardingLayout from "./layout";
// Mock environment variables
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
POSTHOG_API_KEY: "mock-posthog-api-key",
POSTHOG_HOST: "mock-posthog-host",
IS_POSTHOG_CONFIGURED: true,
ENCRYPTION_KEY: "mock-encryption-key",
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
GITHUB_ID: "mock-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
}));
// Mock dependencies
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganization: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
getOrganizationProjectsCount: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
describe("OnboardingLayout", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("redirects to login if no session", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("returns not found if user is member or billing", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "member",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(notFound).toHaveBeenCalled();
});
test("throws error if organization is not found", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getOrganization).mockResolvedValue(null);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found");
});
test("redirects to home if project limit is reached", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
isAIEnabled: false,
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
};
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
await OnboardingLayout(props);
expect(redirect).toHaveBeenCalledWith("/");
});
test("renders children when all conditions are met", async () => {
const mockSession = {
user: { id: "test-user-id" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
isAIEnabled: false,
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
};
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2);
const props = {
params: { organizationId: "test-org-id" },
children: <div>Test Child</div>,
};
const result = await OnboardingLayout(props);
expect(result).toEqual(<>{props.children}</>);
});
});

View File

@@ -0,0 +1,72 @@
import { getUserProjects } from "@/lib/project/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { getTranslate } from "@/tolgee/server";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
const mockTranslate = vi.fn((key) => key);
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
vi.mock("next/link", () => ({
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
}));
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
OnboardingOptionsContainer: ({ options }: any) => (
<div data-testid="options">{options.map((o: any) => o.title).join(",")}</div>
),
}));
vi.mock("@/modules/ui/components/header", () => ({ Header: ({ title }: any) => <h1>{title}</h1> }));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
describe("Mode Page", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const params = Promise.resolve({ organizationId: "org1" });
test("redirects to login if no session user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
await Page({ params });
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("renders header and options without close link when no projects", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
const element = await Page({ params });
render(element as React.ReactElement);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"organizations.projects.new.mode.what_are_you_here_for"
);
expect(screen.getByTestId("options")).toHaveTextContent(
"organizations.projects.new.mode.formbricks_surveys," + "organizations.projects.new.mode.formbricks_cx"
);
expect(screen.queryByRole("link")).toBeNull();
});
test("renders close link when projects exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" } as any]);
const element = await Page({ params });
render(element as React.ReactElement);
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/");
});
});

View File

@@ -0,0 +1,124 @@
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ProjectSettings } from "./ProjectSettings";
// Mocks before imports
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: pushMock }) }));
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
vi.mock("react-hot-toast", () => ({ toast: { error: vi.fn() } }));
vi.mock("@/app/(app)/environments/[environmentId]/actions", () => ({ createProjectAction: vi.fn() }));
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
vi.mock("@/modules/ui/components/color-picker", () => ({
ColorPicker: ({ color, onChange }: any) => (
<button data-testid="color-picker" onClick={() => onChange("#000")}>
{color}
</button>
),
}));
vi.mock("@/modules/ui/components/input", () => ({
Input: ({ value, onChange, placeholder }: any) => (
<input placeholder={placeholder} value={value} onChange={(e) => onChange((e.target as any).value)} />
),
}));
vi.mock("@/modules/ui/components/multi-select", () => ({
MultiSelect: ({ value, options, onChange }: any) => (
<select
data-testid="multi-select"
multiple
value={value}
onChange={(e) => onChange(Array.from((e.target as any).selectedOptions).map((o: any) => o.value))}>
{options.map((o: any) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
),
}));
vi.mock("@/modules/ui/components/survey", () => ({
SurveyInline: () => <div data-testid="survey-inline" />,
}));
vi.mock("@/lib/templates", () => ({ previewSurvey: () => ({}) }));
vi.mock("@/modules/ee/teams/team-list/components/create-team-modal", () => ({
CreateTeamModal: ({ open }: any) => <div data-testid={open ? "team-modal-open" : "team-modal-closed"} />,
}));
// Clean up after each test
afterEach(() => {
cleanup();
vi.clearAllMocks();
localStorage.clear();
});
describe("ProjectSettings component", () => {
const baseProps = {
organizationId: "org1",
projectMode: "cx",
industry: "ind",
defaultBrandColor: "#fff",
organizationTeams: [],
canDoRoleManagement: false,
userProjectsCount: 0,
} as any;
const fillAndSubmit = async () => {
const nameInput = screen.getByPlaceholderText("e.g. Formbricks");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "TestProject");
const nextButton = screen.getByRole("button", { name: "common.next" });
await userEvent.click(nextButton);
};
test("successful createProject for link channel navigates to surveys and clears localStorage", async () => {
(createProjectAction as any).mockResolvedValue({
data: { environments: [{ id: "env123", type: "production" }] },
});
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
await fillAndSubmit();
expect(createProjectAction).toHaveBeenCalledWith({
organizationId: "org1",
data: expect.objectContaining({ teamIds: [] }),
});
expect(pushMock).toHaveBeenCalledWith("/environments/env123/surveys");
expect(localStorage.getItem("FORMBRICKS_SURVEYS_FILTERS_KEY_LS")).toBeNull();
});
test("successful createProject for app channel navigates to connect", async () => {
(createProjectAction as any).mockResolvedValue({
data: { environments: [{ id: "env456", type: "production" }] },
});
render(<ProjectSettings {...baseProps} channel="app" projectMode="cx" />);
await fillAndSubmit();
expect(pushMock).toHaveBeenCalledWith("/environments/env456/connect");
});
test("successful createProject for cx mode navigates to xm-templates when channel is neither link nor app", async () => {
(createProjectAction as any).mockResolvedValue({
data: { environments: [{ id: "env789", type: "production" }] },
});
render(<ProjectSettings {...baseProps} channel="unknown" projectMode="cx" />);
await fillAndSubmit();
expect(pushMock).toHaveBeenCalledWith("/environments/env789/xm-templates");
});
test("shows error toast on createProject error response", async () => {
(createProjectAction as any).mockResolvedValue({ error: "err" });
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
await fillAndSubmit();
expect(toast.error).toHaveBeenCalledWith("formatted-error");
});
test("shows error toast on exception", async () => {
(createProjectAction as any).mockImplementation(() => {
throw new Error("fail");
});
render(<ProjectSettings {...baseProps} channel="link" projectMode="cx" />);
await fillAndSubmit();
expect(toast.error).toHaveBeenCalledWith("organizations.projects.new.settings.project_creation_failed");
});
});

View File

@@ -0,0 +1,106 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getUserProjects } from "@/lib/project/service";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import Page from "./page";
vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
// Mocks before component import
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
vi.mock("next/link", () => ({
__esModule: true,
default: ({ href, children }: any) => <a href={href}>{children}</a>,
}));
vi.mock("@/modules/ui/components/header", () => ({
Header: ({ title, subtitle }: any) => (
<div>
<h1>{title}</h1>
<p>{subtitle}</p>
</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
vi.mock(
"@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings",
() => ({
ProjectSettings: (props: any) => <div data-testid="project-settings" data-mode={props.projectMode} />,
})
);
// Cleanup after each test
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("ProjectSettingsPage", () => {
const params = Promise.resolve({ organizationId: "org1" });
const searchParams = Promise.resolve({ channel: "link", industry: "other", mode: "cx" } as any);
test("redirects to login when no session user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
await Page({ params, searchParams });
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("throws when teams not found", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
session: { user: { id: "u1" } },
organization: { billing: { plan: "basic" } },
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
});
test("renders header, settings and close link when projects exist", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
session: { user: { id: "u1" } },
organization: { billing: { plan: "basic" } },
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);
// Header
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"organizations.projects.new.settings.project_settings_title"
);
// ProjectSettings stub receives mode prop
expect(screen.getByTestId("project-settings")).toHaveAttribute("data-mode", "cx");
// Close link for existing projects
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "/");
});
test("renders without close link when no projects", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
session: { user: { id: "u1" } },
organization: { billing: { plan: "basic" } },
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);
expect(screen.queryByRole("link")).toBeNull();
});
});

View File

@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -0,0 +1,106 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Home, Settings } from "lucide-react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { OnboardingOptionsContainer } from "./OnboardingOptionsContainer";
describe("OnboardingOptionsContainer", () => {
afterEach(() => {
cleanup();
});
test("renders options with links", () => {
const options = [
{
title: "Test Option",
description: "Test Description",
icon: Home,
href: "/test",
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Test Option")).toBeInTheDocument();
expect(screen.getByText("Test Description")).toBeInTheDocument();
});
test("renders options with onClick handler", () => {
const onClickMock = vi.fn();
const options = [
{
title: "Click Option",
description: "Click Description",
icon: Home,
onClick: onClickMock,
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Click Option")).toBeInTheDocument();
expect(screen.getByText("Click Description")).toBeInTheDocument();
});
test("renders options with iconText", () => {
const options = [
{
title: "Icon Text Option",
description: "Icon Text Description",
icon: Home,
iconText: "Custom Icon Text",
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Custom Icon Text")).toBeInTheDocument();
});
test("renders options with loading state", () => {
const options = [
{
title: "Loading Option",
description: "Loading Description",
icon: Home,
isLoading: true,
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("Loading Option")).toBeInTheDocument();
});
test("renders multiple options", () => {
const options = [
{
title: "First Option",
description: "First Description",
icon: Home,
},
{
title: "Second Option",
description: "Second Description",
icon: Settings,
},
];
render(<OnboardingOptionsContainer options={options} />);
expect(screen.getByText("First Option")).toBeInTheDocument();
expect(screen.getByText("Second Option")).toBeInTheDocument();
});
test("calls onClick handler when clicking an option", async () => {
const onClickMock = vi.fn();
const options = [
{
title: "Click Option",
description: "Click Description",
icon: Home,
onClick: onClickMock,
},
];
render(<OnboardingOptionsContainer options={options} />);
await userEvent.click(screen.getByText("Click Option"));
expect(onClickMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,4 +1,3 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -10,7 +9,7 @@ import {
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth";
@@ -49,7 +48,6 @@ vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getEnterpriseLicense: vi.fn(),
getOrganizationProjectsLimit: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
@@ -176,7 +174,6 @@ describe("EnvironmentLayout", () => {
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
mockIsDevelopment = false;
@@ -189,13 +186,19 @@ describe("EnvironmentLayout", () => {
});
test("renders correctly with default props", async () => {
// Ensure the default mockLicense has isPendingDowngrade: false and active: false
vi.mocked(getEnterpriseLicense).mockResolvedValue({
...mockLicense,
isPendingDowngrade: false,
active: false,
});
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
@@ -203,20 +206,31 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
expect(screen.getByText("Child Content")).toBeInTheDocument();
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); // This should now pass
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
});
test("renders DevEnvironmentBanner in development environment", async () => {
const devEnvironment = { ...mockEnvironment, type: "development" as const };
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
mockIsDevelopment = true;
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
@@ -224,13 +238,24 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
});
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
mockIsFormbricksCloud = true;
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
@@ -238,17 +263,21 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
});
test("renders PendingDowngradeBanner when pending downgrade", async () => {
// Ensure the license mock reflects the condition needed for the banner
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
@@ -256,12 +285,24 @@ describe("EnvironmentLayout", () => {
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
});
test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.user_not_found"
);
@@ -269,6 +310,19 @@ describe("EnvironmentLayout", () => {
test("throws error if organization not found", async () => {
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.organization_not_found"
);
@@ -276,13 +330,39 @@ describe("EnvironmentLayout", () => {
test("throws error if environment not found", async () => {
vi.mocked(getEnvironment).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.environment_not_found"
);
});
test("throws error if projects, environments or organizations not found", async () => {
vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing
vi.mocked(getUserProjects).mockResolvedValue(null as any);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"environments.projects_environments_organizations_not_found"
);
@@ -291,6 +371,19 @@ describe("EnvironmentLayout", () => {
test("throws error if member has no project permission", async () => {
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
"common.project_permission_not_found"
);

View File

@@ -12,7 +12,8 @@ import {
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";

View File

@@ -109,7 +109,7 @@ export const MainNavigation = ({
useEffect(() => {
const toggleTextOpacity = () => {
setIsTextVisible(isCollapsed ? true : false);
setIsTextVisible(isCollapsed);
};
const timeoutId = setTimeout(toggleTextOpacity, 150);
return () => clearTimeout(timeoutId);
@@ -170,7 +170,7 @@ export const MainNavigation = ({
name: t("common.actions"),
href: `/environments/${environment.id}/actions`,
icon: MousePointerClick,
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
isActive: pathname?.includes("/actions"),
},
{
name: t("common.integrations"),

View File

@@ -9,7 +9,7 @@ import { ZId } from "@formbricks/types/common";
import { ZUserUpdateInput } from "@formbricks/types/user";
export const updateUserAction = authenticatedActionClient
.schema(ZUserUpdateInput.partial())
.schema(ZUserUpdateInput.pick({ name: true, locale: true }))
.action(async ({ parsedInput, ctx }) => {
return await updateUser(ctx.user.id, parsedInput);
});

View File

@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import EnterpriseSettingsPage from "./page";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -179,15 +178,23 @@ describe("EnterpriseSettingsPage", () => {
});
test("renders correctly for an owner when not on Formbricks Cloud", async () => {
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { default: EnterpriseSettingsPage } = await import("./page");
const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } });
render(Page);
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
expect(redirect).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,6 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
@@ -118,7 +118,7 @@ const Page = async (props) => {
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
<svg
viewBox="0 0 1024 1024"
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
aria-hidden="true">
<circle
cx={512}

View File

@@ -1,6 +1,10 @@
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { ResponseBadges } from "@/modules/ui/components/response-badges";
import { cleanup } from "@testing-library/react";
import { AnyActionArg } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
import {
@@ -257,3 +261,238 @@ describe("generateResponseTableColumns", () => {
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
});
});
describe("ResponseTableColumns", () => {
afterEach(() => {
cleanup();
});
test("includes verifiedEmailColumn when isVerifyEmailEnabled is true", () => {
// Arrange
const mockSurvey = {
questions: [],
variables: [],
hiddenFields: { fieldIds: [] },
isVerifyEmailEnabled: true,
} as unknown as TSurvey;
const mockT = vi.fn((key) => key);
const isExpanded = false;
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
// Assert
const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail");
expect(verifiedEmailColumn).toBeDefined();
expect(verifiedEmailColumn?.accessorKey).toBe("verifiedEmail");
// Call the header function to trigger the t function call with "common.verified_email"
if (verifiedEmailColumn && typeof verifiedEmailColumn.header === "function") {
verifiedEmailColumn.header();
expect(mockT).toHaveBeenCalledWith("common.verified_email");
}
});
test("excludes verifiedEmailColumn when isVerifyEmailEnabled is false", () => {
// Arrange
const mockSurvey = {
questions: [],
variables: [],
hiddenFields: { fieldIds: [] },
isVerifyEmailEnabled: false,
} as unknown as TSurvey;
const mockT = vi.fn((key) => key);
const isExpanded = false;
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
// Assert
const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail");
expect(verifiedEmailColumn).toBeUndefined();
});
});
describe("ResponseTableColumns - Column Implementations", () => {
afterEach(() => {
cleanup();
});
test("dateColumn renders with formatted date", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt");
expect(dateColumn).toBeDefined();
// Call the header function to test it returns the expected value
expect(dateColumn?.header?.()).toBe("common.date");
// Mock a response with a date to test the cell function
const mockRow = {
original: { createdAt: "2023-01-01T12:00:00Z" },
} as any;
// Call the cell function and check the formatted date
dateColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(getFormattedDateTimeString)).toHaveBeenCalledWith(new Date("2023-01-01T12:00:00Z"));
});
test("personColumn renders anonymous when person is null", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
// Test header content
const headerResult = personColumn?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with no person
const mockRow = {
original: { person: null },
} as any;
// Mock the t function for this specific call
t.mockReturnValueOnce("Anonymous User");
// Call the cell function and check it returns "Anonymous"
const cellResult = personColumn?.cell?.({ row: mockRow } as any);
expect(t).toHaveBeenCalledWith("common.anonymous");
expect(cellResult?.props?.children).toBe("Anonymous User");
});
test("personColumn renders person identifier when person exists", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
// Mock a response with a person
const mockRow = {
original: {
person: { id: "123", attributes: { email: "test@example.com" } },
contactAttributes: { name: "John Doe" },
},
} as any;
// Call the cell function
personColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(getContactIdentifier)).toHaveBeenCalledWith(
mockRow.original.person,
mockRow.original.contactAttributes
);
});
test("tagsColumn returns undefined when tags is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags");
expect(tagsColumn).toBeDefined();
// Mock a response with no tags
const mockRow = {
original: { tags: null },
} as any;
// Call the cell function
const cellResult = tagsColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeUndefined();
});
test("notesColumn renders when notes is an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with notes
const mockRow = {
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
} as any;
// Call the cell function
notesColumn?.cell?.({ row: mockRow } as any);
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
});
test("notesColumn returns undefined when notes is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
expect(notesColumn).toBeDefined();
// Mock a response with no notes
const mockRow = {
original: { notes: null },
} as any;
// Call the cell function
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
expect(cellResult).toBeUndefined();
});
test("variableColumns render variable values correctly", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find the variable column for var1
const var1Column: any = columns.find((col) => (col as any).accessorKey === "var1");
expect(var1Column).toBeDefined();
// Test the header
const headerResult = var1Column?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with a string variable
const mockRow = {
original: { variables: { var1: "Test Value" } },
} as any;
// Call the cell function
const cellResult = var1Column?.cell?.({ row: mockRow } as any);
expect(cellResult?.props.children).toBe("Test Value");
// Test with a number variable
const var2Column: any = columns.find((col) => (col as any).accessorKey === "var2");
expect(var2Column).toBeDefined();
const mockRowNumber = {
original: { variables: { var2: 42 } },
} as any;
const cellResultNumber = var2Column?.cell?.({ row: mockRowNumber } as any);
expect(cellResultNumber?.props.children).toBe(42);
});
test("hiddenFieldColumns render when fieldIds exist", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
// Find the hidden field column
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hfColumn).toBeDefined();
// Test the header
const headerResult = hfColumn?.header?.();
expect(headerResult).toBeDefined();
// Mock a response with a hidden field value
const mockRow = {
original: { responseData: { hf1: "Hidden Value" } },
} as any;
// Call the cell function
const cellResult = hfColumn?.cell?.({ row: mockRow } as any);
expect(cellResult?.props.children).toBe("Hidden Value");
});
test("hiddenFieldColumns are empty when fieldIds don't exist", () => {
// Create a survey with no hidden field IDs
const surveyWithNoHiddenFields = {
...mockSurvey,
hiddenFields: { enabled: true }, // no fieldIds
};
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any);
// Check that no hidden field columns were created
const hfColumn = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hfColumn).toBeUndefined();
});
});

View File

@@ -1,7 +1,14 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
OptionsType,
QuestionOption,
QuestionOptions,
QuestionsComboBox,
SelectedCommandItem,
} from "./QuestionsComboBox";
describe("QuestionsComboBox", () => {
afterEach(() => {
@@ -53,3 +60,67 @@ describe("QuestionsComboBox", () => {
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
});
});
describe("SelectedCommandItem", () => {
test("renders question icon and color for QUESTIONS with questionType", () => {
const { container } = render(
<SelectedCommandItem
label="Q1"
type={OptionsType.QUESTIONS}
questionType={TSurveyQuestionTypeEnum.OpenText}
/>
);
expect(container.querySelector(".bg-brand-dark")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Q1");
});
test("renders attribute icon and color for ATTRIBUTES", () => {
const { container } = render(<SelectedCommandItem label="Attr" type={OptionsType.ATTRIBUTES} />);
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Attr");
});
test("renders hidden field icon and color for HIDDEN_FIELDS", () => {
const { container } = render(<SelectedCommandItem label="Hidden" type={OptionsType.HIDDEN_FIELDS} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Hidden");
});
test("renders meta icon and color for META with label", () => {
const { container } = render(<SelectedCommandItem label="device" type={OptionsType.META} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("device");
});
test("renders other icon and color for OTHERS with label", () => {
const { container } = render(<SelectedCommandItem label="Language" type={OptionsType.OTHERS} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Language");
});
test("renders tag icon and color for TAGS", () => {
const { container } = render(<SelectedCommandItem label="Tag1" type={OptionsType.TAGS} />);
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
expect(container.querySelector("svg")).toBeInTheDocument();
expect(container.textContent).toContain("Tag1");
});
test("renders fallback color and no icon for unknown type", () => {
const { container } = render(<SelectedCommandItem label="Unknown" type={"UNKNOWN"} />);
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
expect(container.querySelector("svg")).not.toBeInTheDocument();
expect(container.textContent).toContain("Unknown");
});
test("renders fallback for non-string label", () => {
const { container } = render(
<SelectedCommandItem label={{ default: "NonString" }} type={OptionsType.QUESTIONS} />
);
expect(container.textContent).toContain("NonString");
});
});

View File

@@ -18,11 +18,12 @@ import {
CheckIcon,
ChevronDown,
ChevronUp,
ContactIcon,
EyeOff,
GlobeIcon,
GridIcon,
HashIcon,
HelpCircleIcon,
HomeIcon,
ImageIcon,
LanguagesIcon,
ListIcon,
@@ -63,59 +64,60 @@ interface QuestionComboBoxProps {
onChangeValue: (option: QuestionOption) => void;
}
const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
const getIconType = () => {
switch (type) {
case OptionsType.QUESTIONS:
switch (questionType) {
case TSurveyQuestionTypeEnum.OpenText:
return <MessageSquareTextIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Rating:
return <StarIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.CTA:
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.OpenText:
return <HelpCircleIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
return <ListIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
return <Rows3Icon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.NPS:
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Consent:
return <CheckIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.PictureSelection:
return <ImageIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Matrix:
return <GridIcon width={18} height={18} className="text-white" />;
case TSurveyQuestionTypeEnum.Ranking:
return <ListOrderedIcon width={18} height={18} className="text-white" />;
}
case OptionsType.ATTRIBUTES:
return <User width={18} height={18} className="text-white" />;
const questionIcons = {
// questions
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
case OptionsType.HIDDEN_FIELDS:
return <EyeOff width={18} height={18} className="text-white" />;
case OptionsType.META:
switch (label) {
case "device":
return <SmartphoneIcon width={18} height={18} className="text-white" />;
case "os":
return <AirplayIcon width={18} height={18} className="text-white" />;
case "browser":
return <GlobeIcon width={18} height={18} className="text-white" />;
case "source":
return <GlobeIcon width={18} height={18} className="text-white" />;
case "action":
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
}
case OptionsType.OTHERS:
switch (label) {
case "Language":
return <LanguagesIcon width={18} height={18} className="text-white" />;
}
case OptionsType.TAGS:
return <HashIcon width={18} height={18} className="text-white" />;
// attributes
[OptionsType.ATTRIBUTES]: User,
// hidden fields
[OptionsType.HIDDEN_FIELDS]: EyeOff,
// meta
device: SmartphoneIcon,
os: AirplayIcon,
browser: GlobeIcon,
source: GlobeIcon,
action: MousePointerClickIcon,
// others
Language: LanguagesIcon,
// tags
[OptionsType.TAGS]: HashIcon,
};
const getIcon = (type: string) => {
const IconComponent = questionIcons[type];
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
};
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
const getIconType = () => {
if (type) {
if (type === OptionsType.QUESTIONS && questionType) {
return getIcon(questionType);
} else if (type === OptionsType.ATTRIBUTES) {
return getIcon(OptionsType.ATTRIBUTES);
} else if (type === OptionsType.HIDDEN_FIELDS) {
return getIcon(OptionsType.HIDDEN_FIELDS);
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
return getIcon(label);
} else if (type === OptionsType.TAGS) {
return getIcon(OptionsType.TAGS);
}
}
};
@@ -164,7 +166,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
value={inputValue}
onValueChange={setInputValue}
placeholder={t("common.search") + "..."}
className="h-5 border-none border-transparent p-0 shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0"
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/>
)}
<div>

View File

@@ -0,0 +1,263 @@
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useParams } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { ResponseFilter } from "./ResponseFilter";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
getSurveyFilterDataAction: vi.fn(),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getSurveyFilterDataBySurveySharingKeyAction: vi.fn(),
}));
vi.mock("@/app/lib/surveys/surveys", () => ({
generateQuestionAndFilterOptions: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useParams: vi.fn(),
}));
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [[vi.fn()]],
}));
vi.mock("./QuestionsComboBox", () => ({
QuestionsComboBox: ({ onChangeValue }) => (
<div data-testid="questions-combo-box">
<button onClick={() => onChangeValue({ id: "q1", label: "Question 1", type: "OpenText" })}>
Select Question
</button>
</div>
),
OptionsType: {
QUESTIONS: "Questions",
ATTRIBUTES: "Attributes",
TAGS: "Tags",
LANGUAGES: "Languages",
},
}));
// Update the mock for QuestionFilterComboBox to always render
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox",
() => ({
QuestionFilterComboBox: () => (
<div data-testid="filter-combo-box">
<button data-testid="select-filter-btn">Select Filter</button>
<button data-testid="select-filter-type-btn">Select Filter Type</button>
</div>
),
})
);
describe("ResponseFilter", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockSelectedFilter = {
filter: [],
onlyComplete: false,
};
const mockSelectedOptions = {
questionFilterOptions: [
{
type: TSurveyQuestionTypeEnum.OpenText,
filterOptions: ["equals", "does not equal"],
filterComboBoxOptions: [],
id: "q1",
},
],
questionOptions: [
{
label: "Questions",
type: "Questions",
option: [
{ id: "q1", label: "Question 1", type: "OpenText", questionType: TSurveyQuestionTypeEnum.OpenText },
],
},
],
} as any;
const mockSetSelectedFilter = vi.fn();
const mockSetSelectedOptions = vi.fn();
const mockSurvey = {
id: "survey1",
environmentId: "env1",
name: "Test Survey",
createdAt: new Date(),
updatedAt: new Date(),
status: "draft",
createdBy: "user1",
questions: [],
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
triggers: [],
displayOption: "displayOnce",
} as unknown as TSurvey;
beforeEach(() => {
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: mockSelectedFilter,
setSelectedFilter: mockSetSelectedFilter,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
vi.mocked(useParams).mockReturnValue({ environmentId: "env1", surveyId: "survey1" });
vi.mocked(getSurveyFilterDataAction).mockResolvedValue({
data: {
attributes: [],
meta: {},
environmentTags: [],
hiddenFields: [],
} as any,
});
vi.mocked(generateQuestionAndFilterOptions).mockReturnValue({
questionFilterOptions: mockSelectedOptions.questionFilterOptions,
questionOptions: mockSelectedOptions.questionOptions,
});
});
test("renders with default state", () => {
render(<ResponseFilter survey={mockSurvey} />);
expect(screen.getByText("Filter")).toBeInTheDocument();
});
test("opens the filter popover when clicked", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(
screen.getByText("environments.surveys.summary.show_all_responses_that_match")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.only_completed")).toBeInTheDocument();
});
test("fetches filter data when opened", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(getSurveyFilterDataAction).toHaveBeenCalledWith({ surveyId: "survey1" });
expect(mockSetSelectedOptions).toHaveBeenCalled();
});
test("handles adding new filter", async () => {
// Start with an empty filter
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: { filter: [], onlyComplete: false },
setSelectedFilter: mockSetSelectedFilter,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
// Verify there's no filter yet
expect(screen.queryByTestId("questions-combo-box")).not.toBeInTheDocument();
// Add a new filter and check that the questions combo box appears
await userEvent.click(screen.getByText("common.add_filter"));
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
});
test("handles only complete checkbox toggle", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
await userEvent.click(screen.getByRole("checkbox"));
await userEvent.click(screen.getByText("common.apply_filters"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: true });
});
test("handles selecting question and filter options", async () => {
// Setup with a pre-populated filter to ensure the filter components are rendered
const setSelectedFilterMock = vi.fn();
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [
{
questionType: { id: "q1", label: "Question 1", type: "OpenText" },
filterType: { filterComboBoxValue: undefined, filterValue: undefined },
},
],
onlyComplete: false,
},
setSelectedFilter: setSelectedFilterMock,
selectedOptions: mockSelectedOptions,
setSelectedOptions: mockSetSelectedOptions,
} as any);
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
// Verify both combo boxes are rendered
expect(screen.getByTestId("questions-combo-box")).toBeInTheDocument();
expect(screen.getByTestId("filter-combo-box")).toBeInTheDocument();
// Use data-testid to find our buttons instead of text
await userEvent.click(screen.getByText("Select Question"));
await userEvent.click(screen.getByTestId("select-filter-btn"));
await userEvent.click(screen.getByText("common.apply_filters"));
expect(setSelectedFilterMock).toHaveBeenCalled();
});
test("handles clear all filters", async () => {
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
await userEvent.click(screen.getByText("common.clear_all"));
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
});
test("uses sharing key action when on sharing page", async () => {
vi.mocked(useParams).mockReturnValue({
environmentId: "env1",
surveyId: "survey1",
sharingKey: "share123",
});
vi.mocked(getSurveyFilterDataBySurveySharingKeyAction).mockResolvedValue({
data: {
attributes: [],
meta: {},
environmentTags: [],
hiddenFields: [],
} as any,
});
render(<ResponseFilter survey={mockSurvey} />);
await userEvent.click(screen.getByText("Filter"));
expect(getSurveyFilterDataBySurveySharingKeyAction).toHaveBeenCalledWith({
sharingKey: "share123",
environmentId: "env1",
});
});
});

View File

@@ -0,0 +1,35 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import ForgotPasswordPage from "./page";
vi.mock("@/modules/auth/forgot-password/page", () => ({
ForgotPasswordPage: () => (
<div data-testid="forgot-password-page">
<div data-testid="form-wrapper">
<div data-testid="forgot-password-form">Forgot Password Form</div>
</div>
</div>
),
}));
describe("ForgotPasswordPage", () => {
afterEach(() => {
cleanup();
});
test("renders the forgot password page", () => {
render(<ForgotPasswordPage />);
expect(screen.getByTestId("forgot-password-page")).toBeInTheDocument();
});
test("renders the form wrapper", () => {
render(<ForgotPasswordPage />);
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
});
test("renders the forgot password form", () => {
render(<ForgotPasswordPage />);
expect(screen.getByTestId("forgot-password-form")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,74 @@
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import "@testing-library/jest-dom/vitest";
import { cleanup, render } from "@testing-library/react";
import { useRouter } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import ClientEnvironmentRedirect from "./ClientEnvironmentRedirect";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("ClientEnvironmentRedirect", () => {
afterEach(() => {
cleanup();
});
test("should redirect to the provided environment ID when no last environment exists", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage
const localStorageMock = {
getItem: vi.fn().mockReturnValue(null),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id");
});
test("should redirect to the last environment ID when it exists in localStorage", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage with a last environment ID
const localStorageMock = {
getItem: vi.fn().mockReturnValue("last-env-id"),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id");
});
test("should update redirect when environment ID prop changes", () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
// Mock localStorage
const localStorageMock = {
getItem: vi.fn().mockReturnValue(null),
};
Object.defineProperty(window, "localStorage", {
value: localStorageMock,
});
const { rerender } = render(<ClientEnvironmentRedirect environmentId="initial-env-id" />);
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
// Clear mock calls
mockPush.mockClear();
// Rerender with new environment ID
rerender(<ClientEnvironmentRedirect environmentId="new-env-id" />);
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
});
});

View File

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

View File

@@ -1,135 +0,0 @@
import { sendFollowUpEmail } from "@/modules/email";
import { z } from "zod";
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
type FollowUpResult = {
followUpId: string;
status: "success" | "error" | "skipped";
error?: string;
};
export const evaluateFollowUp = async (
followUpId: string,
followUpAction: TSurveyFollowUpAction,
survey: TSurvey,
response: TResponse,
organization: TOrganization
): Promise<void> => {
const { properties } = followUpAction;
const { to, subject, body, replyTo } = properties;
const toValueFromResponse = response.data[to];
const logoUrl = organization.whitelabel?.logoUrl || "";
// Check if 'to' is a direct email address (team member or user email)
const parsedEmailTo = z.string().email().safeParse(to);
if (parsedEmailTo.success) {
// 'to' is a valid email address, send email directly
await sendFollowUpEmail({
html: body,
subject,
to: parsedEmailTo.data,
replyTo,
survey,
response,
attachResponseData: properties.attachResponseData,
logoUrl,
});
return;
}
// If not a direct email, check if it's a question ID or hidden field ID
if (!toValueFromResponse) {
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
}
if (typeof toValueFromResponse === "string") {
// parse this string to check for an email:
const parsedResult = z.string().email().safeParse(toValueFromResponse);
if (parsedResult.data) {
// send email to this email address
await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
} else if (Array.isArray(toValueFromResponse)) {
const emailAddress = toValueFromResponse[2];
if (!emailAddress) {
throw new Error(`Email address not found in response data for followup: ${followUpId}`);
}
const parsedResult = z.string().email().safeParse(emailAddress);
if (parsedResult.data) {
await sendFollowUpEmail({
html: body,
subject,
to: parsedResult.data,
replyTo,
logoUrl,
survey,
response,
attachResponseData: properties.attachResponseData,
});
} else {
throw new Error(`Email address is not valid for followup: ${followUpId}`);
}
}
};
export const sendSurveyFollowUps = async (
survey: TSurvey,
response: TResponse,
organization: TOrganization
): Promise<FollowUpResult[]> => {
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
const { trigger } = followUp;
// Check if we should skip this follow-up based on ending IDs
if (trigger.properties) {
const { endingIds } = trigger.properties;
const { endingId } = response;
if (!endingId || !endingIds.includes(endingId)) {
return Promise.resolve({
followUpId: followUp.id,
status: "skipped",
});
}
}
return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
.then(() => ({
followUpId: followUp.id,
status: "success" as const,
}))
.catch((error) => ({
followUpId: followUp.id,
status: "error" as const,
error: error instanceof Error ? error.message : "Something went wrong",
}));
});
const followUpResults = await Promise.all(followUpPromises);
// Log all errors
const errors = followUpResults
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
if (errors.length > 0) {
logger.error(errors, "Follow-up processing errors");
}
return followUpResults;
};

View File

@@ -1,4 +1,3 @@
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
@@ -11,7 +10,8 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { sendResponseFinishedEmail } from "@/modules/email";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import { prisma } from "@formbricks/database";
@@ -164,11 +164,15 @@ export const POST = async (request: Request) => {
select: { email: true, locale: true },
});
// send follow up emails
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan);
if (surveyFollowUpsPermission) {
await sendSurveyFollowUps(survey, response, organization);
if (survey.followUps?.length > 0) {
// send follow up emails
const followUpsResult = await sendFollowUpsForResponse(response.id);
if (!followUpsResult.ok) {
const { error: followUpsError } = followUpsResult;
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
}
}
}
const emailPromises = usersWithNotifications.map((user) =>

View File

@@ -102,6 +102,8 @@ describe("getSurveysForEnvironmentState", () => {
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId },
select: expect.any(Object), // Check if select is called, specific fields are in the original code
orderBy: { createdAt: "desc" },
take: 30,
});
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
expect(result).toEqual([mockTransformedSurvey]);
@@ -116,6 +118,8 @@ describe("getSurveysForEnvironmentState", () => {
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: { environmentId },
select: expect.any(Object),
orderBy: { createdAt: "desc" },
take: 30,
});
expect(transformPrismaSurvey).not.toHaveBeenCalled();
expect(result).toEqual([]);

View File

@@ -21,6 +21,10 @@ export const getSurveysForEnvironmentState = reactCache(
where: {
environmentId,
},
orderBy: {
createdAt: "desc",
},
take: 30,
select: {
id: true,
welcomeCard: true,

View File

@@ -33,6 +33,7 @@ export const responseSelection = {
singleUseId: true,
language: true,
displayId: true,
endingId: true,
contact: {
select: {
id: true,

View File

@@ -2,7 +2,7 @@ import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
import { responses } from "@/app/lib/api/response";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { getSurvey } from "@/lib/survey/service";
import { generateSurveySingleUseIds } from "@/lib/utils/singleUseSurveys";
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";

View File

@@ -1,15 +1,16 @@
import { webhookCache } from "@/lib/cache/webhook";
import { Webhook } from "@prisma/client";
import { Prisma, Webhook } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { ValidationError } from "@formbricks/types/errors";
import { deleteWebhook } from "./webhook";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { deleteWebhook, getWebhook } from "./webhook";
vi.mock("@formbricks/database", () => ({
prisma: {
webhook: {
delete: vi.fn(),
findUnique: vi.fn(),
},
},
}));
@@ -33,9 +34,15 @@ vi.mock("@/lib/utils/validate", () => ({
},
}));
vi.mock("@/lib/cache", () => ({
// Accept any function and return the exact same generic Fn keeps typings intact
cache: <T extends (...args: any[]) => any>(fn: T): T => fn,
}));
describe("deleteWebhook", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("should delete the webhook and return the deleted webhook object when provided with a valid webhook ID", async () => {
@@ -105,4 +112,133 @@ describe("deleteWebhook", () => {
expect(prisma.webhook.delete).not.toHaveBeenCalled();
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
test("should throw ResourceNotFoundError when webhook does not exist", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: "P2025",
clientVersion: "1.0.0",
});
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaError);
await expect(deleteWebhook("non-existent-id")).rejects.toThrow(ResourceNotFoundError);
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
test("should throw DatabaseError when database operation fails", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(prismaError);
await expect(deleteWebhook("test-webhook-id")).rejects.toThrow(DatabaseError);
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
test("should throw DatabaseError when an unknown error occurs", async () => {
vi.mocked(prisma.webhook.delete).mockRejectedValueOnce(new Error("Unknown error"));
await expect(deleteWebhook("test-webhook-id")).rejects.toThrow(DatabaseError);
expect(webhookCache.revalidate).not.toHaveBeenCalled();
});
});
describe("getWebhook", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("should return webhook when it exists", async () => {
const mockedWebhook: Webhook = {
id: "test-webhook-id",
url: "https://example.com",
name: "Test Webhook",
createdAt: new Date(),
updatedAt: new Date(),
source: "user",
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
};
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(mockedWebhook);
const webhook = await getWebhook("test-webhook-id");
expect(webhook).toEqual(mockedWebhook);
expect(prisma.webhook.findUnique).toHaveBeenCalledWith({
where: {
id: "test-webhook-id",
},
});
});
test("should return null when webhook does not exist", async () => {
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(null);
const webhook = await getWebhook("non-existent-id");
expect(webhook).toBeNull();
expect(prisma.webhook.findUnique).toHaveBeenCalledWith({
where: {
id: "non-existent-id",
},
});
});
test("should throw ValidationError when called with invalid webhook ID", async () => {
const { validateInputs } = await import("@/lib/utils/validate");
(validateInputs as any).mockImplementation(() => {
throw new ValidationError("Validation failed");
});
await expect(getWebhook("invalid-id")).rejects.toThrow(ValidationError);
expect(prisma.webhook.findUnique).not.toHaveBeenCalled();
});
test("should throw DatabaseError when database operation fails", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(prismaError);
await expect(getWebhook("test-webhook-id")).rejects.toThrow(DatabaseError);
});
test("should throw original error when an unknown error occurs", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.webhook.findUnique).mockRejectedValueOnce(unknownError);
await expect(getWebhook("test-webhook-id")).rejects.toThrow(unknownError);
});
test("should use cache when getting webhook", async () => {
const mockedWebhook: Webhook = {
id: "test-webhook-id",
url: "https://example.com",
name: "Test Webhook",
createdAt: new Date(),
updatedAt: new Date(),
source: "user",
environmentId: "test-environment-id",
triggers: [],
surveyIds: [],
};
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce(mockedWebhook);
const webhook = await getWebhook("test-webhook-id");
expect(webhook).toEqual(mockedWebhook);
expect(prisma.webhook.findUnique).toHaveBeenCalledWith({
where: {
id: "test-webhook-id",
},
});
});
});

View File

@@ -31,6 +31,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
contactId,
surveyId,
displayId,
endingId,
finished,
data,
meta,
@@ -64,7 +65,8 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
},
},
display: displayId ? { connect: { id: displayId } } : undefined,
finished: finished,
finished,
endingId,
data: data,
language: language,
...(contact?.id && {

View File

@@ -1,10 +1,11 @@
import { getLocale } from "@/tolgee/language";
import { getTolgee } from "@/tolgee/server";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup } from "@testing-library/react";
import { TolgeeInstance } from "@tolgee/react";
import React from "react";
import { renderToString } from "react-dom/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import RootLayout from "./layout";
import RootLayout, { metadata } from "./layout";
// Mock dependencies for the layout
@@ -40,15 +41,6 @@ vi.mock("@/tolgee/server", () => ({
getTolgee: vi.fn(),
}));
vi.mock("@/modules/ui/components/post-hog-client", () => ({
PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => (
<div data-testid="ph-provider">
PHProvider: {posthogEnabled}
{children}
</div>
),
}));
vi.mock("@/tolgee/client", () => ({
TolgeeNextProvider: ({
children,
@@ -95,10 +87,53 @@ describe("RootLayout", () => {
const children = <div data-testid="child">Child Content</div>;
const element = await RootLayout({ children });
render(element);
const html = renderToString(element);
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
// Create a container and set its innerHTML
const container = document.createElement("div");
container.innerHTML = html;
document.body.appendChild(container);
// Now we can use screen queries on the rendered content
expect(container.querySelector('[data-testid="tolgee-next-provider"]')).toBeInTheDocument();
expect(container.querySelector('[data-testid="sentry-provider"]')).toBeInTheDocument();
expect(container.querySelector('[data-testid="child"]')).toHaveTextContent("Child Content");
// Cleanup
document.body.removeChild(container);
});
test("renders with different locale", async () => {
const fakeLocale = "de-DE";
vi.mocked(getLocale).mockResolvedValue(fakeLocale);
const fakeStaticData = { key: "value" };
const fakeTolgee = {
loadRequired: vi.fn().mockResolvedValue(fakeStaticData),
};
vi.mocked(getTolgee).mockResolvedValue(fakeTolgee as unknown as TolgeeInstance);
const children = <div data-testid="child">Child Content</div>;
const element = await RootLayout({ children });
const html = renderToString(element);
const container = document.createElement("div");
container.innerHTML = html;
document.body.appendChild(container);
const tolgeeProvider = container.querySelector('[data-testid="tolgee-next-provider"]');
expect(tolgeeProvider).toHaveTextContent(fakeLocale);
document.body.removeChild(container);
});
test("exports correct metadata", () => {
expect(metadata).toEqual({
title: {
template: "%s | Formbricks",
default: "Formbricks",
},
description: "Open-Source Survey Suite",
});
});
});

View File

@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { isValidCssSelector } from "./actionClass";
describe("isValidCssSelector", () => {
beforeEach(() => {
// Mock document.createElement and querySelector
const mockElement = {
querySelector: vi.fn(),
};
global.document = {
createElement: vi.fn(() => mockElement),
} as any;
});
test("should return false for undefined selector", () => {
expect(isValidCssSelector(undefined)).toBe(false);
});
test("should return false for empty string", () => {
expect(isValidCssSelector("")).toBe(false);
});
test("should return true for valid CSS selector", () => {
const mockElement = {
querySelector: vi.fn(),
};
(document.createElement as any).mockReturnValue(mockElement);
expect(isValidCssSelector(".class")).toBe(true);
expect(isValidCssSelector("#id")).toBe(true);
expect(isValidCssSelector("div")).toBe(true);
});
test("should return false for invalid CSS selector", () => {
const mockElement = {
querySelector: vi.fn(() => {
throw new Error("Invalid selector");
}),
};
(document.createElement as any).mockReturnValue(mockElement);
expect(isValidCssSelector("..invalid")).toBe(false);
expect(isValidCssSelector("##invalid")).toBe(false);
});
});

View File

@@ -0,0 +1,366 @@
import { NextApiResponse } from "next";
import { describe, expect, test } from "vitest";
import { responses } from "./response";
describe("API Response Utilities", () => {
describe("successResponse", () => {
test("should return a success response with data", () => {
const testData = { message: "test" };
const response = responses.successResponse(testData);
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
return response.json().then((body) => {
expect(body).toEqual({ data: testData });
});
});
test("should include CORS headers when cors is true", () => {
const testData = { message: "test" };
const response = responses.successResponse(testData, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const testData = { message: "test" };
const customCache = "public, max-age=3600";
const response = responses.successResponse(testData, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("badRequestResponse", () => {
test("should return a bad request response", () => {
const message = "Invalid input";
const details = { field: "email" };
const response = responses.badRequestResponse(message, details);
expect(response.status).toBe(400);
return response.json().then((body) => {
expect(body).toEqual({
code: "bad_request",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Invalid input";
const response = responses.badRequestResponse(message);
expect(response.status).toBe(400);
return response.json().then((body) => {
expect(body).toEqual({
code: "bad_request",
message,
details: {},
});
});
});
test("should use custom cache control header when provided", () => {
const message = "Invalid input";
const customCache = "no-cache";
const response = responses.badRequestResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("notFoundResponse", () => {
test("should return a not found response", () => {
const resourceType = "User";
const resourceId = "123";
const response = responses.notFoundResponse(resourceType, resourceId);
expect(response.status).toBe(404);
return response.json().then((body) => {
expect(body).toEqual({
code: "not_found",
message: `${resourceType} not found`,
details: {
resource_id: resourceId,
resource_type: resourceType,
},
});
});
});
test("should handle null resourceId", () => {
const resourceType = "User";
const response = responses.notFoundResponse(resourceType, null);
expect(response.status).toBe(404);
return response.json().then((body) => {
expect(body).toEqual({
code: "not_found",
message: `${resourceType} not found`,
details: {
resource_id: null,
resource_type: resourceType,
},
});
});
});
test("should use custom cache control header when provided", () => {
const resourceType = "User";
const resourceId = "123";
const customCache = "no-cache";
const response = responses.notFoundResponse(resourceType, resourceId, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("internalServerErrorResponse", () => {
test("should return an internal server error response", () => {
const message = "Something went wrong";
const response = responses.internalServerErrorResponse(message);
expect(response.status).toBe(500);
return response.json().then((body) => {
expect(body).toEqual({
code: "internal_server_error",
message,
details: {},
});
});
});
test("should include CORS headers when cors is true", () => {
const message = "Something went wrong";
const response = responses.internalServerErrorResponse(message, true);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
});
test("should use custom cache control header when provided", () => {
const message = "Something went wrong";
const customCache = "no-cache";
const response = responses.internalServerErrorResponse(message, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("goneResponse", () => {
test("should return a gone response", () => {
const message = "Resource no longer available";
const details = { reason: "deleted" };
const response = responses.goneResponse(message, details);
expect(response.status).toBe(410);
return response.json().then((body) => {
expect(body).toEqual({
code: "gone",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Resource no longer available";
const response = responses.goneResponse(message);
expect(response.status).toBe(410);
return response.json().then((body) => {
expect(body).toEqual({
code: "gone",
message,
details: {},
});
});
});
test("should use custom cache control header when provided", () => {
const message = "Resource no longer available";
const customCache = "no-cache";
const response = responses.goneResponse(message, undefined, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("methodNotAllowedResponse", () => {
test("should return a method not allowed response", () => {
const mockRes = {
req: { method: "PUT" },
} as NextApiResponse;
const allowedMethods = ["GET", "POST"];
const response = responses.methodNotAllowedResponse(mockRes, allowedMethods);
expect(response.status).toBe(405);
return response.json().then((body) => {
expect(body).toEqual({
code: "method_not_allowed",
message: "The HTTP PUT method is not supported by this route.",
details: {
allowed_methods: allowedMethods,
},
});
});
});
test("should handle missing request method", () => {
const mockRes = {} as NextApiResponse;
const allowedMethods = ["GET", "POST"];
const response = responses.methodNotAllowedResponse(mockRes, allowedMethods);
expect(response.status).toBe(405);
return response.json().then((body) => {
expect(body).toEqual({
code: "method_not_allowed",
message: "The HTTP undefined method is not supported by this route.",
details: {
allowed_methods: allowedMethods,
},
});
});
});
test("should use custom cache control header when provided", () => {
const mockRes = {
req: { method: "PUT" },
} as NextApiResponse;
const allowedMethods = ["GET", "POST"];
const customCache = "no-cache";
const response = responses.methodNotAllowedResponse(mockRes, allowedMethods, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("notAuthenticatedResponse", () => {
test("should return a not authenticated response", () => {
const response = responses.notAuthenticatedResponse();
expect(response.status).toBe(401);
return response.json().then((body) => {
expect(body).toEqual({
code: "not_authenticated",
message: "Not authenticated",
details: {
"x-Api-Key": "Header not provided or API Key invalid",
},
});
});
});
test("should use custom cache control header when provided", () => {
const customCache = "no-cache";
const response = responses.notAuthenticatedResponse(false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("unauthorizedResponse", () => {
test("should return an unauthorized response", () => {
const response = responses.unauthorizedResponse();
expect(response.status).toBe(401);
return response.json().then((body) => {
expect(body).toEqual({
code: "unauthorized",
message: "You are not authorized to access this resource",
details: {},
});
});
});
test("should use custom cache control header when provided", () => {
const customCache = "no-cache";
const response = responses.unauthorizedResponse(false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("forbiddenResponse", () => {
test("should return a forbidden response", () => {
const message = "Access denied";
const details = { reason: "insufficient_permissions" };
const response = responses.forbiddenResponse(message, false, details);
expect(response.status).toBe(403);
return response.json().then((body) => {
expect(body).toEqual({
code: "forbidden",
message,
details,
});
});
});
test("should handle undefined details", () => {
const message = "Access denied";
const response = responses.forbiddenResponse(message);
expect(response.status).toBe(403);
return response.json().then((body) => {
expect(body).toEqual({
code: "forbidden",
message,
details: {},
});
});
});
test("should use custom cache control header when provided", () => {
const message = "Access denied";
const customCache = "no-cache";
const response = responses.forbiddenResponse(message, false, undefined, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
describe("tooManyRequestsResponse", () => {
test("should return a too many requests response", () => {
const message = "Rate limit exceeded";
const response = responses.tooManyRequestsResponse(message);
expect(response.status).toBe(429);
return response.json().then((body) => {
expect(body).toEqual({
code: "too_many_requests",
message,
details: {},
});
});
});
test("should use custom cache control header when provided", () => {
const message = "Rate limit exceeded";
const customCache = "no-cache";
const response = responses.tooManyRequestsResponse(message, false, customCache);
expect(response.headers.get("Cache-Control")).toBe(customCache);
});
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, test } from "vitest";
import { ZodError, ZodIssueCode } from "zod";
import { transformErrorToDetails } from "./validator";
describe("transformErrorToDetails", () => {
test("should transform ZodError with a single issue to details object", () => {
const error = new ZodError([
{
code: ZodIssueCode.invalid_type,
expected: "string",
received: "number",
path: ["name"],
message: "Expected string, received number",
},
]);
const details = transformErrorToDetails(error);
expect(details).toEqual({
name: "Expected string, received number",
});
});
test("should transform ZodError with multiple issues to details object", () => {
const error = new ZodError([
{
code: ZodIssueCode.invalid_type,
expected: "string",
received: "number",
path: ["name"],
message: "Expected string, received number",
},
{
code: ZodIssueCode.too_small,
minimum: 5,
type: "string",
inclusive: true,
exact: false,
message: "String must contain at least 5 character(s)",
path: ["address", "street"],
},
]);
const details = transformErrorToDetails(error);
expect(details).toEqual({
name: "Expected string, received number",
"address.street": "String must contain at least 5 character(s)",
});
});
test("should return an empty object if ZodError has no issues", () => {
const error = new ZodError([]);
const details = transformErrorToDetails(error);
expect(details).toEqual({});
});
test("should handle issues with empty paths", () => {
const error = new ZodError([
{
code: ZodIssueCode.custom,
path: [],
message: "Global error",
},
]);
const details = transformErrorToDetails(error);
expect(details).toEqual({
"": "Global error",
});
});
test("should handle issues with multi-level paths", () => {
const error = new ZodError([
{
code: ZodIssueCode.invalid_type,
expected: "string",
received: "undefined",
path: ["user", "profile", "firstName"],
message: "Required",
},
]);
const details = transformErrorToDetails(error);
expect(details).toEqual({
"user.profile.firstName": "Required",
});
});
});

View File

@@ -0,0 +1,736 @@
import {
DateRange,
SelectedFilterValue,
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TLanguage } from "@formbricks/types/project";
import {
TSurvey,
TSurveyLanguage,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { generateQuestionAndFilterOptions, getFormattedFilters, getTodayDate } from "./surveys";
describe("surveys", () => {
afterEach(() => {
cleanup();
});
describe("generateQuestionAndFilterOptions", () => {
test("should return question options for basic survey without additional options", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Text Question" },
} as unknown as TSurveyQuestion,
],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
expect(result.questionOptions.length).toBeGreaterThan(0);
expect(result.questionOptions[0].header).toBe(OptionsType.QUESTIONS);
expect(result.questionFilterOptions.length).toBe(1);
expect(result.questionFilterOptions[0].id).toBe("q1");
});
test("should include tags in options when provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const tags: TTag[] = [
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
];
const result = generateQuestionAndFilterOptions(survey, tags, {}, {}, {});
const tagsHeader = result.questionOptions.find((opt) => opt.header === OptionsType.TAGS);
expect(tagsHeader).toBeDefined();
expect(tagsHeader?.option.length).toBe(1);
expect(tagsHeader?.option[0].label).toBe("Tag 1");
});
test("should include attributes in options when provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const attributes = {
role: ["admin", "user"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, attributes, {}, {});
const attributesHeader = result.questionOptions.find((opt) => opt.header === OptionsType.ATTRIBUTES);
expect(attributesHeader).toBeDefined();
expect(attributesHeader?.option.length).toBe(1);
expect(attributesHeader?.option[0].label).toBe("role");
});
test("should include meta in options when provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const meta = {
source: ["web", "mobile"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, {}, meta, {});
const metaHeader = result.questionOptions.find((opt) => opt.header === OptionsType.META);
expect(metaHeader).toBeDefined();
expect(metaHeader?.option.length).toBe(1);
expect(metaHeader?.option[0].label).toBe("source");
});
test("should include hidden fields in options when provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const hiddenFields = {
segment: ["free", "paid"],
};
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, hiddenFields);
const hiddenFieldsHeader = result.questionOptions.find(
(opt) => opt.header === OptionsType.HIDDEN_FIELDS
);
expect(hiddenFieldsHeader).toBeDefined();
expect(hiddenFieldsHeader?.option.length).toBe(1);
expect(hiddenFieldsHeader?.option[0].label).toBe("segment");
});
test("should include language options when survey has languages", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
languages: [{ language: { code: "en" } as unknown as TLanguage } as unknown as TSurveyLanguage],
} as unknown as TSurvey;
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
const othersHeader = result.questionOptions.find((opt) => opt.header === OptionsType.OTHERS);
expect(othersHeader).toBeDefined();
expect(othersHeader?.option.some((o) => o.label === "Language")).toBeTruthy();
});
test("should handle all question types correctly", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Text" },
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Multiple Choice Single" },
choices: [{ id: "c1", label: "Choice 1" }],
} as unknown as TSurveyQuestion,
{
id: "q3",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multiple Choice Multi" },
choices: [
{ id: "c1", label: "Choice 1" },
{ id: "other", label: "Other" },
],
} as unknown as TSurveyQuestion,
{
id: "q4",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "NPS" },
} as unknown as TSurveyQuestion,
{
id: "q5",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rating" },
} as unknown as TSurveyQuestion,
{
id: "q6",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
} as unknown as TSurveyQuestion,
{
id: "q7",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: { default: "Picture Selection" },
choices: [
{ id: "p1", imageUrl: "url1" },
{ id: "p2", imageUrl: "url2" },
],
} as unknown as TSurveyQuestion,
{
id: "q8",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
rows: [{ id: "r1", label: "Row 1" }],
columns: [{ id: "c1", label: "Column 1" }],
} as unknown as TSurveyQuestion,
],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {});
expect(result.questionFilterOptions.length).toBe(8);
expect(result.questionFilterOptions.some((o) => o.id === "q1")).toBeTruthy();
expect(result.questionFilterOptions.some((o) => o.id === "q2")).toBeTruthy();
expect(result.questionFilterOptions.some((o) => o.id === "q7")).toBeTruthy();
expect(result.questionFilterOptions.some((o) => o.id === "q8")).toBeTruthy();
});
});
describe("getFormattedFilters", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [
{
id: "openTextQ",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Open Text" },
} as unknown as TSurveyQuestion,
{
id: "mcSingleQ",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Multiple Choice Single" },
choices: [{ id: "c1", label: "Choice 1" }],
} as unknown as TSurveyQuestion,
{
id: "mcMultiQ",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
headline: { default: "Multiple Choice Multi" },
choices: [{ id: "c1", label: "Choice 1" }],
} as unknown as TSurveyQuestion,
{
id: "npsQ",
type: TSurveyQuestionTypeEnum.NPS,
headline: { default: "NPS" },
} as unknown as TSurveyQuestion,
{
id: "ratingQ",
type: TSurveyQuestionTypeEnum.Rating,
headline: { default: "Rating" },
} as unknown as TSurveyQuestion,
{
id: "ctaQ",
type: TSurveyQuestionTypeEnum.CTA,
headline: { default: "CTA" },
} as unknown as TSurveyQuestion,
{
id: "consentQ",
type: TSurveyQuestionTypeEnum.Consent,
headline: { default: "Consent" },
} as unknown as TSurveyQuestion,
{
id: "pictureQ",
type: TSurveyQuestionTypeEnum.PictureSelection,
headline: { default: "Picture Selection" },
choices: [
{ id: "p1", imageUrl: "url1" },
{ id: "p2", imageUrl: "url2" },
],
} as unknown as TSurveyQuestion,
{
id: "matrixQ",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
rows: [{ id: "r1", label: "Row 1" }],
columns: [{ id: "c1", label: "Column 1" }],
} as unknown as TSurveyQuestion,
{
id: "addressQ",
type: TSurveyQuestionTypeEnum.Address,
headline: { default: "Address" },
} as unknown as TSurveyQuestion,
{
id: "contactQ",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: { default: "Contact Info" },
} as unknown as TSurveyQuestion,
{
id: "rankingQ",
type: TSurveyQuestionTypeEnum.Ranking,
headline: { default: "Ranking" },
} as unknown as TSurveyQuestion,
],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const dateRange: DateRange = {
from: new Date("2023-01-01"),
to: new Date("2023-01-31"),
};
test("should return empty filters when no selections", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [],
};
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(Object.keys(result).length).toBe(0);
});
test("should filter by completed responses", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: true,
filter: [],
};
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.finished).toBe(true);
});
test("should filter by date range", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [],
};
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.createdAt).toBeDefined();
expect(result.createdAt?.min).toEqual(dateRange.from);
expect(result.createdAt?.max).toEqual(dateRange.to);
});
test("should filter by tags", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
filterType: { filterComboBoxValue: "Applied" },
},
{
questionType: { type: "Tags", label: "Tag 2", id: "tag2" },
filterType: { filterComboBoxValue: "Not applied" },
},
] as any,
};
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.tags?.applied).toContain("Tag 1");
expect(result.tags?.notApplied).toContain("Tag 2");
});
test("should filter by open text questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Open Text",
id: "openTextQ",
questionType: TSurveyQuestionTypeEnum.OpenText,
},
filterType: { filterComboBoxValue: "Filled out" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.openTextQ).toEqual({ op: "filledOut" });
});
test("should filter by address questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Address",
id: "addressQ",
questionType: TSurveyQuestionTypeEnum.Address,
},
filterType: { filterComboBoxValue: "Skipped" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.addressQ).toEqual({ op: "skipped" });
});
test("should filter by contact info questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Contact Info",
id: "contactQ",
questionType: TSurveyQuestionTypeEnum.ContactInfo,
},
filterType: { filterComboBoxValue: "Filled out" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.contactQ).toEqual({ op: "filledOut" });
});
test("should filter by ranking questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Ranking",
id: "rankingQ",
questionType: TSurveyQuestionTypeEnum.Ranking,
},
filterType: { filterComboBoxValue: "Filled out" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.rankingQ).toEqual({ op: "submitted" });
});
test("should filter by multiple choice single questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "MC Single",
id: "mcSingleQ",
questionType: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
},
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Choice 1"] },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.mcSingleQ).toEqual({ op: "includesOne", value: ["Choice 1"] });
});
test("should filter by multiple choice multi questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "MC Multi",
id: "mcMultiQ",
questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
},
filterType: { filterValue: "Includes all", filterComboBoxValue: ["Choice 1", "Choice 2"] },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.mcMultiQ).toEqual({ op: "includesAll", value: ["Choice 1", "Choice 2"] });
});
test("should filter by NPS questions with different operations", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "NPS",
id: "npsQ",
questionType: TSurveyQuestionTypeEnum.NPS,
},
filterType: { filterValue: "Is equal to", filterComboBoxValue: "7" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.npsQ).toEqual({ op: "equals", value: 7 });
});
test("should filter by rating questions with less than operation", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Rating",
id: "ratingQ",
questionType: TSurveyQuestionTypeEnum.Rating,
},
filterType: { filterValue: "Is less than", filterComboBoxValue: "4" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.ratingQ).toEqual({ op: "lessThan", value: 4 });
});
test("should filter by CTA questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "CTA",
id: "ctaQ",
questionType: TSurveyQuestionTypeEnum.CTA,
},
filterType: { filterComboBoxValue: "Clicked" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.ctaQ).toEqual({ op: "clicked" });
});
test("should filter by consent questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Consent",
id: "consentQ",
questionType: TSurveyQuestionTypeEnum.Consent,
},
filterType: { filterComboBoxValue: "Accepted" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.consentQ).toEqual({ op: "accepted" });
});
test("should filter by picture selection questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Picture",
id: "pictureQ",
questionType: TSurveyQuestionTypeEnum.PictureSelection,
},
filterType: { filterValue: "Includes either", filterComboBoxValue: ["Picture 1"] },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.pictureQ).toEqual({ op: "includesOne", value: ["p1"] });
});
test("should filter by matrix questions", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: {
type: "Questions",
label: "Matrix",
id: "matrixQ",
questionType: TSurveyQuestionTypeEnum.Matrix,
},
filterType: { filterValue: "Row 1", filterComboBoxValue: "Column 1" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.matrixQ).toEqual({ op: "matrix", value: { "Row 1": "Column 1" } });
});
test("should filter by hidden fields", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: { type: "Hidden Fields", label: "plan", id: "plan" },
filterType: { filterValue: "Equals", filterComboBoxValue: "pro" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.data?.plan).toEqual({ op: "equals", value: "pro" });
});
test("should filter by attributes", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: { type: "Attributes", label: "role", id: "role" },
filterType: { filterValue: "Not equals", filterComboBoxValue: "admin" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.contactAttributes?.role).toEqual({ op: "notEquals", value: "admin" });
});
test("should filter by other filters", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: { type: "Other Filters", label: "Language", id: "language" },
filterType: { filterValue: "Equals", filterComboBoxValue: "en" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.others?.Language).toEqual({ op: "equals", value: "en" });
});
test("should filter by meta fields", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: false,
filter: [
{
questionType: { type: "Meta", label: "source", id: "source" },
filterType: { filterValue: "Not equals", filterComboBoxValue: "web" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.meta?.source).toEqual({ op: "notEquals", value: "web" });
});
test("should handle multiple filters together", () => {
const selectedFilter: SelectedFilterValue = {
onlyComplete: true,
filter: [
{
questionType: {
type: "Questions",
label: "NPS",
id: "npsQ",
questionType: TSurveyQuestionTypeEnum.NPS,
},
filterType: { filterValue: "Is more than", filterComboBoxValue: "7" },
},
{
questionType: { type: "Tags", label: "Tag 1", id: "tag1" },
filterType: { filterComboBoxValue: "Applied" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, dateRange);
expect(result.finished).toBe(true);
expect(result.createdAt).toBeDefined();
expect(result.data?.npsQ).toEqual({ op: "greaterThan", value: 7 });
expect(result.tags?.applied).toContain("Tag 1");
});
});
describe("getTodayDate", () => {
test("should return today's date with time set to end of day", () => {
const today = new Date();
const result = getTodayDate();
expect(result.getFullYear()).toBe(today.getFullYear());
expect(result.getMonth()).toBe(today.getMonth());
expect(result.getDate()).toBe(today.getDate());
expect(result.getHours()).toBe(23);
expect(result.getMinutes()).toBe(59);
expect(result.getSeconds()).toBe(59);
expect(result.getMilliseconds()).toBe(999);
});
});
});

View File

@@ -0,0 +1,99 @@
import * as constants from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Mock } from "vitest";
vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() }));
describe("bucket middleware rate limiters", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
const mockedRateLimit = rateLimit as unknown as Mock;
mockedRateLimit.mockImplementation((config) => config);
});
test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => {
const { loginLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
expect(loginLimiter).toEqual({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
});
test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => {
const { signupLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
expect(signupLimiter).toEqual({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
});
test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => {
const { verifyEmailLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
expect(verifyEmailLimiter).toEqual({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
});
test("forgotPasswordLimiter uses FORGET_PASSWORD_RATE_LIMIT settings", async () => {
const { forgotPasswordLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
expect(forgotPasswordLimiter).toEqual({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
});
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
const { clientSideApiEndpointsLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
expect(clientSideApiEndpointsLimiter).toEqual({
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
});
test("shareUrlLimiter uses SHARE_RATE_LIMIT settings", async () => {
const { shareUrlLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SHARE_RATE_LIMIT.interval,
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
});
expect(shareUrlLimiter).toEqual({
interval: constants.SHARE_RATE_LIMIT.interval,
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
});
});
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
const { syncUserIdentificationLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
expect(syncUserIdentificationLimiter).toEqual({
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
});
});
});

View File

@@ -1,4 +1,3 @@
import { rateLimit } from "@/app/middleware/rate-limit";
import {
CLIENT_SIDE_API_RATE_LIMIT,
FORGET_PASSWORD_RATE_LIMIT,
@@ -8,6 +7,7 @@ import {
SYNC_USER_IDENTIFICATION_RATE_LIMIT,
VERIFY_EMAIL_RATE_LIMIT,
} from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
export const loginLimiter = rateLimit({
interval: LOGIN_RATE_LIMIT.interval,

View File

@@ -0,0 +1,140 @@
import { describe, expect, test } from "vitest";
import {
isAuthProtectedRoute,
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isManagementApiRoute,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute,
} from "./endpoint-validator";
describe("endpoint-validator", () => {
describe("isLoginRoute", () => {
test("should return true for login routes", () => {
expect(isLoginRoute("/api/auth/callback/credentials")).toBe(true);
expect(isLoginRoute("/auth/login")).toBe(true);
});
test("should return false for non-login routes", () => {
expect(isLoginRoute("/auth/signup")).toBe(false);
expect(isLoginRoute("/api/something")).toBe(false);
});
});
describe("isSignupRoute", () => {
test("should return true for signup route", () => {
expect(isSignupRoute("/auth/signup")).toBe(true);
});
test("should return false for non-signup routes", () => {
expect(isSignupRoute("/auth/login")).toBe(false);
expect(isSignupRoute("/api/something")).toBe(false);
});
});
describe("isVerifyEmailRoute", () => {
test("should return true for verify email route", () => {
expect(isVerifyEmailRoute("/auth/verify-email")).toBe(true);
});
test("should return false for non-verify email routes", () => {
expect(isVerifyEmailRoute("/auth/login")).toBe(false);
expect(isVerifyEmailRoute("/api/something")).toBe(false);
});
});
describe("isForgotPasswordRoute", () => {
test("should return true for forgot password route", () => {
expect(isForgotPasswordRoute("/auth/forgot-password")).toBe(true);
});
test("should return false for non-forgot password routes", () => {
expect(isForgotPasswordRoute("/auth/login")).toBe(false);
expect(isForgotPasswordRoute("/api/something")).toBe(false);
});
});
describe("isClientSideApiRoute", () => {
test("should return true for client-side API routes", () => {
expect(isClientSideApiRoute("/api/packages/something")).toBe(true);
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/storage")).toBe(true);
expect(isClientSideApiRoute("/api/v1/client/other")).toBe(true);
expect(isClientSideApiRoute("/api/v2/client/other")).toBe(true);
});
test("should return false for non-client-side API routes", () => {
expect(isClientSideApiRoute("/api/v1/management/something")).toBe(false);
expect(isClientSideApiRoute("/api/something")).toBe(false);
expect(isClientSideApiRoute("/auth/login")).toBe(false);
});
});
describe("isManagementApiRoute", () => {
test("should return true for management API routes", () => {
expect(isManagementApiRoute("/api/v1/management/something")).toBe(true);
expect(isManagementApiRoute("/api/v2/management/other")).toBe(true);
});
test("should return false for non-management API routes", () => {
expect(isManagementApiRoute("/api/v1/client/something")).toBe(false);
expect(isManagementApiRoute("/api/something")).toBe(false);
expect(isManagementApiRoute("/auth/login")).toBe(false);
});
});
describe("isShareUrlRoute", () => {
test("should return true for share URL routes", () => {
expect(isShareUrlRoute("/share/abc123/summary")).toBe(true);
expect(isShareUrlRoute("/share/abc123/responses")).toBe(true);
expect(isShareUrlRoute("/share/abc123def456/summary")).toBe(true);
});
test("should return false for non-share URL routes", () => {
expect(isShareUrlRoute("/share/abc123")).toBe(false);
expect(isShareUrlRoute("/share/abc123/other")).toBe(false);
expect(isShareUrlRoute("/api/something")).toBe(false);
});
});
describe("isAuthProtectedRoute", () => {
test("should return true for protected routes", () => {
expect(isAuthProtectedRoute("/environments")).toBe(true);
expect(isAuthProtectedRoute("/environments/something")).toBe(true);
expect(isAuthProtectedRoute("/setup/organization")).toBe(true);
expect(isAuthProtectedRoute("/organizations")).toBe(true);
expect(isAuthProtectedRoute("/organizations/something")).toBe(true);
});
test("should return false for non-protected routes", () => {
expect(isAuthProtectedRoute("/auth/login")).toBe(false);
expect(isAuthProtectedRoute("/api/something")).toBe(false);
expect(isAuthProtectedRoute("/")).toBe(false);
});
});
describe("isSyncWithUserIdentificationEndpoint", () => {
test("should return environmentId and userId for valid sync URLs", () => {
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
expect(result1).toEqual({
environmentId: "env123",
userId: "user456",
});
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/abc-123/app/sync/xyz-789");
expect(result2).toEqual({
environmentId: "abc-123",
userId: "xyz-789",
});
});
test("should return false for invalid sync URLs", () => {
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
});
});
});

View File

@@ -1,4 +1,5 @@
export const isLoginRoute = (url: string) => url === "/api/auth/callback/credentials";
export const isLoginRoute = (url: string) =>
url === "/api/auth/callback/credentials" || url === "/auth/login";
export const isSignupRoute = (url: string) => url === "/auth/signup";

View File

@@ -0,0 +1,37 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/preact";
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import NotFound from "./not-found";
describe("NotFound", () => {
afterEach(() => {
cleanup();
});
test("renders 404 page with correct content", () => {
render(<NotFound />);
// Check for the 404 text
const errorCode = screen.getByTestId("error-code");
expect(errorCode).toBeInTheDocument();
expect(errorCode).toHaveClass("text-sm", "font-semibold");
expect(errorCode).toHaveTextContent("404");
// Check for the heading
const heading = screen.getByRole("heading", { name: "Page not found" });
expect(heading).toBeInTheDocument();
expect(heading).toHaveClass("mt-2", "text-2xl", "font-bold");
// Check for the error message
const errorMessage = screen.getByTestId("error-message");
expect(errorMessage).toBeInTheDocument();
expect(errorMessage).toHaveClass("mt-2", "text-base");
expect(errorMessage).toHaveTextContent("Sorry, we couldn't find the page you're looking for.");
// Check for the button
const button = screen.getByRole("button", { name: "Back to home" });
expect(button).toBeInTheDocument();
expect(button).toHaveClass("mt-8");
});
});

View File

@@ -3,18 +3,18 @@ import Link from "next/link";
const NotFound = () => {
return (
<>
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">Page not found</h1>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
Sorry, we couldnt find the page youre looking for.
</p>
<Link href={"/"}>
<Button className="mt-8">Back to home</Button>
</Link>
</div>
</>
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
<p className="text-sm font-semibold text-zinc-900 dark:text-white" data-testid="error-code">
404
</p>
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">Page not found</h1>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400" data-testid="error-message">
Sorry, we couldn&apos;t find the page you&apos;re looking for.
</p>
<Link href={"/"}>
<Button className="mt-8">Back to home</Button>
</Link>
</div>
);
};

391
apps/web/app/page.test.tsx Normal file
View File

@@ -0,0 +1,391 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import Page from "./page";
// Mock dependencies
vi.mock("@/lib/environment/service", () => ({
getFirstEnvironmentIdByUserId: vi.fn(),
}));
vi.mock("@/lib/instance/service", () => ({
getIsFreshInstance: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationsByUserId: vi.fn(),
}));
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/modules/ui/components/client-logout", () => ({
ClientLogout: () => <div data-testid="client-logout">Client Logout</div>,
}));
vi.mock("@/app/ClientEnvironmentRedirect", () => ({
default: ({ environmentId }: { environmentId: string }) => (
<div data-testid="client-environment-redirect">Environment ID: {environmentId}</div>
),
}));
describe("Page", () => {
beforeEach(() => {
cleanup();
vi.clearAllMocks();
});
test("redirects to setup/intro when no session and fresh instance", async () => {
const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { redirect } = await import("next/navigation");
vi.mocked(getServerSession).mockResolvedValue(null);
vi.mocked(getIsFreshInstance).mockResolvedValue(true);
await Page();
expect(redirect).toHaveBeenCalledWith("/setup/intro");
});
test("redirects to auth/login when no session and not fresh instance", async () => {
const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { redirect } = await import("next/navigation");
vi.mocked(getServerSession).mockResolvedValue(null);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
await Page();
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("shows client logout when user is not found", async () => {
const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { render } = await import("@testing-library/react");
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "test-user-id" },
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(null);
const result = await Page();
const { container } = render(result);
expect(container.querySelector('[data-testid="client-logout"]')).toBeInTheDocument();
});
test("redirects to organization creation when user has no organizations", async () => {
const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { redirect } = await import("next/navigation");
const mockUser: TUser = {
id: "test-user-id",
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: null,
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US",
lastLoginAt: null,
isActive: true,
};
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "test-user-id" },
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([]);
await Page();
expect(redirect).toHaveBeenCalledWith("/setup/organization/create");
});
test("redirects to project creation when user has organizations but no environment", async () => {
const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { redirect } = await import("next/navigation");
const mockUser: TUser = {
id: "test-user-id",
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: null,
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US",
lastLoginAt: null,
isActive: true,
};
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
isAIEnabled: false,
};
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "owner",
};
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "test-user-id" },
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isManager: false,
isOwner: true,
isBilling: false,
isMember: true,
});
await Page();
expect(redirect).toHaveBeenCalledWith(`/organizations/${mockOrganization.id}/projects/new/mode`);
});
test("redirects to landing when user has organizations but no environment and is not owner/manager", async () => {
const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { redirect } = await import("next/navigation");
const mockUser: TUser = {
id: "test-user-id",
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: null,
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US",
lastLoginAt: null,
isActive: true,
};
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
isAIEnabled: false,
};
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "member",
};
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "test-user-id" },
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isManager: false,
isOwner: false,
isBilling: false,
isMember: true,
});
await Page();
expect(redirect).toHaveBeenCalledWith(`/organizations/${mockOrganization.id}/landing`);
});
test("renders ClientEnvironmentRedirect when user has environment", async () => {
const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getFirstEnvironmentIdByUserId } = await import("@/lib/environment/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { render } = await import("@testing-library/react");
const mockUser: TUser = {
id: "test-user-id",
name: "Test User",
email: "test@example.com",
emailVerified: null,
imageUrl: null,
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: null,
objective: null,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
locale: "en-US",
lastLoginAt: null,
isActive: true,
};
const mockOrganization: TOrganization = {
id: "test-org-id",
name: "Test Organization",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
isAIEnabled: false,
};
const mockMembership: TMembership = {
organizationId: "test-org-id",
userId: "test-user-id",
accepted: true,
role: "member",
};
const mockEnvironmentId = "test-env-id";
vi.mocked(getServerSession).mockResolvedValue({
user: { id: "test-user-id" },
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getFirstEnvironmentIdByUserId).mockResolvedValue(mockEnvironmentId);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isManager: false,
isOwner: false,
isBilling: false,
isMember: true,
});
const result = await Page();
const { container } = render(result);
expect(container.querySelector('[data-testid="client-environment-redirect"]')).toHaveTextContent(
`Environment ID: ${mockEnvironmentId}`
);
});
});

View File

@@ -17,9 +17,9 @@ const Page = async () => {
if (!session) {
if (isFreshInstance) {
redirect("/setup/intro");
return redirect("/setup/intro");
} else {
redirect("/auth/login");
return redirect("/auth/login");
}
}

View File

@@ -5,18 +5,16 @@ import Link from "next/link";
const NotFound = async () => {
const t = await getTranslate();
return (
<>
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">{t("share.page_not_found")}</h1>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
{t("share.page_not_found_description")}
</p>
<Link href={"/"}>
<Button className="mt-8">{t("share.back_to_home")}</Button>
</Link>
</div>
</>
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">{t("share.page_not_found")}</h1>
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
{t("share.page_not_found_description")}
</p>
<Link href={"/"}>
<Button className="mt-8">{t("share.back_to_home")}</Button>
</Link>
</div>
);
};

97
apps/web/cache-handler.js Normal file
View File

@@ -0,0 +1,97 @@
// This cache handler follows the @fortedigital/nextjs-cache-handler example
// Read more at: https://github.com/fortedigital/nextjs-cache-handler
// @neshca/cache-handler dependencies
const { CacheHandler } = require("@neshca/cache-handler");
const createLruHandler = require("@neshca/cache-handler/local-lru").default;
// Next/Redis dependencies
const { createClient } = require("redis");
const { PHASE_PRODUCTION_BUILD } = require("next/constants");
// @fortedigital/nextjs-cache-handler dependencies
const createRedisHandler = require("@fortedigital/nextjs-cache-handler/redis-strings").default;
const { Next15CacheHandler } = require("@fortedigital/nextjs-cache-handler/next-15-cache-handler");
// Usual onCreation from @neshca/cache-handler
CacheHandler.onCreation(() => {
// Important - It's recommended to use global scope to ensure only one Redis connection is made
// This ensures only one instance get created
if (global.cacheHandlerConfig) {
return global.cacheHandlerConfig;
}
// Important - It's recommended to use global scope to ensure only one Redis connection is made
// This ensures new instances are not created in a race condition
if (global.cacheHandlerConfigPromise) {
return global.cacheHandlerConfigPromise;
}
// If REDIS_URL is not set, we will use LRU cache only
if (!process.env.REDIS_URL) {
const lruCache = createLruHandler();
return { handlers: [lruCache] };
}
// Main promise initializing the handler
global.cacheHandlerConfigPromise = (async () => {
/** @type {import("redis").RedisClientType | null} */
let redisClient = null;
// eslint-disable-next-line turbo/no-undeclared-env-vars -- Next.js will inject this variable
if (PHASE_PRODUCTION_BUILD !== process.env.NEXT_PHASE) {
const settings = {
url: process.env.REDIS_URL, // Make sure you configure this variable
pingInterval: 10000,
};
try {
redisClient = createClient(settings);
redisClient.on("error", (e) => {
console.error("Redis error", e);
global.cacheHandlerConfig = null;
global.cacheHandlerConfigPromise = null;
});
} catch (error) {
console.error("Failed to create Redis client:", error);
}
}
if (redisClient) {
try {
console.info("Connecting Redis client...");
await redisClient.connect();
console.info("Redis client connected.");
} catch (error) {
console.error("Failed to connect Redis client:", error);
await redisClient
.disconnect()
.catch(() => console.error("Failed to quit the Redis client after failing to connect."));
}
}
const lruCache = createLruHandler();
if (!redisClient?.isReady) {
console.error("Failed to initialize caching layer.");
global.cacheHandlerConfigPromise = null;
global.cacheHandlerConfig = { handlers: [lruCache] };
return global.cacheHandlerConfig;
}
const redisCacheHandler = createRedisHandler({
client: redisClient,
keyPrefix: "nextjs:",
});
global.cacheHandlerConfigPromise = null;
global.cacheHandlerConfig = {
handlers: [redisCacheHandler],
};
return global.cacheHandlerConfig;
})();
return global.cacheHandlerConfigPromise;
});
module.exports = new Next15CacheHandler();

View File

@@ -1,79 +0,0 @@
import { CacheHandler } from "@neshca/cache-handler";
import createLruHandler from "@neshca/cache-handler/local-lru";
import createRedisHandler from "@neshca/cache-handler/redis-strings";
import { createClient } from "redis";
// Function to create a timeout promise
const createTimeoutPromise = (ms, rejectReason) => {
return new Promise((_, reject) => setTimeout(() => reject(new Error(rejectReason)), ms));
};
CacheHandler.onCreation(async () => {
let client;
if (process.env.REDIS_URL) {
try {
// Create a Redis client.
client = createClient({
url: process.env.REDIS_URL,
});
// Redis won't work without error handling.
client.on("error", () => {});
} catch (error) {
console.warn("Failed to create Redis client:", error);
}
if (client) {
try {
// Wait for the client to connect with a timeout of 5000ms.
const connectPromise = client.connect();
const timeoutPromise = createTimeoutPromise(5000, "Redis connection timed out"); // 5000ms timeout
await Promise.race([connectPromise, timeoutPromise]);
} catch (error) {
console.warn("Failed to connect Redis client:", error);
console.warn("Disconnecting the Redis client...");
// Try to disconnect the client to stop it from reconnecting.
client
.disconnect()
.then(() => {
console.info("Redis client disconnected.");
})
.catch(() => {
console.warn("Failed to quit the Redis client after failing to connect.");
});
}
}
}
/** @type {import("@neshca/cache-handler").Handler | null} */
let handler;
if (client?.isReady) {
const redisHandlerOptions = {
client,
keyPrefix: "fb:",
timeoutMs: 1000,
};
// Create the `redis-stack` Handler if the client is available and connected.
handler = await createRedisHandler(redisHandlerOptions);
} else {
// Fallback to LRU handler if Redis client is not available.
// The application will still work, but the cache will be in memory only and not shared.
handler = createLruHandler();
console.log("Using LRU handler for caching.");
}
return {
handlers: [handler],
ttl: {
// We set the stale and the expire age to the same value, because the stale age is determined by the unstable_cache revalidation.
defaultStaleAge: (process.env.REDIS_URL && Number(process.env.REDIS_DEFAULT_TTL)) || 86400,
estimateExpireAge: (staleAge) => staleAge,
},
};
});
export default CacheHandler;

View File

@@ -1,34 +0,0 @@
import "server-only";
import { cache } from "@/lib/cache";
import { ZId } from "@formbricks/types/common";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { validateInputs } from "../utils/validate";
import { actionClassCache } from "./cache";
import { getActionClass } from "./service";
export const canUserUpdateActionClass = (userId: string, actionClassId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [actionClassId, ZId]);
try {
if (!userId) return false;
const actionClass = await getActionClass(actionClassId);
if (!actionClass) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, actionClass.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
} catch (error) {
throw error;
}
},
[`canUserUpdateActionClass-${userId}-${actionClassId}`],
{
tags: [actionClassCache.tag.byId(actionClassId)],
}
)();

View File

@@ -0,0 +1,213 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TActionClass } from "@formbricks/types/action-classes";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { actionClassCache } from "./cache";
import {
deleteActionClass,
getActionClass,
getActionClassByEnvironmentIdAndName,
getActionClasses,
} from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
actionClass: {
findMany: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("../utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("../cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("./cache", () => ({
actionClassCache: {
tag: {
byEnvironmentId: vi.fn(),
byNameAndEnvironmentId: vi.fn(),
byId: vi.fn(),
},
revalidate: vi.fn(),
},
}));
describe("ActionClass Service", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("getActionClasses", () => {
test("should return action classes for environment", async () => {
const mockActionClasses = [
{
id: "id1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action 1",
description: "desc",
type: "code",
key: "key1",
noCodeConfig: {},
environmentId: "env1",
},
];
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
vi.mocked(actionClassCache.tag.byEnvironmentId).mockReturnValue("mock-tag");
const result = await getActionClasses("env1");
expect(result).toEqual(mockActionClasses);
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
where: { environmentId: "env1" },
select: expect.any(Object),
take: undefined,
skip: undefined,
orderBy: { createdAt: "asc" },
});
});
test("should throw DatabaseError when prisma throws", async () => {
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("fail"));
vi.mocked(actionClassCache.tag.byEnvironmentId).mockReturnValue("mock-tag");
await expect(getActionClasses("env1")).rejects.toThrow(DatabaseError);
});
});
describe("getActionClassByEnvironmentIdAndName", () => {
test("should return action class when found", async () => {
const mockActionClass = {
id: "id2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action 2",
description: "desc2",
type: "noCode",
key: null,
noCodeConfig: {},
environmentId: "env2",
};
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(mockActionClass);
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
expect(result).toEqual(mockActionClass);
expect(prisma.actionClass.findFirst).toHaveBeenCalledWith({
where: { name: "Action 2", environmentId: "env2" },
select: expect.any(Object),
});
});
test("should return null when not found", async () => {
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockResolvedValue(null);
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
const result = await getActionClassByEnvironmentIdAndName("env2", "Action 2");
expect(result).toBeNull();
});
test("should throw DatabaseError when prisma throws", async () => {
if (!prisma.actionClass.findFirst) prisma.actionClass.findFirst = vi.fn();
vi.mocked(prisma.actionClass.findFirst).mockRejectedValue(new Error("fail"));
if (!actionClassCache.tag.byNameAndEnvironmentId) actionClassCache.tag.byNameAndEnvironmentId = vi.fn();
vi.mocked(actionClassCache.tag.byNameAndEnvironmentId).mockReturnValue("mock-tag");
await expect(getActionClassByEnvironmentIdAndName("env2", "Action 2")).rejects.toThrow(DatabaseError);
});
});
describe("getActionClass", () => {
test("should return action class when found", async () => {
const mockActionClass = {
id: "id3",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action 3",
description: "desc3",
type: "code",
key: "key3",
noCodeConfig: {},
environmentId: "env3",
};
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(mockActionClass);
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
const result = await getActionClass("id3");
expect(result).toEqual(mockActionClass);
expect(prisma.actionClass.findUnique).toHaveBeenCalledWith({
where: { id: "id3" },
select: expect.any(Object),
});
});
test("should return null when not found", async () => {
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
vi.mocked(prisma.actionClass.findUnique).mockResolvedValue(null);
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
const result = await getActionClass("id3");
expect(result).toBeNull();
});
test("should throw DatabaseError when prisma throws", async () => {
if (!prisma.actionClass.findUnique) prisma.actionClass.findUnique = vi.fn();
vi.mocked(prisma.actionClass.findUnique).mockRejectedValue(new Error("fail"));
if (!actionClassCache.tag.byId) actionClassCache.tag.byId = vi.fn();
vi.mocked(actionClassCache.tag.byId).mockReturnValue("mock-tag");
await expect(getActionClass("id3")).rejects.toThrow(DatabaseError);
});
});
describe("deleteActionClass", () => {
test("should delete and return action class", async () => {
const mockActionClass: TActionClass = {
id: "id4",
createdAt: new Date(),
updatedAt: new Date(),
name: "Action 4",
description: null,
type: "code",
key: "key4",
noCodeConfig: null,
environmentId: "env4",
};
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
vi.mocked(prisma.actionClass.delete).mockResolvedValue(mockActionClass);
vi.mocked(actionClassCache.revalidate).mockReturnValue(undefined);
const result = await deleteActionClass("id4");
expect(result).toEqual(mockActionClass);
expect(prisma.actionClass.delete).toHaveBeenCalledWith({
where: { id: "id4" },
select: expect.any(Object),
});
expect(actionClassCache.revalidate).toHaveBeenCalledWith({
environmentId: mockActionClass.environmentId,
id: "id4",
name: mockActionClass.name,
});
});
test("should throw ResourceNotFoundError if action class is null", async () => {
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
vi.mocked(prisma.actionClass.delete).mockResolvedValue(null as unknown as TActionClass);
await expect(deleteActionClass("id4")).rejects.toThrow(ResourceNotFoundError);
});
test("should rethrow unknown errors", async () => {
if (!prisma.actionClass.delete) prisma.actionClass.delete = vi.fn();
const error = new Error("unknown");
vi.mocked(prisma.actionClass.delete).mockRejectedValue(error);
await expect(deleteActionClass("id4")).rejects.toThrow("unknown");
});
});
});

219
apps/web/lib/auth.test.ts Normal file
View File

@@ -0,0 +1,219 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { AuthenticationError } from "@formbricks/types/errors";
import {
hasOrganizationAccess,
hasOrganizationAuthority,
hasOrganizationOwnership,
hashPassword,
isManagerOrOwner,
isOwner,
verifyPassword,
} from "./auth";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
membership: {
findUnique: vi.fn(),
},
},
}));
describe("Password Management", () => {
test("hashPassword should hash a password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
expect(hashedPassword).toBeDefined();
expect(hashedPassword).not.toBe(password);
});
test("verifyPassword should verify a correct password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
});
test("verifyPassword should reject an incorrect password", async () => {
const password = "testPassword123";
const hashedPassword = await hashPassword(password);
const isValid = await verifyPassword("wrongPassword", hashedPassword);
expect(isValid).toBe(false);
});
});
describe("Organization Access", () => {
const mockUserId = "user123";
const mockOrgId = "org123";
beforeEach(() => {
vi.resetAllMocks();
});
test("hasOrganizationAccess should return true when user has membership", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
});
const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId);
expect(hasAccess).toBe(true);
});
test("hasOrganizationAccess should return false when user has no membership", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
const hasAccess = await hasOrganizationAccess(mockUserId, mockOrgId);
expect(hasAccess).toBe(false);
});
test("isManagerOrOwner should return true for manager role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
});
const isManager = await isManagerOrOwner(mockUserId, mockOrgId);
expect(isManager).toBe(true);
});
test("isManagerOrOwner should return true for owner role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
createdAt: new Date(),
updatedAt: new Date(),
});
const isOwner = await isManagerOrOwner(mockUserId, mockOrgId);
expect(isOwner).toBe(true);
});
test("isManagerOrOwner should return false for member role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
});
const isManagerOrOwnerRole = await isManagerOrOwner(mockUserId, mockOrgId);
expect(isManagerOrOwnerRole).toBe(false);
});
test("isOwner should return true only for owner role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
createdAt: new Date(),
updatedAt: new Date(),
});
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
expect(isOwnerRole).toBe(true);
});
test("isOwner should return false for non-owner roles", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
});
const isOwnerRole = await isOwner(mockUserId, mockOrgId);
expect(isOwnerRole).toBe(false);
});
});
describe("Organization Authority", () => {
const mockUserId = "user123";
const mockOrgId = "org123";
beforeEach(() => {
vi.resetAllMocks();
});
test("hasOrganizationAuthority should return true for manager", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
});
const hasAuthority = await hasOrganizationAuthority(mockUserId, mockOrgId);
expect(hasAuthority).toBe(true);
});
test("hasOrganizationAuthority should throw for non-member", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
});
test("hasOrganizationAuthority should throw for member role", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(hasOrganizationAuthority(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
});
test("hasOrganizationOwnership should return true for owner", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "owner",
createdAt: new Date(),
updatedAt: new Date(),
});
const hasOwnership = await hasOrganizationOwnership(mockUserId, mockOrgId);
expect(hasOwnership).toBe(true);
});
test("hasOrganizationOwnership should throw for non-member", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
});
test("hasOrganizationOwnership should throw for non-owner roles", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue({
id: "membership123",
userId: mockUserId,
organizationId: mockOrgId,
role: "manager",
createdAt: new Date(),
updatedAt: new Date(),
});
await expect(hasOrganizationOwnership(mockUserId, mockOrgId)).rejects.toThrow(AuthenticationError);
});
});

View File

@@ -1,66 +0,0 @@
import { revalidateTag } from "next/cache";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
interface RevalidateProps {
id?: string;
environmentId?: string | null;
surveyId?: string | null;
responseId?: string | null;
questionId?: string | null;
insightId?: string | null;
}
export const documentCache = {
tag: {
byId(id: string) {
return `documents-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-documents`;
},
byResponseId(responseId: string) {
return `responses-${responseId}-documents`;
},
byResponseIdQuestionId(responseId: string, questionId: TSurveyQuestionId) {
return `responses-${responseId}-questions-${questionId}-documents`;
},
bySurveyId(surveyId: string) {
return `surveys-${surveyId}-documents`;
},
bySurveyIdQuestionId(surveyId: string, questionId: TSurveyQuestionId) {
return `surveys-${surveyId}-questions-${questionId}-documents`;
},
byInsightId(insightId: string) {
return `insights-${insightId}-documents`;
},
byInsightIdSurveyIdQuestionId(insightId: string, surveyId: string, questionId: TSurveyQuestionId) {
return `insights-${insightId}-surveys-${surveyId}-questions-${questionId}-documents`;
},
},
revalidate: ({ id, environmentId, surveyId, responseId, questionId, insightId }: RevalidateProps): void => {
if (id) {
revalidateTag(documentCache.tag.byId(id));
}
if (environmentId) {
revalidateTag(documentCache.tag.byEnvironmentId(environmentId));
}
if (responseId) {
revalidateTag(documentCache.tag.byResponseId(responseId));
}
if (surveyId) {
revalidateTag(documentCache.tag.bySurveyId(surveyId));
}
if (responseId && questionId) {
revalidateTag(documentCache.tag.byResponseIdQuestionId(responseId, questionId));
}
if (surveyId && questionId) {
revalidateTag(documentCache.tag.bySurveyIdQuestionId(surveyId, questionId));
}
if (insightId) {
revalidateTag(documentCache.tag.byInsightId(insightId));
}
if (insightId && surveyId && questionId) {
revalidateTag(documentCache.tag.byInsightIdSurveyIdQuestionId(insightId, surveyId, questionId));
}
},
};

View File

@@ -1,25 +0,0 @@
import { revalidateTag } from "next/cache";
interface RevalidateProps {
id?: string;
environmentId?: string;
}
export const insightCache = {
tag: {
byId(id: string) {
return `documentGroups-${id}`;
},
byEnvironmentId(environmentId: string) {
return `environments-${environmentId}-documentGroups`;
},
},
revalidate: ({ id, environmentId }: RevalidateProps): void => {
if (id) {
revalidateTag(insightCache.tag.byId(id));
}
if (environmentId) {
revalidateTag(insightCache.tag.byEnvironmentId(environmentId));
}
},
};

View File

@@ -205,7 +205,6 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
export const REDIS_URL = env.REDIS_URL;
export const REDIS_HTTP_URL = env.REDIS_HTTP_URL;
export const REDIS_DEFAULT_TTL = env.REDIS_DEFAULT_TTL;
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
export const UNKEY_ROOT_KEY = env.UNKEY_ROOT_KEY;

View File

@@ -56,7 +56,6 @@ export const env = createEnv({
OIDC_SIGNING_ALGORITHM: z.string().optional(),
OPENTELEMETRY_LISTENER_URL: z.string().optional(),
REDIS_URL: z.string().optional(),
REDIS_DEFAULT_TTL: z.string().optional(),
REDIS_HTTP_URL: z.string().optional(),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
POSTHOG_API_HOST: z.string().optional(),
@@ -163,7 +162,6 @@ export const env = createEnv({
OIDC_ISSUER: process.env.OIDC_ISSUER,
OIDC_SIGNING_ALGORITHM: process.env.OIDC_SIGNING_ALGORITHM,
REDIS_URL: process.env.REDIS_URL,
REDIS_DEFAULT_TTL: process.env.REDIS_DEFAULT_TTL,
REDIS_HTTP_URL: process.env.REDIS_HTTP_URL,
PASSWORD_RESET_DISABLED: process.env.PASSWORD_RESET_DISABLED,
PRIVACY_URL: process.env.PRIVACY_URL,

View File

@@ -0,0 +1,86 @@
import { Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "./auth";
vi.mock("@formbricks/database", () => ({
prisma: {
membership: {
findFirst: vi.fn(),
},
teamUser: {
findFirst: vi.fn(),
},
},
}));
describe("hasUserEnvironmentAccess", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns true for owner role", async () => {
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
role: "owner",
} as any);
const result = await hasUserEnvironmentAccess("user1", "env1");
expect(result).toBe(true);
});
test("returns true for manager role", async () => {
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
role: "manager",
} as any);
const result = await hasUserEnvironmentAccess("user1", "env1");
expect(result).toBe(true);
});
test("returns true for billing role", async () => {
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
role: "billing",
} as any);
const result = await hasUserEnvironmentAccess("user1", "env1");
expect(result).toBe(true);
});
test("returns true when user has team membership", async () => {
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
role: "member",
} as any);
vi.mocked(prisma.teamUser.findFirst).mockResolvedValue({
userId: "user1",
} as any);
const result = await hasUserEnvironmentAccess("user1", "env1");
expect(result).toBe(true);
});
test("returns false when user has no access", async () => {
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
role: "member",
} as any);
vi.mocked(prisma.teamUser.findFirst).mockResolvedValue(null);
const result = await hasUserEnvironmentAccess("user1", "env1");
expect(result).toBe(false);
});
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.membership.findFirst).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2002",
clientVersion: "1.0.0",
})
);
await expect(hasUserEnvironmentAccess("user1", "env1")).rejects.toThrow(DatabaseError);
});
});

View File

@@ -0,0 +1,206 @@
import { EnvironmentType, Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { environmentCache } from "./cache";
import { getEnvironment, getEnvironments, updateEnvironment } from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
environment: {
findUnique: vi.fn(),
update: vi.fn(),
},
project: {
findFirst: vi.fn(),
},
},
}));
vi.mock("../utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("../cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("./cache", () => ({
environmentCache: {
revalidate: vi.fn(),
tag: {
byId: vi.fn(),
byProjectId: vi.fn(),
},
},
}));
describe("Environment Service", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("getEnvironment", () => {
test("should return environment when found", async () => {
const mockEnvironment = {
id: "clh6pzwx90000e9ogjr0mf7sx",
type: EnvironmentType.production,
projectId: "clh6pzwx90000e9ogjr0mf7sy",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
widgetSetupCompleted: false,
};
vi.mocked(prisma.environment.findUnique).mockResolvedValue(mockEnvironment);
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
const result = await getEnvironment("clh6pzwx90000e9ogjr0mf7sx");
expect(result).toEqual(mockEnvironment);
expect(prisma.environment.findUnique).toHaveBeenCalledWith({
where: {
id: "clh6pzwx90000e9ogjr0mf7sx",
},
});
});
test("should return null when environment not found", async () => {
vi.mocked(prisma.environment.findUnique).mockResolvedValue(null);
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
const result = await getEnvironment("clh6pzwx90000e9ogjr0mf7sx");
expect(result).toBeNull();
});
test("should throw DatabaseError when prisma throws", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.findUnique).mockRejectedValue(prismaError);
vi.mocked(environmentCache.tag.byId).mockReturnValue("mock-tag");
await expect(getEnvironment("clh6pzwx90000e9ogjr0mf7sx")).rejects.toThrow(DatabaseError);
});
});
describe("getEnvironments", () => {
test("should return environments when project exists", async () => {
const mockEnvironments = [
{
id: "clh6pzwx90000e9ogjr0mf7sx",
type: EnvironmentType.production,
projectId: "clh6pzwx90000e9ogjr0mf7sy",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
},
{
id: "clh6pzwx90000e9ogjr0mf7sz",
type: EnvironmentType.development,
projectId: "clh6pzwx90000e9ogjr0mf7sy",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: true,
},
];
vi.mocked(prisma.project.findFirst).mockResolvedValue({
id: "clh6pzwx90000e9ogjr0mf7sy",
name: "Test Project",
environments: [
{
...mockEnvironments[0],
widgetSetupCompleted: false,
},
{
...mockEnvironments[1],
widgetSetupCompleted: true,
},
],
});
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
const result = await getEnvironments("clh6pzwx90000e9ogjr0mf7sy");
expect(result).toEqual(mockEnvironments);
expect(prisma.project.findFirst).toHaveBeenCalledWith({
where: {
id: "clh6pzwx90000e9ogjr0mf7sy",
},
include: {
environments: true,
},
});
});
test("should throw ResourceNotFoundError when project not found", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValue(null);
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
await expect(getEnvironments("clh6pzwx90000e9ogjr0mf7sy")).rejects.toThrow(ResourceNotFoundError);
});
test("should throw DatabaseError when prisma throws", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError);
vi.mocked(environmentCache.tag.byProjectId).mockReturnValue("mock-tag");
await expect(getEnvironments("clh6pzwx90000e9ogjr0mf7sy")).rejects.toThrow(DatabaseError);
});
});
describe("updateEnvironment", () => {
test("should update environment successfully", async () => {
const mockEnvironment = {
id: "clh6pzwx90000e9ogjr0mf7sx",
type: EnvironmentType.production,
projectId: "clh6pzwx90000e9ogjr0mf7sy",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: false,
widgetSetupCompleted: false,
};
vi.mocked(prisma.environment.update).mockResolvedValue(mockEnvironment);
const updateData = {
appSetupCompleted: true,
};
const result = await updateEnvironment("clh6pzwx90000e9ogjr0mf7sx", updateData);
expect(result).toEqual(mockEnvironment);
expect(prisma.environment.update).toHaveBeenCalledWith({
where: {
id: "clh6pzwx90000e9ogjr0mf7sx",
},
data: expect.objectContaining({
appSetupCompleted: true,
updatedAt: expect.any(Date),
}),
});
expect(environmentCache.revalidate).toHaveBeenCalledWith({
id: "clh6pzwx90000e9ogjr0mf7sx",
projectId: "clh6pzwx90000e9ogjr0mf7sy",
});
});
test("should throw DatabaseError when prisma throws", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.environment.update).mockRejectedValue(prismaError);
await expect(
updateEnvironment("clh6pzwx90000e9ogjr0mf7sx", { appSetupCompleted: true })
).rejects.toThrow(DatabaseError);
});
});
});

View File

@@ -1,39 +0,0 @@
export const fetchRessource = async (url: string) => {
const res = await fetch(url);
// If the status code is not in the range 200-299,
// we still try to parse and throw it.
if (!res.ok) {
const error: any = new Error("An error occurred while fetching the data.");
// Attach extra info to the error object.
error.info = await res.json();
error.status = res.status;
throw error;
}
return res.json();
};
export const fetcher = async (url: string) => {
const res = await fetch(url);
// If the status code is not in the range 200-299,
// we still try to parse and throw it.
if (!res.ok) {
const error: any = new Error("An error occurred while fetching the data.");
// Attach extra info to the error object.
error.info = await res.json();
error.status = res.status;
throw error;
}
return res.json();
};
export const updateRessource = async (url: string, { arg }: { arg: any }) => {
return fetch(url, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(arg),
});
};

View File

@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
// Create a mock module for constants with proper types
const constantsMock = {
SURVEY_URL: undefined as string | undefined,
WEBAPP_URL: "http://localhost:3000" as string,
};
// Mock the constants module
vi.mock("./constants", () => constantsMock);
describe("getSurveyDomain", () => {
beforeEach(() => {
// Reset the mock values before each test
constantsMock.SURVEY_URL = undefined;
constantsMock.WEBAPP_URL = "http://localhost:3000";
vi.resetModules();
});
test("should return WEBAPP_URL when SURVEY_URL is not set", async () => {
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("http://localhost:3000");
});
test("should return SURVEY_URL when it is set", async () => {
constantsMock.SURVEY_URL = "https://surveys.example.com";
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("https://surveys.example.com");
});
test("should handle empty string SURVEY_URL by returning WEBAPP_URL", async () => {
constantsMock.SURVEY_URL = "";
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("http://localhost:3000");
});
test("should handle undefined SURVEY_URL by returning WEBAPP_URL", async () => {
constantsMock.SURVEY_URL = undefined;
const { getSurveyDomain } = await import("./getSurveyUrl");
const domain = getSurveyDomain();
expect(domain).toBe("http://localhost:3000");
});
});

View File

@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { hashString } from "./hashString";
describe("hashString", () => {
test("should return a string", () => {
const input = "test string";
const hash = hashString(input);
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
test("should produce consistent hashes for the same input", () => {
const input = "test string";
const hash1 = hashString(input);
const hash2 = hashString(input);
expect(hash1).toBe(hash2);
});
test("should handle empty strings", () => {
const hash = hashString("");
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
test("should handle special characters", () => {
const input = "!@#$%^&*()_+{}|:<>?";
const hash = hashString(input);
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
test("should handle unicode characters", () => {
const input = "Hello, 世界!";
const hash = hashString(input);
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
test("should handle long strings", () => {
const input = "a".repeat(1000);
const hash = hashString(input);
expect(typeof hash).toBe("string");
expect(hash.length).toBeGreaterThan(0);
});
});

View File

@@ -1,4 +1,4 @@
import { mockSurveyLanguages } from "@/lib/survey/tests/__mock__/survey.mock";
import { mockSurveyLanguages } from "@/lib/survey/__mock__/survey.mock";
import {
TSurvey,
TSurveyCTAQuestion,

View File

@@ -1,28 +0,0 @@
import "server-only";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { TI18nString } from "@formbricks/types/surveys/types";
import { isI18nObject } from "./utils";
// Helper function to extract a regular string from an i18nString.
const extractStringFromI18n = (i18nString: TI18nString, languageCode: string): string => {
if (typeof i18nString === "object" && i18nString !== null) {
return i18nString[languageCode] || "";
}
return i18nString;
};
// Assuming I18nString and extraction logic are defined
const reverseTranslateObject = <T extends Record<string, any>>(obj: T, languageCode: string): T => {
const clonedObj = structuredClone(obj);
for (let key in clonedObj) {
const value = clonedObj[key];
if (isI18nObject(value)) {
// Now TypeScript knows `value` is I18nString, treat it accordingly
clonedObj[key] = extractStringFromI18n(value, languageCode) as T[Extract<keyof T, string>];
} else if (typeof value === "object" && value !== null) {
// Recursively handle nested objects
clonedObj[key] = reverseTranslateObject(value, languageCode);
}
}
return clonedObj;
};

View File

@@ -1,31 +0,0 @@
import "server-only";
import { ZId } from "@formbricks/types/common";
import { cache } from "../cache";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { validateInputs } from "../utils/validate";
import { getIntegration } from "./service";
export const canUserAccessIntegration = async (userId: string, integrationId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [integrationId, ZId]);
if (!userId) return false;
try {
const integration = await getIntegration(integrationId);
if (!integration) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, integration.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
} catch (error) {
throw error;
}
},
[`canUserAccessIntegration-${userId}-${integrationId}`],
{
tags: [`integrations-${integrationId}`],
}
)();

View File

@@ -0,0 +1,291 @@
import { IntegrationType, Prisma } from "@prisma/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegrationInput } from "@formbricks/types/integration";
import { ITEMS_PER_PAGE } from "../constants";
import {
createOrUpdateIntegration,
deleteIntegration,
getIntegration,
getIntegrationByType,
getIntegrations,
} from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
integration: {
upsert: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
},
},
}));
describe("Integration Service", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
const mockIntegrationConfig = {
email: "test@example.com",
key: {
scope: "https://www.googleapis.com/auth/spreadsheets",
token_type: "Bearer" as const,
expiry_date: 1234567890,
access_token: "mock-access-token",
refresh_token: "mock-refresh-token",
},
data: [
{
spreadsheetId: "spreadsheet123",
spreadsheetName: "Test Spreadsheet",
surveyId: "survey123",
surveyName: "Test Survey",
questionIds: ["q1", "q2"],
questions: "Question 1, Question 2",
createdAt: new Date(),
includeHiddenFields: false,
includeMetadata: true,
includeCreatedAt: true,
includeVariables: false,
},
],
};
describe("createOrUpdateIntegration", () => {
const mockEnvironmentId = "clg123456789012345678901234";
const mockIntegrationData: TIntegrationInput = {
type: "googleSheets",
config: mockIntegrationConfig,
};
test("should create a new integration", async () => {
const mockIntegration = {
id: "int_123",
environmentId: mockEnvironmentId,
...mockIntegrationData,
};
vi.mocked(prisma.integration.upsert).mockResolvedValue(mockIntegration);
const result = await createOrUpdateIntegration(mockEnvironmentId, mockIntegrationData);
expect(prisma.integration.upsert).toHaveBeenCalledWith({
where: {
type_environmentId: {
environmentId: mockEnvironmentId,
type: mockIntegrationData.type,
},
},
update: {
...mockIntegrationData,
environment: { connect: { id: mockEnvironmentId } },
},
create: {
...mockIntegrationData,
environment: { connect: { id: mockEnvironmentId } },
},
});
expect(result).toEqual(mockIntegration);
});
test("should throw DatabaseError when Prisma throws an error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.integration.upsert).mockRejectedValue(prismaError);
await expect(createOrUpdateIntegration(mockEnvironmentId, mockIntegrationData)).rejects.toThrow(
DatabaseError
);
});
});
describe("getIntegrations", () => {
const mockEnvironmentId = "clg123456789012345678901234";
const mockIntegrations = [
{
id: "int_123",
environmentId: mockEnvironmentId,
type: IntegrationType.googleSheets,
config: mockIntegrationConfig,
},
];
test("should get all integrations for an environment", async () => {
vi.mocked(prisma.integration.findMany).mockResolvedValue(mockIntegrations);
const result = await getIntegrations(mockEnvironmentId);
expect(prisma.integration.findMany).toHaveBeenCalledWith({
where: {
environmentId: mockEnvironmentId,
},
});
expect(result).toEqual(mockIntegrations);
});
test("should get paginated integrations", async () => {
const page = 2;
vi.mocked(prisma.integration.findMany).mockResolvedValue(mockIntegrations);
const result = await getIntegrations(mockEnvironmentId, page);
expect(prisma.integration.findMany).toHaveBeenCalledWith({
where: {
environmentId: mockEnvironmentId,
},
take: ITEMS_PER_PAGE,
skip: ITEMS_PER_PAGE * (page - 1),
});
expect(result).toEqual(mockIntegrations);
});
test("should throw DatabaseError when Prisma throws an error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.integration.findMany).mockRejectedValue(prismaError);
await expect(getIntegrations(mockEnvironmentId)).rejects.toThrow(DatabaseError);
});
});
describe("getIntegration", () => {
const mockIntegrationId = "int_123";
const mockIntegration = {
id: mockIntegrationId,
environmentId: "clg123456789012345678901234",
type: IntegrationType.googleSheets,
config: mockIntegrationConfig,
};
test("should get an integration by ID", async () => {
vi.mocked(prisma.integration.findUnique).mockResolvedValue(mockIntegration);
const result = await getIntegration(mockIntegrationId);
expect(prisma.integration.findUnique).toHaveBeenCalledWith({
where: {
id: mockIntegrationId,
},
});
expect(result).toEqual(mockIntegration);
});
test("should return null when integration is not found", async () => {
vi.mocked(prisma.integration.findUnique).mockResolvedValue(null);
const result = await getIntegration(mockIntegrationId);
expect(result).toBeNull();
});
test("should throw DatabaseError when Prisma throws an error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.integration.findUnique).mockRejectedValue(prismaError);
await expect(getIntegration(mockIntegrationId)).rejects.toThrow(DatabaseError);
});
});
describe("getIntegrationByType", () => {
const mockEnvironmentId = "clg123456789012345678901234";
const mockType = IntegrationType.googleSheets;
const mockIntegration = {
id: "int_123",
environmentId: mockEnvironmentId,
type: mockType,
config: mockIntegrationConfig,
};
test("should get an integration by type", async () => {
vi.mocked(prisma.integration.findUnique).mockResolvedValue(mockIntegration);
const result = await getIntegrationByType(mockEnvironmentId, mockType);
expect(prisma.integration.findUnique).toHaveBeenCalledWith({
where: {
type_environmentId: {
environmentId: mockEnvironmentId,
type: mockType,
},
},
});
expect(result).toEqual(mockIntegration);
});
test("should return null when integration is not found", async () => {
vi.mocked(prisma.integration.findUnique).mockResolvedValue(null);
const result = await getIntegrationByType(mockEnvironmentId, mockType);
expect(result).toBeNull();
});
test("should throw DatabaseError when Prisma throws an error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.integration.findUnique).mockRejectedValue(prismaError);
await expect(getIntegrationByType(mockEnvironmentId, mockType)).rejects.toThrow(DatabaseError);
});
});
describe("deleteIntegration", () => {
const mockIntegrationId = "int_123";
const mockIntegration = {
id: mockIntegrationId,
environmentId: "clg123456789012345678901234",
type: IntegrationType.googleSheets,
config: mockIntegrationConfig,
};
test("should delete an integration", async () => {
vi.mocked(prisma.integration.delete).mockResolvedValue(mockIntegration);
const result = await deleteIntegration(mockIntegrationId);
expect(prisma.integration.delete).toHaveBeenCalledWith({
where: {
id: mockIntegrationId,
},
});
expect(result).toEqual(mockIntegration);
});
test("should throw DatabaseError when Prisma throws an error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.integration.delete).mockRejectedValue(prismaError);
await expect(deleteIntegration(mockIntegrationId)).rejects.toThrow(DatabaseError);
});
});
});

View File

@@ -3,8 +3,7 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
import { cache } from "../cache";

195
apps/web/lib/jwt.test.ts Normal file
View File

@@ -0,0 +1,195 @@
import { env } from "@/lib/env";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import {
createEmailToken,
createInviteToken,
createToken,
createTokenForLinkSurvey,
getEmailFromEmailToken,
verifyInviteToken,
verifyToken,
verifyTokenForLinkSurvey,
} from "./jwt";
// Mock environment variables
vi.mock("@/lib/env", () => ({
env: {
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
NEXTAUTH_SECRET: "test-nextauth-secret",
} as typeof env,
}));
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
},
},
}));
describe("JWT Functions", () => {
const mockUser = {
id: "test-user-id",
email: "test@example.com",
};
beforeEach(() => {
vi.clearAllMocks();
(prisma.user.findUnique as any).mockResolvedValue(mockUser);
});
describe("createToken", () => {
test("should create a valid token", () => {
const token = createToken(mockUser.id, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
});
test("should throw error if ENCRYPTION_KEY is not set", () => {
const originalKey = env.ENCRYPTION_KEY;
try {
(env as any).ENCRYPTION_KEY = undefined;
expect(() => createToken(mockUser.id, mockUser.email)).toThrow("ENCRYPTION_KEY is not set");
} finally {
(env as any).ENCRYPTION_KEY = originalKey;
}
});
});
describe("createTokenForLinkSurvey", () => {
test("should create a valid survey link token", () => {
const surveyId = "test-survey-id";
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
});
test("should throw error if ENCRYPTION_KEY is not set", () => {
const originalKey = env.ENCRYPTION_KEY;
try {
(env as any).ENCRYPTION_KEY = undefined;
expect(() => createTokenForLinkSurvey("test-survey-id", mockUser.email)).toThrow(
"ENCRYPTION_KEY is not set"
);
} finally {
(env as any).ENCRYPTION_KEY = originalKey;
}
});
});
describe("createEmailToken", () => {
test("should create a valid email token", () => {
const token = createEmailToken(mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
});
test("should throw error if ENCRYPTION_KEY is not set", () => {
const originalKey = env.ENCRYPTION_KEY;
try {
(env as any).ENCRYPTION_KEY = undefined;
expect(() => createEmailToken(mockUser.email)).toThrow("ENCRYPTION_KEY is not set");
} finally {
(env as any).ENCRYPTION_KEY = originalKey;
}
});
test("should throw error if NEXTAUTH_SECRET is not set", () => {
const originalSecret = env.NEXTAUTH_SECRET;
try {
(env as any).NEXTAUTH_SECRET = undefined;
expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
} finally {
(env as any).NEXTAUTH_SECRET = originalSecret;
}
});
});
describe("getEmailFromEmailToken", () => {
test("should extract email from valid token", () => {
const token = createEmailToken(mockUser.email);
const extractedEmail = getEmailFromEmailToken(token);
expect(extractedEmail).toBe(mockUser.email);
});
test("should throw error if ENCRYPTION_KEY is not set", () => {
const originalKey = env.ENCRYPTION_KEY;
try {
(env as any).ENCRYPTION_KEY = undefined;
expect(() => getEmailFromEmailToken("invalid-token")).toThrow("ENCRYPTION_KEY is not set");
} finally {
(env as any).ENCRYPTION_KEY = originalKey;
}
});
});
describe("createInviteToken", () => {
test("should create a valid invite token", () => {
const inviteId = "test-invite-id";
const token = createInviteToken(inviteId, mockUser.email);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
});
test("should throw error if ENCRYPTION_KEY is not set", () => {
const originalKey = env.ENCRYPTION_KEY;
try {
(env as any).ENCRYPTION_KEY = undefined;
expect(() => createInviteToken("test-invite-id", mockUser.email)).toThrow(
"ENCRYPTION_KEY is not set"
);
} finally {
(env as any).ENCRYPTION_KEY = originalKey;
}
});
});
describe("verifyTokenForLinkSurvey", () => {
test("should verify valid survey link token", () => {
const surveyId = "test-survey-id";
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
const verifiedEmail = verifyTokenForLinkSurvey(token, surveyId);
expect(verifiedEmail).toBe(mockUser.email);
});
test("should return null for invalid token", () => {
const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
expect(result).toBeNull();
});
});
describe("verifyToken", () => {
test("should verify valid token", async () => {
const token = createToken(mockUser.id, mockUser.email);
const verified = await verifyToken(token);
expect(verified).toEqual({
id: mockUser.id,
email: mockUser.email,
});
});
test("should throw error if user not found", async () => {
(prisma.user.findUnique as any).mockResolvedValue(null);
const token = createToken(mockUser.id, mockUser.email);
await expect(verifyToken(token)).rejects.toThrow("User not found");
});
});
describe("verifyInviteToken", () => {
test("should verify valid invite token", () => {
const inviteId = "test-invite-id";
const token = createInviteToken(inviteId, mockUser.email);
const verified = verifyInviteToken(token);
expect(verified).toEqual({
inviteId,
email: mockUser.email,
});
});
test("should throw error for invalid token", () => {
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
});
});
});

View File

@@ -0,0 +1,53 @@
import { renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { getMembershipByUserIdOrganizationIdAction } from "./actions";
import { useMembershipRole } from "./useMembershipRole";
vi.mock("./actions", () => ({
getMembershipByUserIdOrganizationIdAction: vi.fn(),
}));
describe("useMembershipRole", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("should fetch and return membership role", async () => {
const mockRole: TOrganizationRole = "owner";
vi.mocked(getMembershipByUserIdOrganizationIdAction).mockResolvedValue(mockRole);
const { result } = renderHook(() => useMembershipRole("env-123", "user-123"));
expect(result.current.isLoading).toBe(true);
expect(result.current.membershipRole).toBeUndefined();
expect(result.current.error).toBe("");
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.membershipRole).toBe(mockRole);
expect(result.current.error).toBe("");
expect(getMembershipByUserIdOrganizationIdAction).toHaveBeenCalledWith("env-123", "user-123");
});
test("should handle error when fetching membership role fails", async () => {
const errorMessage = "Failed to fetch role";
vi.mocked(getMembershipByUserIdOrganizationIdAction).mockRejectedValue(new Error(errorMessage));
const { result } = renderHook(() => useMembershipRole("env-123", "user-123"));
expect(result.current.isLoading).toBe(true);
expect(result.current.membershipRole).toBeUndefined();
expect(result.current.error).toBe("");
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.membershipRole).toBeUndefined();
expect(result.current.error).toBe(errorMessage);
expect(getMembershipByUserIdOrganizationIdAction).toHaveBeenCalledWith("env-123", "user-123");
});
});

View File

@@ -17,6 +17,7 @@ export const useMembershipRole = (environmentId: string, userId: string) => {
} catch (err: any) {
const error = err?.message || "Something went wrong";
setError(error);
setIsLoading(false);
}
};
getRole();

View File

@@ -0,0 +1,184 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { membershipCache } from "./cache";
import { createMembership, getMembershipByUserIdOrganizationId } from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
membership: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("./cache", () => ({
membershipCache: {
tag: {
byUserId: vi.fn(),
byOrganizationId: vi.fn(),
},
revalidate: vi.fn(),
},
}));
describe("Membership Service", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("getMembershipByUserIdOrganizationId", () => {
const mockUserId = "user123";
const mockOrgId = "org123";
test("returns membership when found", async () => {
const mockMembership: TMembership = {
organizationId: mockOrgId,
userId: mockUserId,
accepted: true,
role: "owner",
};
vi.mocked(prisma.membership.findUnique).mockResolvedValue(mockMembership);
const result = await getMembershipByUserIdOrganizationId(mockUserId, mockOrgId);
expect(result).toEqual(mockMembership);
expect(prisma.membership.findUnique).toHaveBeenCalledWith({
where: {
userId_organizationId: {
userId: mockUserId,
organizationId: mockOrgId,
},
},
});
});
test("returns null when membership not found", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
const result = await getMembershipByUserIdOrganizationId(mockUserId, mockOrgId);
expect(result).toBeNull();
});
test("throws DatabaseError on Prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.membership.findUnique).mockRejectedValue(prismaError);
await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(DatabaseError);
});
test("throws UnknownError on unknown error", async () => {
vi.mocked(prisma.membership.findUnique).mockRejectedValue(new Error("Unknown error"));
await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(UnknownError);
});
});
describe("createMembership", () => {
const mockUserId = "user123";
const mockOrgId = "org123";
const mockMembershipData: Partial<TMembership> = {
accepted: true,
role: "member",
};
test("creates new membership when none exists", async () => {
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
const mockCreatedMembership = {
organizationId: mockOrgId,
userId: mockUserId,
accepted: true,
role: "member",
} as TMembership;
vi.mocked(prisma.membership.create).mockResolvedValue(mockCreatedMembership as any);
const result = await createMembership(mockOrgId, mockUserId, mockMembershipData);
expect(result).toEqual(mockCreatedMembership);
expect(prisma.membership.create).toHaveBeenCalledWith({
data: {
userId: mockUserId,
organizationId: mockOrgId,
accepted: mockMembershipData.accepted,
role: mockMembershipData.role,
},
});
expect(membershipCache.revalidate).toHaveBeenCalledWith({
userId: mockUserId,
organizationId: mockOrgId,
});
});
test("returns existing membership if role matches", async () => {
const existingMembership = {
organizationId: mockOrgId,
userId: mockUserId,
accepted: true,
role: "member",
} as TMembership;
vi.mocked(prisma.membership.findUnique).mockResolvedValue(existingMembership as any);
const result = await createMembership(mockOrgId, mockUserId, mockMembershipData);
expect(result).toEqual(existingMembership);
expect(prisma.membership.create).not.toHaveBeenCalled();
expect(prisma.membership.update).not.toHaveBeenCalled();
});
test("updates existing membership if role differs", async () => {
const existingMembership = {
organizationId: mockOrgId,
userId: mockUserId,
accepted: true,
role: "member",
} as TMembership;
const updatedMembership = {
...existingMembership,
role: "owner",
} as TMembership;
vi.mocked(prisma.membership.findUnique).mockResolvedValue(existingMembership as any);
vi.mocked(prisma.membership.update).mockResolvedValue(updatedMembership as any);
const result = await createMembership(mockOrgId, mockUserId, { ...mockMembershipData, role: "owner" });
expect(result).toEqual(updatedMembership);
expect(prisma.membership.update).toHaveBeenCalledWith({
where: {
userId_organizationId: {
userId: mockUserId,
organizationId: mockOrgId,
},
},
data: {
accepted: mockMembershipData.accepted,
role: "owner",
},
});
expect(membershipCache.revalidate).toHaveBeenCalledWith({
userId: mockUserId,
organizationId: mockOrgId,
});
});
test("throws DatabaseError on Prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.membership.findUnique).mockRejectedValue(prismaError);
await expect(createMembership(mockOrgId, mockUserId, mockMembershipData)).rejects.toThrow(
DatabaseError
);
});
});
});

View File

@@ -0,0 +1,59 @@
import { describe, expect, test } from "vitest";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { getAccessFlags } from "./utils";
describe("getAccessFlags", () => {
test("should return correct flags for owner role", () => {
const role: TOrganizationRole = "owner";
const flags = getAccessFlags(role);
expect(flags).toEqual({
isManager: false,
isOwner: true,
isBilling: false,
isMember: false,
});
});
test("should return correct flags for manager role", () => {
const role: TOrganizationRole = "manager";
const flags = getAccessFlags(role);
expect(flags).toEqual({
isManager: true,
isOwner: false,
isBilling: false,
isMember: false,
});
});
test("should return correct flags for billing role", () => {
const role: TOrganizationRole = "billing";
const flags = getAccessFlags(role);
expect(flags).toEqual({
isManager: false,
isOwner: false,
isBilling: true,
isMember: false,
});
});
test("should return correct flags for member role", () => {
const role: TOrganizationRole = "member";
const flags = getAccessFlags(role);
expect(flags).toEqual({
isManager: false,
isOwner: false,
isBilling: false,
isMember: true,
});
});
test("should return all flags as false when role is undefined", () => {
const flags = getAccessFlags(undefined);
expect(flags).toEqual({
isManager: false,
isOwner: false,
isBilling: false,
isMember: false,
});
});
});

View File

@@ -0,0 +1,129 @@
import { describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { getMembershipByUserIdOrganizationId } from "../membership/service";
import { getAccessFlags } from "../membership/utils";
import { canUserAccessOrganization, verifyUserRoleAccess } from "./auth";
import { getOrganizationsByUserId } from "./service";
vi.mock("./service", () => ({
getOrganizationsByUserId: vi.fn(),
}));
vi.mock("../membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("../membership/utils", () => ({
getAccessFlags: vi.fn(),
}));
describe("auth", () => {
describe("canUserAccessOrganization", () => {
test("returns true when user has access to organization", async () => {
const mockOrganizations: TOrganization[] = [
{
id: "org1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Org 1",
billing: {
stripeCustomerId: null,
plan: "free",
period: "monthly",
limits: {
projects: 3,
monthly: {
responses: 1500,
miu: 2000,
},
},
periodStart: new Date(),
},
isAIEnabled: false,
},
];
vi.mocked(getOrganizationsByUserId).mockResolvedValue(mockOrganizations);
const result = await canUserAccessOrganization("user1", "org1");
expect(result).toBe(true);
});
});
describe("verifyUserRoleAccess", () => {
test("returns all access for owner role", async () => {
const mockMembership: TMembership = {
organizationId: "org1",
userId: "user1",
accepted: true,
role: "owner",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: true,
isManager: false,
isBilling: false,
isMember: false,
});
const result = await verifyUserRoleAccess("org1", "user1");
expect(result).toEqual({
hasCreateOrUpdateAccess: true,
hasDeleteAccess: true,
hasCreateOrUpdateMembersAccess: true,
hasDeleteMembersAccess: true,
hasBillingAccess: true,
});
});
test("returns limited access for manager role", async () => {
const mockMembership: TMembership = {
organizationId: "org1",
userId: "user1",
accepted: true,
role: "manager",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: false,
isManager: true,
isBilling: false,
isMember: false,
});
const result = await verifyUserRoleAccess("org1", "user1");
expect(result).toEqual({
hasCreateOrUpdateAccess: false,
hasDeleteAccess: false,
hasCreateOrUpdateMembersAccess: true,
hasDeleteMembersAccess: true,
hasBillingAccess: true,
});
});
test("returns no access for member role", async () => {
const mockMembership: TMembership = {
organizationId: "org1",
userId: "user1",
accepted: true,
role: "member",
};
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: false,
isManager: false,
isBilling: false,
isMember: true,
});
const result = await verifyUserRoleAccess("org1", "user1");
expect(result).toEqual({
hasCreateOrUpdateAccess: false,
hasDeleteAccess: false,
hasCreateOrUpdateMembersAccess: false,
hasDeleteMembersAccess: false,
hasBillingAccess: false,
});
});
});
});

View File

@@ -0,0 +1,273 @@
import { BILLING_LIMITS, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { organizationCache } from "./cache";
import { createOrganization, getOrganization, getOrganizationsByUserId, updateOrganization } from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findUnique: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("./cache", () => ({
organizationCache: {
tag: {
byId: vi.fn(),
byUserId: vi.fn(),
},
revalidate: vi.fn(),
},
}));
describe("Organization Service", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("getOrganization", () => {
test("should return organization when found", async () => {
const mockOrganization = {
id: "org1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
},
},
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly" as const,
},
isAIEnabled: false,
whitelabel: false,
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization);
const result = await getOrganization("org1");
expect(result).toEqual(mockOrganization);
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "org1" },
select: expect.any(Object),
});
});
test("should return null when organization not found", async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
const result = await getOrganization("nonexistent");
expect(result).toBeNull();
});
test("should throw DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.organization.findUnique).mockRejectedValue(prismaError);
await expect(getOrganization("org1")).rejects.toThrow(DatabaseError);
});
});
describe("getOrganizationsByUserId", () => {
test("should return organizations for user", async () => {
const mockOrganizations = [
{
id: "org1",
name: "Test Org 1",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
},
},
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly" as const,
},
isAIEnabled: false,
whitelabel: false,
},
];
vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations);
const result = await getOrganizationsByUserId("user1");
expect(result).toEqual(mockOrganizations);
expect(prisma.organization.findMany).toHaveBeenCalledWith({
where: {
memberships: {
some: {
userId: "user1",
},
},
},
select: expect.any(Object),
});
});
test("should throw DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.organization.findMany).mockRejectedValue(prismaError);
await expect(getOrganizationsByUserId("user1")).rejects.toThrow(DatabaseError);
});
});
describe("createOrganization", () => {
test("should create organization with default billing settings", async () => {
const mockOrganization = {
id: "org1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
},
},
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly" as const,
},
isAIEnabled: false,
whitelabel: false,
};
vi.mocked(prisma.organization.create).mockResolvedValue(mockOrganization);
const result = await createOrganization({ name: "Test Org" });
expect(result).toEqual(mockOrganization);
expect(prisma.organization.create).toHaveBeenCalledWith({
data: {
name: "Test Org",
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
},
},
stripeCustomerId: null,
periodStart: expect.any(Date),
period: "monthly",
},
},
select: expect.any(Object),
});
expect(organizationCache.revalidate).toHaveBeenCalledWith({
id: mockOrganization.id,
count: true,
});
});
test("should throw DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.organization.create).mockRejectedValue(prismaError);
await expect(createOrganization({ name: "Test Org" })).rejects.toThrow(DatabaseError);
});
});
describe("updateOrganization", () => {
test("should update organization and revalidate cache", async () => {
const mockOrganization = {
id: "org1",
name: "Updated Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
},
},
stripeCustomerId: null,
periodStart: new Date(),
period: "monthly" as const,
},
isAIEnabled: false,
whitelabel: false,
memberships: [{ userId: "user1" }, { userId: "user2" }],
projects: [
{
environments: [{ id: "env1" }, { id: "env2" }],
},
],
};
vi.mocked(prisma.organization.update).mockResolvedValue(mockOrganization);
const result = await updateOrganization("org1", { name: "Updated Org" });
expect(result).toEqual({
id: "org1",
name: "Updated Org",
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
billing: {
plan: PROJECT_FEATURE_KEYS.FREE,
limits: {
projects: BILLING_LIMITS.FREE.PROJECTS,
monthly: {
responses: BILLING_LIMITS.FREE.RESPONSES,
miu: BILLING_LIMITS.FREE.MIU,
},
},
stripeCustomerId: null,
periodStart: expect.any(Date),
period: "monthly",
},
isAIEnabled: false,
whitelabel: false,
});
expect(prisma.organization.update).toHaveBeenCalledWith({
where: { id: "org1" },
data: { name: "Updated Org" },
select: expect.any(Object),
});
expect(organizationCache.revalidate).toHaveBeenCalledWith({
id: "org1",
});
});
});
});

View File

@@ -0,0 +1,421 @@
import { createId } from "@paralleldrive/cuid2";
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "../constants";
import { getProject, getProjectByEnvironmentId, getProjects, getUserProjects } from "./service";
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
membership: {
findFirst: vi.fn(),
},
},
}));
describe("Project Service", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("getProject should return a project when it exists", async () => {
const mockProject = {
id: createId(),
name: "Test Project",
organizationId: createId(),
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
logo: null,
};
vi.mocked(prisma.project.findUnique).mockResolvedValue(mockProject);
const result = await getProject(mockProject.id);
expect(result).toEqual(mockProject);
expect(prisma.project.findUnique).toHaveBeenCalledWith({
where: {
id: mockProject.id,
},
select: expect.any(Object),
});
});
test("getProject should return null when project does not exist", async () => {
vi.mocked(prisma.project.findUnique).mockResolvedValue(null);
const result = await getProject(createId());
expect(result).toBeNull();
});
test("getProject should throw DatabaseError when prisma throws", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.findUnique).mockRejectedValue(prismaError);
await expect(getProject(createId())).rejects.toThrow(DatabaseError);
});
test("getProjectByEnvironmentId should return a project when it exists", async () => {
const mockProject = {
id: createId(),
name: "Test Project",
organizationId: createId(),
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
logo: null,
};
vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject);
const result = await getProjectByEnvironmentId(createId());
expect(result).toEqual(mockProject);
expect(prisma.project.findFirst).toHaveBeenCalledWith({
where: {
environments: {
some: {
id: expect.any(String),
},
},
},
select: expect.any(Object),
});
});
test("getProjectByEnvironmentId should return null when project does not exist", async () => {
vi.mocked(prisma.project.findFirst).mockResolvedValue(null);
const result = await getProjectByEnvironmentId(createId());
expect(result).toBeNull();
});
test("getProjectByEnvironmentId should throw DatabaseError when prisma throws", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError);
await expect(getProjectByEnvironmentId(createId())).rejects.toThrow(DatabaseError);
});
test("getUserProjects should return projects for admin user", async () => {
const userId = createId();
const organizationId = createId();
const mockProjects = [
{
id: createId(),
name: "Test Project 1",
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
logo: null,
},
{
id: createId(),
name: "Test Project 2",
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
logo: null,
},
];
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
id: createId(),
userId,
organizationId,
role: "admin",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
const result = await getUserProjects(userId, organizationId);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId,
},
select: expect.any(Object),
take: undefined,
skip: undefined,
});
});
test("getUserProjects should return projects for member user", async () => {
const userId = createId();
const organizationId = createId();
const mockProjects = [
{
id: createId(),
name: "Test Project 1",
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
logo: null,
},
];
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
id: createId(),
userId,
organizationId,
role: "member",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
const result = await getUserProjects(userId, organizationId);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId,
projectTeams: {
some: {
team: {
teamUsers: {
some: {
userId,
},
},
},
},
},
},
select: expect.any(Object),
take: undefined,
skip: undefined,
});
});
test("getUserProjects should throw ValidationError when user is not a member of organization", async () => {
const userId = createId();
const organizationId = createId();
vi.mocked(prisma.membership.findFirst).mockResolvedValue(null);
await expect(getUserProjects(userId, organizationId)).rejects.toThrow(ValidationError);
});
test("getUserProjects should handle pagination", async () => {
const userId = createId();
const organizationId = createId();
const mockProjects = [
{
id: createId(),
name: "Test Project 1",
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
logo: null,
},
];
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
id: createId(),
userId,
organizationId,
role: "admin",
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
const page = 2;
const result = await getUserProjects(userId, organizationId, page);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId,
},
select: expect.any(Object),
take: ITEMS_PER_PAGE,
skip: ITEMS_PER_PAGE * (page - 1),
});
});
test("getProjects should return all projects for an organization", async () => {
const organizationId = createId();
const mockProjects = [
{
id: createId(),
name: "Test Project 1",
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
logo: null,
},
{
id: createId(),
name: "Test Project 2",
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
logo: null,
},
];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
const result = await getProjects(organizationId);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId,
},
select: expect.any(Object),
take: undefined,
skip: undefined,
});
});
test("getProjects should handle pagination", async () => {
const organizationId = createId();
const mockProjects = [
{
id: createId(),
name: "Test Project 1",
organizationId,
createdAt: new Date(),
updatedAt: new Date(),
languages: ["en"],
recontactDays: 0,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {},
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
environments: [],
styling: {},
logo: null,
},
];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
const page = 2;
const result = await getProjects(organizationId, page);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId,
},
select: expect.any(Object),
take: ITEMS_PER_PAGE,
skip: ITEMS_PER_PAGE * (page - 1),
});
});
test("getProjects should throw DatabaseError when prisma throws", async () => {
const organizationId = createId();
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
await expect(getProjects(organizationId)).rejects.toThrow(DatabaseError);
});
});

View File

@@ -4,8 +4,7 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import type { TProject } from "@formbricks/types/project";
import { ITEMS_PER_PAGE } from "../constants";

View File

@@ -1,36 +0,0 @@
import "server-only";
import { cache } from "@/lib/cache";
import { ZId } from "@formbricks/types/common";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { getSurvey } from "../survey/service";
import { validateInputs } from "../utils/validate";
import { responseCache } from "./cache";
import { getResponse } from "./service";
export const canUserAccessResponse = (userId: string, responseId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [responseId, ZId]);
if (!userId) return false;
try {
const response = await getResponse(responseId);
if (!response) return false;
const survey = await getSurvey(response.surveyId);
if (!survey) return false;
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, survey.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
} catch (error) {
throw error;
}
},
[`canUserAccessResponse-${userId}-${responseId}`],
{
tags: [responseCache.tag.byId(responseId)],
}
)();

View File

@@ -22,7 +22,7 @@ import { responseNoteCache } from "../responseNote/cache";
import { getResponseNotes } from "../responseNote/service";
import { deleteFile, putFile } from "../storage/service";
import { getSurvey } from "../survey/service";
import { convertToCsv, convertToXlsxBuffer } from "../utils/fileConversion";
import { convertToCsv, convertToXlsxBuffer } from "../utils/file-conversion";
import { validateInputs } from "../utils/validate";
import { responseCache } from "./cache";
import {

View File

@@ -24,7 +24,7 @@ import {
mockContactAttributeKey,
mockOrganizationOutput,
mockSurveyOutput,
} from "../../survey/tests/__mock__/survey.mock";
} from "../../survey/__mock__/survey.mock";
import {
deleteResponse,
getResponse,

View File

@@ -0,0 +1,557 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
buildWhereClause,
calculateTtcTotal,
extracMetadataKeys,
extractSurveyDetails,
generateAllPermutationsOfSubsets,
getResponseContactAttributes,
getResponseHiddenFields,
getResponseMeta,
getResponsesFileName,
getResponsesJson,
} from "./utils";
describe("Response Utils", () => {
describe("calculateTtcTotal", () => {
test("should calculate total time correctly", () => {
const ttc = {
question1: 10,
question2: 20,
question3: 30,
};
const result = calculateTtcTotal(ttc);
expect(result._total).toBe(60);
});
test("should handle empty ttc object", () => {
const ttc = {};
const result = calculateTtcTotal(ttc);
expect(result._total).toBe(0);
});
});
describe("buildWhereClause", () => {
const mockSurvey: Partial<TSurvey> = {
id: "survey1",
name: "Test Survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{ id: "1", label: { default: "Option 1" } },
{ id: "other", label: { default: "Other" } },
],
shuffleOption: "none",
isDraft: false,
},
],
type: "app",
hiddenFields: { enabled: true, fieldIds: [] },
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
createdBy: "user1",
status: "draft",
};
test("should build where clause with finished filter", () => {
const filterCriteria = { finished: true };
const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria);
expect(result.AND).toContainEqual({ finished: true });
});
test("should build where clause with date range", () => {
const filterCriteria = {
createdAt: {
min: new Date("2024-01-01"),
max: new Date("2024-12-31"),
},
};
const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria);
expect(result.AND).toContainEqual({
createdAt: {
gte: new Date("2024-01-01"),
lte: new Date("2024-12-31"),
},
});
});
test("should build where clause with tags", () => {
const filterCriteria = {
tags: {
applied: ["tag1", "tag2"],
notApplied: ["tag3"],
},
};
const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria);
expect(result.AND).toHaveLength(1);
});
test("should build where clause with contact attributes", () => {
const filterCriteria = {
contactAttributes: {
email: { op: "equals" as const, value: "test@example.com" },
},
};
const result = buildWhereClause(mockSurvey as TSurvey, filterCriteria);
expect(result.AND).toHaveLength(1);
});
});
describe("buildWhereClause others & meta filters", () => {
const baseSurvey: Partial<TSurvey> = {
id: "s1",
name: "Survey",
questions: [],
type: "app",
hiddenFields: { enabled: false, fieldIds: [] },
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "e1",
createdBy: "u1",
status: "inProgress",
};
test("others: equals & notEquals", () => {
const criteria = {
others: {
Language: { op: "equals" as const, value: "en" },
Region: { op: "notEquals" as const, value: "APAC" },
},
};
const result = buildWhereClause(baseSurvey as TSurvey, criteria);
expect(result.AND).toEqual([
{
AND: [{ language: "en" }, { region: { not: "APAC" } }],
},
]);
});
test("meta: equals & notEquals map to userAgent paths", () => {
const criteria = {
meta: {
browser: { op: "equals" as const, value: "Chrome" },
os: { op: "notEquals" as const, value: "Windows" },
},
};
const result = buildWhereClause(baseSurvey as TSurvey, criteria);
expect(result.AND).toEqual([
{
AND: [
{ meta: { path: ["userAgent", "browser"], equals: "Chrome" } },
{ meta: { path: ["userAgent", "os"], not: "Windows" } },
],
},
]);
});
});
describe("buildWhereClause datafield filter operations", () => {
const textSurvey: Partial<TSurvey> = {
id: "s2",
name: "TextSurvey",
questions: [
{
id: "qText",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Text Q" },
required: false,
isDraft: false,
charLimit: {},
inputType: "text",
},
{
id: "qNum",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Num Q" },
required: false,
isDraft: false,
charLimit: {},
inputType: "number",
},
],
type: "app",
hiddenFields: { enabled: false, fieldIds: [] },
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "e2",
createdBy: "u2",
status: "inProgress",
};
const ops: Array<[keyof TSurveyQuestionTypeEnum | string, any, any]> = [
["submitted", { op: "submitted" }, { path: ["qText"], not: Prisma.DbNull }],
["filledOut", { op: "filledOut" }, { path: ["qText"], not: [] }],
["skipped", { op: "skipped" }, "OR"],
["equals", { op: "equals", value: "foo" }, { path: ["qText"], equals: "foo" }],
["notEquals", { op: "notEquals", value: "bar" }, "NOT"],
["lessThan", { op: "lessThan", value: 5 }, { path: ["qNum"], lt: 5 }],
["lessEqual", { op: "lessEqual", value: 10 }, { path: ["qNum"], lte: 10 }],
["greaterThan", { op: "greaterThan", value: 1 }, { path: ["qNum"], gt: 1 }],
["greaterEqual", { op: "greaterEqual", value: 2 }, { path: ["qNum"], gte: 2 }],
[
"includesAll",
{ op: "includesAll", value: ["a", "b"] },
{ path: ["qText"], array_contains: ["a", "b"] },
],
];
ops.forEach(([name, filter, expected]) => {
test(name as string, () => {
const result = buildWhereClause(textSurvey as TSurvey, {
data: {
[["submitted", "filledOut", "equals", "includesAll"].includes(name as string) ? "qText" : "qNum"]:
filter,
},
});
// for OR/NOT cases we just ensure the operator key exists
if (expected === "OR" || expected === "NOT") {
expect(JSON.stringify(result)).toMatch(
new RegExp(name === "skipped" ? `"OR":\\s*\\[` : `"not":"${filter.value}"`)
);
} else {
expect(result.AND).toEqual([
{
AND: [{ data: expected }],
},
]);
}
});
});
test("uploaded & notUploaded", () => {
const res1 = buildWhereClause(textSurvey as TSurvey, { data: { qText: { op: "uploaded" } } });
expect(res1.AND).toContainEqual({
AND: [{ data: { path: ["qText"], not: "skipped" } }],
});
const res2 = buildWhereClause(textSurvey as TSurvey, { data: { qText: { op: "notUploaded" } } });
expect(JSON.stringify(res2)).toMatch(/"equals":"skipped"/);
expect(JSON.stringify(res2)).toMatch(/"equals":{}/);
});
test("clicked, accepted & booked", () => {
["clicked", "accepted", "booked"].forEach((status) => {
const key = status as "clicked" | "accepted" | "booked";
const res = buildWhereClause(textSurvey as TSurvey, { data: { qText: { op: key } } });
expect(res.AND).toEqual([{ AND: [{ data: { path: ["qText"], equals: status } }] }]);
});
});
test("matrix", () => {
const matrixSurvey: Partial<TSurvey> = {
id: "s3",
name: "MatrixSurvey",
questions: [
{
id: "qM",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
required: false,
rows: [{ default: "R1" }],
columns: [{ default: "C1" }],
shuffleOption: "none",
isDraft: false,
},
],
type: "app",
hiddenFields: { enabled: false, fieldIds: [] },
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "e3",
createdBy: "u3",
status: "inProgress",
};
const res = buildWhereClause(matrixSurvey as TSurvey, {
data: { qM: { op: "matrix", value: { R1: "foo" } } },
});
expect(res.AND).toEqual([
{
AND: [
{
data: { path: ["qM", "R1"], equals: "foo" },
},
],
},
]);
});
});
describe("getResponsesFileName", () => {
test("should generate correct filename", () => {
const surveyName = "Test Survey";
const extension = "csv";
const result = getResponsesFileName(surveyName, extension);
expect(result).toContain("export-test_survey-");
});
});
describe("extracMetadataKeys", () => {
test("should extract metadata keys correctly", () => {
const meta = {
userAgent: { browser: "Chrome", os: "Windows", device: "Desktop" },
country: "US",
source: "direct",
};
const result = extracMetadataKeys(meta);
expect(result).toContain("userAgent - browser");
expect(result).toContain("userAgent - os");
expect(result).toContain("userAgent - device");
expect(result).toContain("country");
expect(result).toContain("source");
});
test("should handle empty metadata", () => {
const result = extracMetadataKeys({});
expect(result).toEqual([]);
});
});
describe("extractSurveyDetails", () => {
const mockSurvey: Partial<TSurvey> = {
id: "survey1",
name: "Test Survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{ id: "1", label: { default: "Option 1" } },
{ id: "2", label: { default: "Option 2" } },
],
shuffleOption: "none",
isDraft: false,
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix Question" },
required: true,
rows: [{ default: "Row 1" }, { default: "Row 2" }],
columns: [{ default: "Column 1" }, { default: "Column 2" }],
shuffleOption: "none",
isDraft: false,
},
],
type: "app",
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
createdBy: "user1",
status: "draft",
};
const mockResponses: Partial<TResponse>[] = [
{
id: "response1",
surveyId: "survey1",
data: {},
meta: { userAgent: { browser: "Chrome" } },
contactAttributes: { email: "test@example.com" },
finished: true,
createdAt: new Date(),
updatedAt: new Date(),
notes: [],
tags: [],
},
];
test("should extract survey details correctly", () => {
const result = extractSurveyDetails(mockSurvey as TSurvey, mockResponses as TResponse[]);
expect(result.metaDataFields).toContain("userAgent - browser");
expect(result.questions).toHaveLength(2); // 1 regular question + 2 matrix rows
expect(result.hiddenFields).toContain("hidden1");
expect(result.userAttributes).toContain("email");
});
});
describe("getResponsesJson", () => {
const mockSurvey: Partial<TSurvey> = {
id: "survey1",
name: "Test Survey",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
headline: { default: "Question 1" },
required: true,
choices: [
{ id: "1", label: { default: "Option 1" } },
{ id: "2", label: { default: "Option 2" } },
],
shuffleOption: "none",
isDraft: false,
},
],
type: "app",
hiddenFields: { enabled: true, fieldIds: [] },
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
createdBy: "user1",
status: "draft",
};
const mockResponses: Partial<TResponse>[] = [
{
id: "response1",
surveyId: "survey1",
data: { q1: "answer1" },
meta: { userAgent: { browser: "Chrome" } },
contactAttributes: { email: "test@example.com" },
finished: true,
createdAt: new Date(),
updatedAt: new Date(),
notes: [],
tags: [],
},
];
test("should generate correct JSON data", () => {
const questionsHeadlines = [["1. Question 1"]];
const userAttributes = ["email"];
const hiddenFields: string[] = [];
const result = getResponsesJson(
mockSurvey as TSurvey,
mockResponses as TResponse[],
questionsHeadlines,
userAttributes,
hiddenFields
);
expect(result[0]["Response ID"]).toBe("response1");
expect(result[0]["userAgent - browser"]).toBe("Chrome");
expect(result[0]["1. Question 1"]).toBe("answer1");
expect(result[0]["email"]).toBe("test@example.com");
});
});
describe("getResponseContactAttributes", () => {
test("should extract contact attributes correctly", () => {
const responses = [
{
contactAttributes: { email: "test1@example.com", name: "Test 1" },
data: {},
meta: {},
},
{
contactAttributes: { email: "test2@example.com", name: "Test 2" },
data: {},
meta: {},
},
];
const result = getResponseContactAttributes(
responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]
);
expect(result.email).toContain("test1@example.com");
expect(result.email).toContain("test2@example.com");
expect(result.name).toContain("Test 1");
expect(result.name).toContain("Test 2");
});
test("should handle empty responses", () => {
const result = getResponseContactAttributes([]);
expect(result).toEqual({});
});
});
describe("getResponseMeta", () => {
test("should extract meta data correctly", () => {
const responses = [
{
contactAttributes: {},
data: {},
meta: {
userAgent: { browser: "Chrome", os: "Windows" },
country: "US",
},
},
{
contactAttributes: {},
data: {},
meta: {
userAgent: { browser: "Firefox", os: "MacOS" },
country: "UK",
},
},
];
const result = getResponseMeta(responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]);
expect(result.browser).toContain("Chrome");
expect(result.browser).toContain("Firefox");
expect(result.os).toContain("Windows");
expect(result.os).toContain("MacOS");
});
test("should handle empty responses", () => {
const result = getResponseMeta([]);
expect(result).toEqual({});
});
});
describe("getResponseHiddenFields", () => {
const mockSurvey: Partial<TSurvey> = {
id: "survey1",
name: "Test Survey",
questions: [],
type: "app",
hiddenFields: { enabled: true, fieldIds: ["hidden1", "hidden2"] },
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
createdBy: "user1",
status: "draft",
};
test("should extract hidden fields correctly", () => {
const responses = [
{
contactAttributes: {},
data: { hidden1: "value1", hidden2: "value2" },
meta: {},
},
{
contactAttributes: {},
data: { hidden1: "value3", hidden2: "value4" },
meta: {},
},
];
const result = getResponseHiddenFields(
mockSurvey as TSurvey,
responses as Pick<TResponse, "contactAttributes" | "data" | "meta">[]
);
expect(result.hidden1).toContain("value1");
expect(result.hidden1).toContain("value3");
expect(result.hidden2).toContain("value2");
expect(result.hidden2).toContain("value4");
});
test("should handle empty responses", () => {
const result = getResponseHiddenFields(mockSurvey as TSurvey, []);
expect(result).toEqual({
hidden1: [],
hidden2: [],
});
});
});
describe("generateAllPermutationsOfSubsets", () => {
test("with empty array returns empty", () => {
expect(generateAllPermutationsOfSubsets([])).toEqual([]);
});
test("with two elements returns 4 permutations", () => {
const out = generateAllPermutationsOfSubsets(["x", "y"]);
expect(out).toEqual(expect.arrayContaining([["x"], ["y"], ["x", "y"], ["y", "x"]]));
expect(out).toHaveLength(4);
});
});
});

View File

@@ -683,7 +683,7 @@ export const getResponseHiddenFields = (
}
};
const generateAllPermutationsOfSubsets = (array: string[]): string[][] => {
export const generateAllPermutationsOfSubsets = (array: string[]): string[][] => {
const subsets: string[][] = [];
// Helper function to generate permutations of an array

View File

@@ -1,66 +0,0 @@
import { cache } from "@/lib/cache";
import { ZId } from "@formbricks/types/common";
import { canUserAccessResponse } from "../response/auth";
import { getResponse } from "../response/service";
import { validateInputs } from "../utils/validate";
import { responseNoteCache } from "./cache";
import { getResponseNote } from "./service";
export const canUserModifyResponseNote = async (userId: string, responseNoteId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [responseNoteId, ZId]);
if (!userId || !responseNoteId) return false;
try {
const responseNote = await getResponseNote(responseNoteId);
if (!responseNote) return false;
return responseNote.user.id === userId;
} catch (error) {
throw error;
}
},
[`canUserModifyResponseNote-${userId}-${responseNoteId}`],
{
tags: [responseNoteCache.tag.byId(responseNoteId)],
}
)();
export const canUserResolveResponseNote = async (
userId: string,
responseId: string,
responseNoteId: string
): Promise<boolean> =>
cache(
async () => {
validateInputs([userId, ZId], [responseNoteId, ZId]);
if (!userId || !responseId || !responseNoteId) return false;
try {
const response = await getResponse(responseId);
let noteExistsOnResponse = false;
response?.notes.forEach((note) => {
if (note.id === responseNoteId) {
noteExistsOnResponse = true;
}
});
if (!noteExistsOnResponse) return false;
const canAccessResponse = await canUserAccessResponse(userId, responseId);
return canAccessResponse;
} catch (error) {
throw error;
}
},
[`canUserResolveResponseNote-${userId}-${responseNoteId}`],
{
tags: [responseNoteCache.tag.byId(responseNoteId)],
}
)();

View File

@@ -4,8 +4,7 @@ import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/common";
import { ZId, ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponseNote } from "@formbricks/types/responses";
import { responseCache } from "../response/cache";

View File

@@ -0,0 +1,353 @@
import { describe, expect, test, vi } from "vitest";
import { TSurveyQuestionType, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { convertResponseValue, getQuestionResponseMapping, processResponseData } from "./responses";
// Mock the recall and i18n utils
vi.mock("@/lib/utils/recall", () => ({
parseRecallInfo: vi.fn((text) => text),
}));
vi.mock("./i18n/utils", () => ({
getLocalizedValue: vi.fn((obj, lang) => obj[lang] || obj.default),
}));
describe("Response Processing", () => {
describe("processResponseData", () => {
test("should handle string input", () => {
expect(processResponseData("test")).toBe("test");
});
test("should handle number input", () => {
expect(processResponseData(42)).toBe("42");
});
test("should handle array input", () => {
expect(processResponseData(["a", "b", "c"])).toBe("a; b; c");
});
test("should filter out empty values from array", () => {
const input = ["a", "", "c"];
expect(processResponseData(input)).toBe("a; c");
});
test("should handle object input", () => {
const input = { key1: "value1", key2: "value2" };
expect(processResponseData(input)).toBe("key1: value1\nkey2: value2");
});
test("should filter out empty values from object", () => {
const input = { key1: "value1", key2: "", key3: "value3" };
expect(processResponseData(input)).toBe("key1: value1\nkey3: value3");
});
test("should return empty string for unsupported types", () => {
expect(processResponseData(undefined as any)).toBe("");
});
});
describe("convertResponseValue", () => {
const mockOpenTextQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText as const,
headline: { default: "Test Question" },
required: true,
inputType: "text" as const,
longAnswer: false,
charLimit: { enabled: false },
};
const mockRankingQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.Ranking as const,
headline: { default: "Test Question" },
required: true,
choices: [
{ id: "1", label: { default: "Choice 1" } },
{ id: "2", label: { default: "Choice 2" } },
],
shuffleOption: "none" as const,
};
const mockFileUploadQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.FileUpload as const,
headline: { default: "Test Question" },
required: true,
allowMultipleFiles: true,
};
const mockPictureSelectionQuestion = {
id: "q1",
type: TSurveyQuestionTypeEnum.PictureSelection as const,
headline: { default: "Test Question" },
required: true,
allowMulti: false,
choices: [
{ id: "1", imageUrl: "image1.jpg", label: { default: "Choice 1" } },
{ id: "2", imageUrl: "image2.jpg", label: { default: "Choice 2" } },
],
};
test("should handle ranking type with string input", () => {
expect(convertResponseValue("answer", mockRankingQuestion)).toEqual(["answer"]);
});
test("should handle ranking type with array input", () => {
expect(convertResponseValue(["answer1", "answer2"], mockRankingQuestion)).toEqual([
"answer1",
"answer2",
]);
});
test("should handle fileUpload type with string input", () => {
expect(convertResponseValue("file.jpg", mockFileUploadQuestion)).toEqual(["file.jpg"]);
});
test("should handle fileUpload type with array input", () => {
expect(convertResponseValue(["file1.jpg", "file2.jpg"], mockFileUploadQuestion)).toEqual([
"file1.jpg",
"file2.jpg",
]);
});
test("should handle pictureSelection type with string input", () => {
expect(convertResponseValue("1", mockPictureSelectionQuestion)).toEqual(["image1.jpg"]);
});
test("should handle pictureSelection type with array input", () => {
expect(convertResponseValue(["1", "2"], mockPictureSelectionQuestion)).toEqual([
"image1.jpg",
"image2.jpg",
]);
});
test("should handle pictureSelection type with invalid choice", () => {
expect(convertResponseValue("invalid", mockPictureSelectionQuestion)).toEqual([]);
});
test("should handle default case with string input", () => {
expect(convertResponseValue("answer", mockOpenTextQuestion)).toBe("answer");
});
test("should handle default case with number input", () => {
expect(convertResponseValue(42, mockOpenTextQuestion)).toBe("42");
});
test("should handle default case with array input", () => {
expect(convertResponseValue(["a", "b", "c"], mockOpenTextQuestion)).toBe("a; b; c");
});
test("should handle default case with object input", () => {
const input = { key1: "value1", key2: "value2" };
expect(convertResponseValue(input, mockOpenTextQuestion)).toBe("key1: value1\nkey2: value2");
});
});
describe("getQuestionResponseMapping", () => {
const mockSurvey = {
id: "survey1",
type: "link" as const,
status: "inProgress" as const,
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
environmentId: "env1",
createdBy: null,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText as const,
headline: { default: "Question 1" },
required: true,
inputType: "text" as const,
longAnswer: false,
charLimit: { enabled: false },
},
{
id: "q2",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti as const,
headline: { default: "Question 2" },
required: true,
choices: [
{ id: "1", label: { default: "Option 1" } },
{ id: "2", label: { default: "Option 2" } },
],
shuffleOption: "none" as const,
},
],
hiddenFields: {
enabled: false,
fieldIds: [],
},
displayOption: "displayOnce" as const,
delay: 0,
languages: [
{
language: {
id: "lang1",
code: "default",
createdAt: new Date(),
updatedAt: new Date(),
alias: null,
projectId: "proj1",
},
default: true,
enabled: true,
},
],
variables: [],
endings: [],
displayLimit: null,
autoClose: null,
autoComplete: null,
recontactDays: null,
runOnDate: null,
closeOnDate: null,
welcomeCard: {
enabled: false,
timeToFinish: false,
showResponseCount: false,
},
showLanguageSwitch: false,
isBackButtonHidden: false,
isVerifyEmailEnabled: false,
isSingleResponsePerEmailEnabled: false,
displayPercentage: 100,
styling: null,
projectOverwrites: null,
verifyEmail: null,
inlineTriggers: [],
pin: null,
triggers: [],
followUps: [],
segment: null,
recaptcha: null,
surveyClosedMessage: null,
singleUse: {
enabled: false,
isEncrypted: false,
},
resultShareKey: null,
};
const mockResponse = {
id: "response1",
surveyId: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
finished: true,
data: {
q1: "Answer 1",
q2: ["Option 1", "Option 2"],
},
language: "default",
meta: {
url: undefined,
country: undefined,
action: undefined,
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
ttc: {},
variables: {},
contact: null,
contactAttributes: {},
singleUseId: null,
};
test("should map questions to responses correctly", () => {
const mapping = getQuestionResponseMapping(mockSurvey, mockResponse);
expect(mapping).toHaveLength(2);
expect(mapping[0]).toEqual({
question: "Question 1",
response: "Answer 1",
type: TSurveyQuestionTypeEnum.OpenText,
});
expect(mapping[1]).toEqual({
question: "Question 2",
response: "Option 1; Option 2",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
});
});
test("should handle missing response data", () => {
const response = {
id: "response1",
surveyId: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
finished: true,
data: {},
language: "default",
meta: {
url: undefined,
country: undefined,
action: undefined,
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
ttc: {},
variables: {},
contact: null,
contactAttributes: {},
singleUseId: null,
};
const mapping = getQuestionResponseMapping(mockSurvey, response);
expect(mapping).toHaveLength(2);
expect(mapping[0].response).toBe("");
expect(mapping[1].response).toBe("");
});
test("should handle different language", () => {
const survey = {
...mockSurvey,
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText as const,
headline: { default: "Question 1", en: "Question 1 EN" },
required: true,
inputType: "text" as const,
longAnswer: false,
charLimit: { enabled: false },
},
],
};
const response = {
id: "response1",
surveyId: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
finished: true,
data: { q1: "Answer 1" },
language: "en",
meta: {
url: undefined,
country: undefined,
action: undefined,
source: undefined,
userAgent: undefined,
},
notes: [],
tags: [],
person: null,
personAttributes: {},
ttc: {},
variables: {},
contact: null,
contactAttributes: {},
singleUseId: null,
};
const mapping = getQuestionResponseMapping(survey, response);
expect(mapping[0].question).toBe("Question 1 EN");
});
});
});

View File

@@ -0,0 +1,134 @@
import { S3Client } from "@aws-sdk/client-s3";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock AWS SDK
const mockSend = vi.fn();
const mockS3Client = {
send: mockSend,
};
vi.mock("@aws-sdk/client-s3", () => ({
S3Client: vi.fn(() => mockS3Client),
HeadBucketCommand: vi.fn(),
PutObjectCommand: vi.fn(),
DeleteObjectCommand: vi.fn(),
GetObjectCommand: vi.fn(),
}));
// Mock environment variables
vi.mock("../constants", () => ({
S3_ACCESS_KEY: "test-access-key",
S3_SECRET_KEY: "test-secret-key",
S3_REGION: "test-region",
S3_BUCKET_NAME: "test-bucket",
S3_ENDPOINT_URL: "http://test-endpoint",
S3_FORCE_PATH_STYLE: true,
isS3Configured: () => true,
IS_FORMBRICKS_CLOUD: false,
MAX_SIZES: {
standard: 5 * 1024 * 1024,
big: 10 * 1024 * 1024,
},
WEBAPP_URL: "http://test-webapp",
ENCRYPTION_KEY: "test-encryption-key-32-chars-long!!",
UPLOADS_DIR: "/tmp/uploads",
}));
// Mock crypto functions
vi.mock("crypto", () => ({
randomUUID: () => "test-uuid",
}));
describe("Storage Service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("getS3Client", () => {
test("should create and return S3 client instance", async () => {
const { getS3Client } = await import("./service");
const client = getS3Client();
expect(client).toBe(mockS3Client);
expect(S3Client).toHaveBeenCalledWith({
credentials: {
accessKeyId: "test-access-key",
secretAccessKey: "test-secret-key",
},
region: "test-region",
endpoint: "http://test-endpoint",
forcePathStyle: true,
});
});
test("should return existing client instance on subsequent calls", async () => {
vi.resetModules();
const { getS3Client } = await import("./service");
const client1 = getS3Client();
const client2 = getS3Client();
expect(client1).toBe(client2);
expect(S3Client).toHaveBeenCalledTimes(1);
});
});
describe("testS3BucketAccess", () => {
let testS3BucketAccess: any;
beforeEach(async () => {
const serviceModule = await import("./service");
testS3BucketAccess = serviceModule.testS3BucketAccess;
});
test("should return true when bucket access is successful", async () => {
mockSend.mockResolvedValueOnce({});
const result = await testS3BucketAccess();
expect(result).toBe(true);
expect(mockSend).toHaveBeenCalledTimes(1);
});
test("should throw error when bucket access fails", async () => {
const error = new Error("Access denied");
mockSend.mockRejectedValueOnce(error);
await expect(testS3BucketAccess()).rejects.toThrow(
"S3 Bucket Access Test Failed: Error: Access denied"
);
});
});
describe("putFile", () => {
let putFile: any;
beforeEach(async () => {
const serviceModule = await import("./service");
putFile = serviceModule.putFile;
});
test("should successfully upload file to S3", async () => {
const fileName = "test.jpg";
const fileBuffer = Buffer.from("test");
const accessType = "private";
const environmentId = "env123";
mockSend.mockResolvedValueOnce({});
const result = await putFile(fileName, fileBuffer, accessType, environmentId);
expect(result).toEqual({ success: true, message: "File uploaded" });
expect(mockSend).toHaveBeenCalledTimes(1);
});
test("should throw error when S3 upload fails", async () => {
const fileName = "test.jpg";
const fileBuffer = Buffer.from("test");
const accessType = "private";
const environmentId = "env123";
const error = new Error("Upload failed");
mockSend.mockRejectedValueOnce(error);
await expect(putFile(fileName, fileBuffer, accessType, environmentId)).rejects.toThrow("Upload failed");
});
});
});

View File

@@ -0,0 +1,42 @@
import { describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { getFileNameWithIdFromUrl, getOriginalFileNameFromUrl } from "./utils";
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("Storage Utils", () => {
describe("getOriginalFileNameFromUrl", () => {
test("should handle URL without file ID", () => {
const url = "/storage/test-file.pdf";
expect(getOriginalFileNameFromUrl(url)).toBe("test-file.pdf");
});
test("should handle invalid URL", () => {
const url = "invalid-url";
expect(getOriginalFileNameFromUrl(url)).toBeUndefined();
expect(logger.error).toHaveBeenCalled();
});
});
describe("getFileNameWithIdFromUrl", () => {
test("should get full filename with ID from storage URL", () => {
const url = "/storage/test-file.pdf--fid--123";
expect(getFileNameWithIdFromUrl(url)).toBe("test-file.pdf--fid--123");
});
test("should get full filename with ID from external URL", () => {
const url = "https://example.com/path/test-file.pdf--fid--123";
expect(getFileNameWithIdFromUrl(url)).toBe("test-file.pdf--fid--123");
});
test("should handle invalid URL", () => {
const url = "invalid-url";
expect(getFileNameWithIdFromUrl(url)).toBeUndefined();
expect(logger.error).toHaveBeenCalled();
});
});
});

View File

@@ -13,7 +13,7 @@ import {
TSurveyWelcomeCard,
} from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { selectSurvey } from "../../service";
import { selectSurvey } from "../service";
const selectContact = {
id: true,

View File

@@ -1,31 +0,0 @@
import { cache } from "@/lib/cache";
import { ZId } from "@formbricks/types/common";
import { hasUserEnvironmentAccess } from "../environment/auth";
import { validateInputs } from "../utils/validate";
import { surveyCache } from "./cache";
import { getSurvey } from "./service";
export const canUserAccessSurvey = (userId: string, surveyId: string): Promise<boolean> =>
cache(
async () => {
validateInputs([surveyId, ZId], [userId, ZId]);
if (!userId) return false;
try {
const survey = await getSurvey(surveyId);
if (!survey) throw new Error("Survey not found");
const hasAccessToEnvironment = await hasUserEnvironmentAccess(userId, survey.environmentId);
if (!hasAccessToEnvironment) return false;
return true;
} catch (error) {
throw error;
}
},
[`canUserAccessSurvey-${userId}-${surveyId}`],
{
tags: [surveyCache.tag.byId(surveyId)],
}
)();

View File

@@ -0,0 +1,122 @@
import { cleanup } from "@testing-library/react";
import { revalidateTag } from "next/cache";
import { afterEach, describe, expect, test, vi } from "vitest";
import { surveyCache } from "./cache";
// Mock the revalidateTag function from next/cache
vi.mock("next/cache", () => ({
revalidateTag: vi.fn(),
}));
describe("surveyCache", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("tag methods", () => {
test("byId returns the correct tag string", () => {
const id = "survey-123";
expect(surveyCache.tag.byId(id)).toBe(`surveys-${id}`);
});
test("byEnvironmentId returns the correct tag string", () => {
const environmentId = "env-456";
expect(surveyCache.tag.byEnvironmentId(environmentId)).toBe(`environments-${environmentId}-surveys`);
});
test("byAttributeClassId returns the correct tag string", () => {
const attributeClassId = "attr-789";
expect(surveyCache.tag.byAttributeClassId(attributeClassId)).toBe(
`attributeFilters-${attributeClassId}-surveys`
);
});
test("byActionClassId returns the correct tag string", () => {
const actionClassId = "action-012";
expect(surveyCache.tag.byActionClassId(actionClassId)).toBe(`actionClasses-${actionClassId}-surveys`);
});
test("bySegmentId returns the correct tag string", () => {
const segmentId = "segment-345";
expect(surveyCache.tag.bySegmentId(segmentId)).toBe(`segments-${segmentId}-surveys`);
});
test("byResultShareKey returns the correct tag string", () => {
const resultShareKey = "share-678";
expect(surveyCache.tag.byResultShareKey(resultShareKey)).toBe(`surveys-resultShare-${resultShareKey}`);
});
});
describe("revalidate method", () => {
test("calls revalidateTag with correct tag when id is provided", () => {
const id = "survey-123";
surveyCache.revalidate({ id });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-${id}`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag with correct tag when attributeClassId is provided", () => {
const attributeClassId = "attr-789";
surveyCache.revalidate({ attributeClassId });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`attributeFilters-${attributeClassId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag with correct tag when actionClassId is provided", () => {
const actionClassId = "action-012";
surveyCache.revalidate({ actionClassId });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`actionClasses-${actionClassId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag with correct tag when environmentId is provided", () => {
const environmentId = "env-456";
surveyCache.revalidate({ environmentId });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`environments-${environmentId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag with correct tag when segmentId is provided", () => {
const segmentId = "segment-345";
surveyCache.revalidate({ segmentId });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`segments-${segmentId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag with correct tag when resultShareKey is provided", () => {
const resultShareKey = "share-678";
surveyCache.revalidate({ resultShareKey });
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-resultShare-${resultShareKey}`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(1);
});
test("calls revalidateTag multiple times when multiple parameters are provided", () => {
const props = {
id: "survey-123",
environmentId: "env-456",
attributeClassId: "attr-789",
actionClassId: "action-012",
segmentId: "segment-345",
resultShareKey: "share-678",
};
surveyCache.revalidate(props);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledTimes(6);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-${props.id}`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`environments-${props.environmentId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(
`attributeFilters-${props.attributeClassId}-surveys`
);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`actionClasses-${props.actionClassId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`segments-${props.segmentId}-surveys`);
expect(vi.mocked(revalidateTag)).toHaveBeenCalledWith(`surveys-resultShare-${props.resultShareKey}`);
});
test("does not call revalidateTag when no parameters are provided", () => {
surveyCache.revalidate({});
expect(vi.mocked(revalidateTag)).not.toHaveBeenCalled();
});
});
});

File diff suppressed because it is too large Load Diff

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