mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
Compare commits
31 Commits
feature/ad
...
v3.10.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42a38a6f47 | ||
|
|
34bb9c2127 | ||
|
|
6442b5e4aa | ||
|
|
dde5a55446 | ||
|
|
13e615a798 | ||
|
|
9c81961b0b | ||
|
|
c1a35e2d75 | ||
|
|
13415c75c2 | ||
|
|
300557a0e6 | ||
|
|
fcbb97010c | ||
|
|
6be46b16b2 | ||
|
|
35b2356a31 | ||
|
|
53ef756723 | ||
|
|
0f0b743a10 | ||
|
|
3f7dafb65c | ||
|
|
9df791b5ff | ||
|
|
dea40d9757 | ||
|
|
dd12a589d6 | ||
|
|
af6e5ba31e | ||
|
|
2b57b2080b | ||
|
|
154c85a0f7 | ||
|
|
3f465d4594 | ||
|
|
94e883f4c3 | ||
|
|
38622101f1 | ||
|
|
0eb64c0084 | ||
|
|
409f5b1791 | ||
|
|
14398a9c4f | ||
|
|
d1cdf6e216 | ||
|
|
65da25a626 | ||
|
|
ce8b019e93 | ||
|
|
67d7fe016d |
@@ -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=
|
||||
|
||||
|
||||
22
.github/workflows/e2e.yml
vendored
22
.github/workflows/e2e.yml
vendored
@@ -11,6 +11,8 @@ on:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
required: false
|
||||
ENTERPRISE_LICENSE_KEY:
|
||||
required: true
|
||||
# Add other secrets if necessary
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import OnboardingLayout from "./layout";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/auth", () => ({
|
||||
hasUserEnvironmentAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("OnboardingLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if session is missing", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||
|
||||
await OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div>Test Content</div>,
|
||||
});
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws AuthorizationError if user lacks access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(false);
|
||||
|
||||
await expect(
|
||||
OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div>Test Content</div>,
|
||||
})
|
||||
).rejects.toThrow("User is not authorized to access this environment");
|
||||
});
|
||||
|
||||
test("renders children if user has access", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user1" } });
|
||||
vi.mocked(hasUserEnvironmentAccess).mockResolvedValueOnce(true);
|
||||
|
||||
const result = await OnboardingLayout({
|
||||
params: { environmentId: "env1" },
|
||||
children: <div data-testid="child">Test Content</div>,
|
||||
});
|
||||
|
||||
render(result);
|
||||
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,7 @@ const OnboardingLayout = async (props) => {
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!isAuthorized) {
|
||||
throw AuthorizationError;
|
||||
throw new AuthorizationError("User is not authorized to access this environment");
|
||||
}
|
||||
|
||||
return <div className="flex-1 bg-slate-50">{children}</div>;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { XMTemplateList } from "./XMTemplateList";
|
||||
|
||||
// Prepare push mock and module mocks before importing component
|
||||
const pushMock = vi.fn();
|
||||
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: vi.fn(() => ({ push: pushMock })) }));
|
||||
vi.mock("react-hot-toast", () => ({ default: { error: vi.fn() } }));
|
||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates", () => ({
|
||||
getXMTemplates: (t: any) => [
|
||||
{ id: 1, name: "tmpl1" },
|
||||
{ id: 2, name: "tmpl2" },
|
||||
],
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils", () => ({
|
||||
replacePresetPlaceholders: (template: any, project: any) => ({ ...template, projectId: project.id }),
|
||||
}));
|
||||
vi.mock("@/modules/survey/components/template-list/actions", () => ({ createSurveyAction: vi.fn() }));
|
||||
vi.mock("@/lib/utils/helper", () => ({ getFormattedErrorMessage: () => "formatted-error" }));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
||||
<div>
|
||||
{options.map((opt, idx) => (
|
||||
<button key={idx} data-testid={`option-${idx}`} onClick={opt.onClick}>
|
||||
{opt.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Reset mocks between tests
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("XMTemplateList component", () => {
|
||||
const project = { id: "proj1" } as any;
|
||||
const user = { id: "user1" } as any;
|
||||
const environmentId = "env1";
|
||||
|
||||
test("creates survey and navigates on success", async () => {
|
||||
// Mock successful survey creation
|
||||
vi.mocked(createSurveyAction).mockResolvedValue({ data: { id: "survey1" } } as any);
|
||||
|
||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
||||
|
||||
const option0 = screen.getByTestId("option-0");
|
||||
await userEvent.click(option0);
|
||||
|
||||
expect(createSurveyAction).toHaveBeenCalledWith({
|
||||
environmentId,
|
||||
surveyBody: expect.objectContaining({ id: 1, projectId: "proj1", type: "link", createdBy: "user1" }),
|
||||
});
|
||||
expect(pushMock).toHaveBeenCalledWith(`/environments/${environmentId}/surveys/survey1/edit?mode=cx`);
|
||||
});
|
||||
|
||||
test("shows error toast on failure", async () => {
|
||||
// Mock failed survey creation
|
||||
vi.mocked(createSurveyAction).mockResolvedValue({ error: "err" } as any);
|
||||
|
||||
render(<XMTemplateList project={project} user={user} environmentId={environmentId} />);
|
||||
|
||||
const option1 = screen.getByTestId("option-1");
|
||||
await userEvent.click(option1);
|
||||
|
||||
expect(createSurveyAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("formatted-error");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
import { replacePresetPlaceholders } from "./utils";
|
||||
|
||||
// Mock data
|
||||
const mockProject: TProject = {
|
||||
id: "project1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Project",
|
||||
organizationId: "org1",
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: { light: "#FFFFFF" },
|
||||
},
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
config: {
|
||||
channel: "link" as const,
|
||||
industry: "eCommerce" as "eCommerce" | "saas" | "other" | null,
|
||||
},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
languages: [],
|
||||
logo: null,
|
||||
};
|
||||
const mockTemplate: TXMTemplate = {
|
||||
name: "$[projectName] Survey",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
inputType: "text",
|
||||
type: "email" as any,
|
||||
headline: { default: "$[projectName] Question" },
|
||||
required: false,
|
||||
charLimit: { enabled: true, min: 400, max: 1000 },
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{
|
||||
id: "e1",
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you for completing the survey!" },
|
||||
},
|
||||
],
|
||||
styling: {
|
||||
brandColor: { light: "#0000FF" },
|
||||
questionColor: { light: "#00FF00" },
|
||||
inputColor: { light: "#FF0000" },
|
||||
},
|
||||
};
|
||||
|
||||
describe("replacePresetPlaceholders", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in template name", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.name).toBe("Test Project Survey");
|
||||
});
|
||||
|
||||
test("replaces projectName placeholder in question headline", () => {
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result.questions[0].headline.default).toBe("Test Project Question");
|
||||
});
|
||||
|
||||
test("returns a new object without mutating the original template", () => {
|
||||
const originalTemplate = structuredClone(mockTemplate);
|
||||
const result = replacePresetPlaceholders(mockTemplate, mockProject);
|
||||
expect(result).not.toBe(mockTemplate);
|
||||
expect(mockTemplate).toEqual(originalTemplate);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/preact";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { getXMSurveyDefault, getXMTemplates } from "./xm-templates";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: { error: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("xm-templates", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("getXMSurveyDefault returns default survey template", () => {
|
||||
const tMock = vi.fn((key) => key) as TFnType;
|
||||
const result = getXMSurveyDefault(tMock);
|
||||
|
||||
expect(result).toEqual({
|
||||
name: "",
|
||||
endings: expect.any(Array),
|
||||
questions: [],
|
||||
styling: {
|
||||
overwriteThemeStyling: true,
|
||||
},
|
||||
});
|
||||
expect(result.endings).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("getXMTemplates returns all templates", () => {
|
||||
const tMock = vi.fn((key) => key) as TFnType;
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result[0].name).toBe("templates.nps_survey_name");
|
||||
expect(result[1].name).toBe("templates.star_rating_survey_name");
|
||||
expect(result[2].name).toBe("templates.csat_survey_name");
|
||||
expect(result[3].name).toBe("templates.cess_survey_name");
|
||||
expect(result[4].name).toBe("templates.smileys_survey_name");
|
||||
expect(result[5].name).toBe("templates.enps_survey_name");
|
||||
});
|
||||
|
||||
test("getXMTemplates handles errors gracefully", async () => {
|
||||
const tMock = vi.fn(() => {
|
||||
throw new Error("Test error");
|
||||
}) as TFnType;
|
||||
|
||||
const result = getXMTemplates(tMock);
|
||||
|
||||
// Dynamically import the mocked logger
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Unable to load XM templates, returning empty array"
|
||||
);
|
||||
});
|
||||
});
|
||||
58
apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts
Normal file
58
apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getTeamsByOrganizationId } from "./onboarding";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
team: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache", () => ({
|
||||
cache: (fn: any) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/team", () => ({
|
||||
teamCache: {
|
||||
tag: { byOrganizationId: vi.fn((id: string) => `organization-${id}-teams`) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getTeamsByOrganizationId", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns mapped teams", async () => {
|
||||
const mockTeams = [
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
];
|
||||
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
|
||||
const result = await getTeamsByOrganizationId("org1");
|
||||
expect(result).toEqual([
|
||||
{ id: "t1", name: "Team 1" },
|
||||
{ id: "t2", name: "Team 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
|
||||
);
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error on unknown error", async () => {
|
||||
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(new Error("fail"));
|
||||
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow("fail");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { LandingSidebar } from "./landing-sidebar";
|
||||
|
||||
// Module mocks must be declared before importing the component
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
|
||||
}));
|
||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open }: { open: boolean }) => (
|
||||
<div data-testid={open ? "modal-open" : "modal-closed"} />
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
ProfileAvatar: ({ userId }: { userId: string }) => <div data-testid="avatar">{userId}</div>,
|
||||
}));
|
||||
|
||||
// Ensure mocks are reset between tests
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("LandingSidebar component", () => {
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
|
||||
const organization = { id: "o1", name: "orgOne" } as any;
|
||||
const organizations = [
|
||||
{ id: "o2", name: "betaOrg" },
|
||||
{ id: "o1", name: "alphaOrg" },
|
||||
] as any;
|
||||
|
||||
test("renders logo, avatar, and initial modal closed", () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
|
||||
// Formbricks logo
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
// Profile avatar
|
||||
expect(screen.getByTestId("avatar")).toHaveTextContent("u1");
|
||||
// CreateOrganizationModal should be closed initially
|
||||
expect(screen.getByTestId("modal-closed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("clicking logout triggers signOut", async () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open user dropdown by clicking on avatar trigger
|
||||
const trigger = screen.getByTestId("avatar").parentElement;
|
||||
if (trigger) await userEvent.click(trigger);
|
||||
|
||||
// Click logout menu item
|
||||
const logoutItem = await screen.findByText("common.logout");
|
||||
await userEvent.click(logoutItem);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/preact";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import LandingLayout from "./layout";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/service");
|
||||
vi.mock("@/lib/membership/service");
|
||||
vi.mock("@/lib/project/service");
|
||||
vi.mock("next-auth");
|
||||
vi.mock("next/navigation");
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("LandingLayout", () => {
|
||||
test("redirects to login if no session exists", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("returns notFound if no membership is found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(notFound)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects to production environment if available", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org-123",
|
||||
userId: "user-123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
vi.mocked(getUserProjects).mockResolvedValue([
|
||||
{
|
||||
id: "proj-123",
|
||||
organizationId: "org-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-02"),
|
||||
name: "Project 1",
|
||||
styling: { allowStyleOverwrite: true },
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
} as any,
|
||||
]);
|
||||
vi.mocked(getEnvironments).mockResolvedValue([
|
||||
{
|
||||
id: "env-123",
|
||||
type: "production",
|
||||
projectId: "proj-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-02"),
|
||||
appSetupCompleted: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
await LandingLayout(props);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith("/environments/env-123/");
|
||||
});
|
||||
|
||||
test("renders children if no projects or production environment exist", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-123" } });
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org-123",
|
||||
userId: "user-123",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
});
|
||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
||||
|
||||
const props = { params: { organizationId: "org-123" }, children: <div>Child Content</div> };
|
||||
|
||||
const result = await LandingLayout(props);
|
||||
|
||||
expect(result).toEqual(
|
||||
<>
|
||||
<div>Child Content</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
FB_LOGO_URL: "https://mock-logo-url.com/logo.png",
|
||||
PRIVACY_URL: "http://localhost:3000/privacy",
|
||||
TERMS_URL: "http://localhost:3000/terms",
|
||||
IMPRINT_URL: "http://localhost:3000/imprint",
|
||||
IMPRINT_ADDRESS: "Mock Address",
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
GOOGLE_OAUTH_ENABLED: false,
|
||||
GITHUB_OAUTH_ENABLED: false,
|
||||
AZURE_OAUTH_ENABLED: false,
|
||||
OIDC_OAUTH_ENABLED: false,
|
||||
SAML_OAUTH_ENABLED: false,
|
||||
SAML_XML_DIR: "./mock-saml-connection",
|
||||
SIGNUP_ENABLED: true,
|
||||
EMAIL_AUTH_ENABLED: true,
|
||||
INVITE_DISABLED: false,
|
||||
SLACK_CLIENT_SECRET: "mock-slack-secret",
|
||||
SLACK_CLIENT_ID: "mock-slack-id",
|
||||
SLACK_AUTH_URL: "https://mock-slack-auth-url.com",
|
||||
GOOGLE_SHEETS_CLIENT_ID: "mock-google-sheets-id",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "mock-google-sheets-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "http://localhost:3000/google-sheets-redirect",
|
||||
NOTION_OAUTH_CLIENT_ID: "mock-notion-id",
|
||||
NOTION_OAUTH_CLIENT_SECRET: "mock-notion-secret",
|
||||
NOTION_REDIRECT_URI: "http://localhost:3000/notion-redirect",
|
||||
NOTION_AUTH_URL: "https://mock-notion-auth-url.com",
|
||||
AIRTABLE_CLIENT_ID: "mock-airtable-id",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "587",
|
||||
SMTP_SECURE_ENABLED: false,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SMTP_AUTHENTICATED: true,
|
||||
SMTP_REJECT_UNAUTHORIZED_TLS: true,
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
NEXTAUTH_SECRET: "mock-nextauth-secret",
|
||||
ITEMS_PER_PAGE: 30,
|
||||
SURVEYS_PER_PAGE: 12,
|
||||
RESPONSES_PER_PAGE: 25,
|
||||
TEXT_RESPONSES_PER_PAGE: 5,
|
||||
INSIGHTS_PER_PAGE: 10,
|
||||
DOCUMENTS_PER_PAGE: 10,
|
||||
MAX_RESPONSES_FOR_INSIGHT_GENERATION: 500,
|
||||
MAX_OTHER_OPTION_LENGTH: 250,
|
||||
ENTERPRISE_LICENSE_KEY: "ABC",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GITHUB_OAUTH_URL: "https://mock-github-auth-url.com",
|
||||
AZURE_ID: "mock-azure-id",
|
||||
AZUREAD_CLIENT_ID: "mock-azure-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
GOOGLE_OAUTH_URL: "https://mock-google-auth-url.com",
|
||||
AZURE_OAUTH_URL: "https://mock-azure-auth-url.com",
|
||||
OIDC_ID: "mock-oidc-id",
|
||||
OIDC_OAUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
SAML_ID: "mock-saml-id",
|
||||
SAML_OAUTH_URL: "https://mock-saml-auth-url.com",
|
||||
SAML_METADATA_URL: "https://mock-saml-metadata-url.com",
|
||||
AZUREAD_TENANT_ID: "mock-azure-tenant-id",
|
||||
AZUREAD_OAUTH_URL: "https://mock-azuread-auth-url.com",
|
||||
OIDC_DISPLAY_NAME: "Mock OIDC",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_REDIRECT_URL: "http://localhost:3000/oidc-redirect",
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
||||
}));
|
||||
vi.mock("@/modules/organization/lib/utils");
|
||||
vi.mock("@/lib/user/service");
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/tolgee/server");
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||
}));
|
||||
|
||||
// Mock the React cache function
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: (fn: any) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
|
||||
test("returns notFound if user does not exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: {},
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
expect(result).toBe("NOT_FOUND_STUB");
|
||||
});
|
||||
|
||||
test("renders header and sidebar for authenticated user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: { id: "org1" },
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||
typeof props === "string" ? props : props.key || ""
|
||||
);
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const element = await Page({ params: { organizationId: "org1" } });
|
||||
render(element as React.ReactElement);
|
||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
// Module mocks must be declared before importing the component
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn(() => "REDIRECT_STUB") }));
|
||||
vi.mock("@/modules/ui/components/header", () => ({
|
||||
Header: ({ title, subtitle }: { title: string; subtitle: string }) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: { options: any[] }) => (
|
||||
<div data-testid="options">{options.map((o) => o.title).join(",")}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children }: { href: string; children: React.ReactNode }) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {} } as any);
|
||||
|
||||
const result = await Page({ params });
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
|
||||
test("renders header, options, and close button when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([{ id: 1 }] as any);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
// Header title and subtitle
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.channel.channel_select_title"
|
||||
);
|
||||
expect(
|
||||
screen.getByText("organizations.projects.new.channel.channel_select_subtitle")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Options container with correct titles
|
||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
||||
"organizations.projects.new.channel.link_and_email_surveys," +
|
||||
"organizations.projects.new.channel.in_product_surveys"
|
||||
);
|
||||
|
||||
// Close button link rendered when projects >=1
|
||||
const closeLink = screen.getByRole("link");
|
||||
expect(closeLink).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
test("does not render close button when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: { user: { id: "user1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([]);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import OnboardingLayout from "./layout";
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getOrganizationProjectsCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("OnboardingLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if no session", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("returns not found if user is member or billing", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws error if organization is not found", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getOrganization).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
|
||||
test("redirects to home if project limit is reached", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
test("renders children when all conditions are met", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
const result = await OnboardingLayout(props);
|
||||
expect(result).toEqual(<>{props.children}</>);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: vi.fn() }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
vi.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer", () => ({
|
||||
OnboardingOptionsContainer: ({ options }: any) => (
|
||||
<div data-testid="options">{options.map((o: any) => o.title).join(",")}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/header", () => ({ Header: ({ title }: any) => <h1>{title}</h1> }));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
|
||||
describe("Mode Page", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
|
||||
test("redirects to login if no session user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
||||
await Page({ params });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("renders header and options without close link when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.mode.what_are_you_here_for"
|
||||
);
|
||||
expect(screen.getByTestId("options")).toHaveTextContent(
|
||||
"organizations.projects.new.mode.formbricks_surveys," + "organizations.projects.new.mode.formbricks_cx"
|
||||
);
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
test("renders close link when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: { user: { id: "u1" } } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" } as any]);
|
||||
|
||||
const element = await Page({ params });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
|
||||
// Mocks before component import
|
||||
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
vi.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({ href, children }: any) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/header", () => ({
|
||||
Header: ({ title, subtitle }: any) => (
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
<p>{subtitle}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings",
|
||||
() => ({
|
||||
ProjectSettings: (props: any) => <div data-testid="project-settings" data-mode={props.projectMode} />,
|
||||
})
|
||||
);
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("ProjectSettingsPage", () => {
|
||||
const params = Promise.resolve({ organizationId: "org1" });
|
||||
const searchParams = Promise.resolve({ channel: "link", industry: "other", mode: "cx" } as any);
|
||||
|
||||
test("redirects to login when no session user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({ session: {} } as any);
|
||||
await Page({ params, searchParams });
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("throws when teams not found", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
|
||||
|
||||
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
||||
});
|
||||
|
||||
test("renders header, settings and close link when projects exist", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
// Header
|
||||
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
|
||||
"organizations.projects.new.settings.project_settings_title"
|
||||
);
|
||||
// ProjectSettings stub receives mode prop
|
||||
expect(screen.getByTestId("project-settings")).toHaveAttribute("data-mode", "cx");
|
||||
// Close link for existing projects
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toHaveAttribute("href", "/");
|
||||
});
|
||||
|
||||
test("renders without close link when no projects", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValueOnce({
|
||||
session: { user: { id: "u1" } },
|
||||
organization: { billing: { plan: "basic" } },
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Home, Settings } from "lucide-react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OnboardingOptionsContainer } from "./OnboardingOptionsContainer";
|
||||
|
||||
describe("OnboardingOptionsContainer", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders options with links", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Test Option",
|
||||
description: "Test Description",
|
||||
icon: Home,
|
||||
href: "/test",
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Test Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with onClick handler", () => {
|
||||
const onClickMock = vi.fn();
|
||||
const options = [
|
||||
{
|
||||
title: "Click Option",
|
||||
description: "Click Description",
|
||||
icon: Home,
|
||||
onClick: onClickMock,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Click Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Click Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with iconText", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Icon Text Option",
|
||||
description: "Icon Text Description",
|
||||
icon: Home,
|
||||
iconText: "Custom Icon Text",
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Custom Icon Text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders options with loading state", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "Loading Option",
|
||||
description: "Loading Description",
|
||||
icon: Home,
|
||||
isLoading: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("Loading Option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders multiple options", () => {
|
||||
const options = [
|
||||
{
|
||||
title: "First Option",
|
||||
description: "First Description",
|
||||
icon: Home,
|
||||
},
|
||||
{
|
||||
title: "Second Option",
|
||||
description: "Second Description",
|
||||
icon: Settings,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
expect(screen.getByText("First Option")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second Option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onClick handler when clicking an option", async () => {
|
||||
const onClickMock = vi.fn();
|
||||
const options = [
|
||||
{
|
||||
title: "Click Option",
|
||||
description: "Click Description",
|
||||
icon: Home,
|
||||
onClick: onClickMock,
|
||||
},
|
||||
];
|
||||
|
||||
render(<OnboardingOptionsContainer options={options} />);
|
||||
await userEvent.click(screen.getByText("Click Option"));
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
@@ -49,7 +48,6 @@ vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||
@@ -176,7 +174,6 @@ describe("EnvironmentLayout", () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||
mockIsDevelopment = false;
|
||||
@@ -189,13 +186,19 @@ describe("EnvironmentLayout", () => {
|
||||
});
|
||||
|
||||
test("renders correctly with default props", async () => {
|
||||
// Ensure the default mockLicense has isPendingDowngrade: false and active: false
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
...mockLicense,
|
||||
isPendingDowngrade: false,
|
||||
active: false,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -203,20 +206,31 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Child Content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); // This should now pass
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DevEnvironmentBanner in development environment", async () => {
|
||||
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
||||
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
||||
mockIsDevelopment = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -224,13 +238,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -238,17 +263,21 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
|
||||
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
});
|
||||
|
||||
test("renders PendingDowngradeBanner when pending downgrade", async () => {
|
||||
// Ensure the license mock reflects the condition needed for the banner
|
||||
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense);
|
||||
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -256,12 +285,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.user_not_found"
|
||||
);
|
||||
@@ -269,6 +310,19 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.organization_not_found"
|
||||
);
|
||||
@@ -276,13 +330,39 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if environment not found", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.environment_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if projects, environments or organizations not found", async () => {
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"environments.projects_environments_organizations_not_found"
|
||||
);
|
||||
@@ -291,6 +371,19 @@ describe("EnvironmentLayout", () => {
|
||||
test("throws error if member has no project permission", async () => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.project_permission_not_found"
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
|
||||
@@ -109,7 +109,7 @@ export const MainNavigation = ({
|
||||
|
||||
useEffect(() => {
|
||||
const toggleTextOpacity = () => {
|
||||
setIsTextVisible(isCollapsed ? true : false);
|
||||
setIsTextVisible(isCollapsed);
|
||||
};
|
||||
const timeoutId = setTimeout(toggleTextOpacity, 150);
|
||||
return () => clearTimeout(timeoutId);
|
||||
@@ -170,7 +170,7 @@ export const MainNavigation = ({
|
||||
name: t("common.actions"),
|
||||
href: `/environments/${environment.id}/actions`,
|
||||
icon: MousePointerClick,
|
||||
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
|
||||
isActive: pathname?.includes("/actions"),
|
||||
},
|
||||
{
|
||||
name: t("common.integrations"),
|
||||
|
||||
@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import EnterpriseSettingsPage from "./page";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -179,15 +178,23 @@ describe("EnterpriseSettingsPage", () => {
|
||||
});
|
||||
|
||||
test("renders correctly for an owner when not on Formbricks Cloud", async () => {
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: EnterpriseSettingsPage } = await import("./page");
|
||||
const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } });
|
||||
render(Page);
|
||||
|
||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
|
||||
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
@@ -118,7 +118,7 @@ const Page = async (props) => {
|
||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
aria-hidden="true">
|
||||
<circle
|
||||
cx={512}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
OptionsType,
|
||||
QuestionOption,
|
||||
QuestionOptions,
|
||||
QuestionsComboBox,
|
||||
SelectedCommandItem,
|
||||
} from "./QuestionsComboBox";
|
||||
|
||||
describe("QuestionsComboBox", () => {
|
||||
afterEach(() => {
|
||||
@@ -53,3 +60,67 @@ describe("QuestionsComboBox", () => {
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
|
||||
});
|
||||
});
|
||||
|
||||
describe("SelectedCommandItem", () => {
|
||||
test("renders question icon and color for QUESTIONS with questionType", () => {
|
||||
const { container } = render(
|
||||
<SelectedCommandItem
|
||||
label="Q1"
|
||||
type={OptionsType.QUESTIONS}
|
||||
questionType={TSurveyQuestionTypeEnum.OpenText}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector(".bg-brand-dark")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Q1");
|
||||
});
|
||||
|
||||
test("renders attribute icon and color for ATTRIBUTES", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Attr" type={OptionsType.ATTRIBUTES} />);
|
||||
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Attr");
|
||||
});
|
||||
|
||||
test("renders hidden field icon and color for HIDDEN_FIELDS", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Hidden" type={OptionsType.HIDDEN_FIELDS} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Hidden");
|
||||
});
|
||||
|
||||
test("renders meta icon and color for META with label", () => {
|
||||
const { container } = render(<SelectedCommandItem label="device" type={OptionsType.META} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("device");
|
||||
});
|
||||
|
||||
test("renders other icon and color for OTHERS with label", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Language" type={OptionsType.OTHERS} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Language");
|
||||
});
|
||||
|
||||
test("renders tag icon and color for TAGS", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Tag1" type={OptionsType.TAGS} />);
|
||||
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Tag1");
|
||||
});
|
||||
|
||||
test("renders fallback color and no icon for unknown type", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Unknown" type={"UNKNOWN"} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).not.toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Unknown");
|
||||
});
|
||||
|
||||
test("renders fallback for non-string label", () => {
|
||||
const { container } = render(
|
||||
<SelectedCommandItem label={{ default: "NonString" }} type={OptionsType.QUESTIONS} />
|
||||
);
|
||||
expect(container.textContent).toContain("NonString");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
HelpCircleIcon,
|
||||
HomeIcon,
|
||||
ImageIcon,
|
||||
LanguagesIcon,
|
||||
ListIcon,
|
||||
@@ -63,59 +64,60 @@ interface QuestionComboBoxProps {
|
||||
onChangeValue: (option: QuestionOption) => void;
|
||||
}
|
||||
|
||||
const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getIconType = () => {
|
||||
switch (type) {
|
||||
case OptionsType.QUESTIONS:
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <MessageSquareTextIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Rating:
|
||||
return <StarIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <HelpCircleIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
return <ListIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
return <Rows3Icon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return <CheckIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return <ImageIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Matrix:
|
||||
return <GridIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
return <ListOrderedIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.ATTRIBUTES:
|
||||
return <User width={18} height={18} className="text-white" />;
|
||||
const questionIcons = {
|
||||
// questions
|
||||
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
|
||||
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
|
||||
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
|
||||
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
|
||||
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
|
||||
|
||||
case OptionsType.HIDDEN_FIELDS:
|
||||
return <EyeOff width={18} height={18} className="text-white" />;
|
||||
case OptionsType.META:
|
||||
switch (label) {
|
||||
case "device":
|
||||
return <SmartphoneIcon width={18} height={18} className="text-white" />;
|
||||
case "os":
|
||||
return <AirplayIcon width={18} height={18} className="text-white" />;
|
||||
case "browser":
|
||||
return <GlobeIcon width={18} height={18} className="text-white" />;
|
||||
case "source":
|
||||
return <GlobeIcon width={18} height={18} className="text-white" />;
|
||||
case "action":
|
||||
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.OTHERS:
|
||||
switch (label) {
|
||||
case "Language":
|
||||
return <LanguagesIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.TAGS:
|
||||
return <HashIcon width={18} height={18} className="text-white" />;
|
||||
// attributes
|
||||
[OptionsType.ATTRIBUTES]: User,
|
||||
|
||||
// hidden fields
|
||||
[OptionsType.HIDDEN_FIELDS]: EyeOff,
|
||||
|
||||
// meta
|
||||
device: SmartphoneIcon,
|
||||
os: AirplayIcon,
|
||||
browser: GlobeIcon,
|
||||
source: GlobeIcon,
|
||||
action: MousePointerClickIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
|
||||
// tags
|
||||
[OptionsType.TAGS]: HashIcon,
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const IconComponent = questionIcons[type];
|
||||
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
|
||||
};
|
||||
|
||||
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getIconType = () => {
|
||||
if (type) {
|
||||
if (type === OptionsType.QUESTIONS && questionType) {
|
||||
return getIcon(questionType);
|
||||
} else if (type === OptionsType.ATTRIBUTES) {
|
||||
return getIcon(OptionsType.ATTRIBUTES);
|
||||
} else if (type === OptionsType.HIDDEN_FIELDS) {
|
||||
return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
|
||||
return getIcon(label);
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return getIcon(OptionsType.TAGS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,7 +166,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={t("common.search") + "..."}
|
||||
className="h-5 border-none border-transparent p-0 shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0"
|
||||
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
|
||||
35
apps/web/app/(auth)/auth/forgot-password/page.test.tsx
Normal file
35
apps/web/app/(auth)/auth/forgot-password/page.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import ForgotPasswordPage from "./page";
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/page", () => ({
|
||||
ForgotPasswordPage: () => (
|
||||
<div data-testid="forgot-password-page">
|
||||
<div data-testid="form-wrapper">
|
||||
<div data-testid="forgot-password-form">Forgot Password Form</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ForgotPasswordPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the forgot password page", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("forgot-password-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the form wrapper", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the forgot password form", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("forgot-password-form")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
74
apps/web/app/ClientEnvironmentRedirect.test.tsx
Normal file
74
apps/web/app/ClientEnvironmentRedirect.test.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import ClientEnvironmentRedirect from "./ClientEnvironmentRedirect";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ClientEnvironmentRedirect", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should redirect to the provided environment ID when no last environment exists", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id");
|
||||
});
|
||||
|
||||
test("should redirect to the last environment ID when it exists in localStorage", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage with a last environment ID
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue("last-env-id"),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
render(<ClientEnvironmentRedirect environmentId="test-env-id" />);
|
||||
|
||||
expect(localStorageMock.getItem).toHaveBeenCalledWith(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/last-env-id");
|
||||
});
|
||||
|
||||
test("should update redirect when environment ID prop changes", () => {
|
||||
const mockPush = vi.fn();
|
||||
vi.mocked(useRouter).mockReturnValue({ push: mockPush } as any);
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
const { rerender } = render(<ClientEnvironmentRedirect environmentId="initial-env-id" />);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/initial-env-id");
|
||||
|
||||
// Clear mock calls
|
||||
mockPush.mockClear();
|
||||
|
||||
// Rerender with new environment ID
|
||||
rerender(<ClientEnvironmentRedirect environmentId="new-env-id" />);
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/new-env-id");
|
||||
});
|
||||
});
|
||||
@@ -1,235 +0,0 @@
|
||||
import {
|
||||
mockContactEmailFollowUp,
|
||||
mockDirectEmailFollowUp,
|
||||
mockEndingFollowUp,
|
||||
mockEndingId2,
|
||||
mockResponse,
|
||||
mockResponseEmailFollowUp,
|
||||
mockResponseWithContactQuestion,
|
||||
mockSurvey,
|
||||
mockSurveyWithContactQuestion,
|
||||
} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock";
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendFollowUpEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Survey Follow Up", () => {
|
||||
const mockOrganization: Partial<TOrganization> = {
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
whitelabel: {
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
},
|
||||
};
|
||||
|
||||
describe("evaluateFollowUp", () => {
|
||||
test("sends email when to is a direct email address", async () => {
|
||||
const followUpId = mockDirectEmailFollowUp.id;
|
||||
const followUpAction = mockDirectEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockDirectEmailFollowUp.action.properties.body,
|
||||
subject: mockDirectEmailFollowUp.action.properties.subject,
|
||||
to: mockDirectEmailFollowUp.action.properties.to,
|
||||
replyTo: mockDirectEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockResponseEmailFollowUp.action.properties.body,
|
||||
subject: mockResponseEmailFollowUp.action.properties.subject,
|
||||
to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to],
|
||||
replyTo: mockResponseEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email in array", async () => {
|
||||
const followUpId = mockContactEmailFollowUp.id;
|
||||
const followUpAction = mockContactEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurveyWithContactQuestion,
|
||||
mockResponseWithContactQuestion,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockContactEmailFollowUp.action.properties.body,
|
||||
subject: mockContactEmailFollowUp.action.properties.subject,
|
||||
to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2],
|
||||
replyTo: mockContactEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurveyWithContactQuestion,
|
||||
response: mockResponseWithContactQuestion,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when to value is not found in response data", async () => {
|
||||
const followUpId = "followup1";
|
||||
const followUpAction = {
|
||||
...mockSurvey.followUps![0].action,
|
||||
properties: {
|
||||
...mockSurvey.followUps![0].action.properties,
|
||||
to: "nonExistentField",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
});
|
||||
|
||||
test("throws error when email address is invalid", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
const invalidResponse = {
|
||||
...mockResponse,
|
||||
data: {
|
||||
[mockResponseEmailFollowUp.action.properties.to]: "invalid-email",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
invalidResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendSurveyFollowUps", () => {
|
||||
test("skips follow-up when ending Id doesn't match", async () => {
|
||||
const responseWithDifferentEnding = {
|
||||
...mockResponse,
|
||||
endingId: mockEndingId2,
|
||||
};
|
||||
|
||||
const mockSurveyWithEndingFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockEndingFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithEndingFollowUp,
|
||||
responseWithDifferentEnding as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockEndingFollowUp.id,
|
||||
status: "skipped",
|
||||
},
|
||||
]);
|
||||
expect(sendFollowUpEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("processes follow-ups and log errors", async () => {
|
||||
const error = new Error("Test error");
|
||||
vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error);
|
||||
|
||||
const mockSurveyWithFollowUps: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockResponseEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUps,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockResponseEmailFollowUp.id,
|
||||
status: "error",
|
||||
error: "Test error",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
[`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`],
|
||||
"Follow-up processing errors"
|
||||
);
|
||||
});
|
||||
|
||||
test("successfully processes follow-ups", async () => {
|
||||
vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined);
|
||||
|
||||
const mockSurveyWithFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockDirectEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUp,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockDirectEmailFollowUp.id,
|
||||
status: "success",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
type FollowUpResult = {
|
||||
followUpId: string;
|
||||
status: "success" | "error" | "skipped";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const evaluateFollowUp = async (
|
||||
followUpId: string,
|
||||
followUpAction: TSurveyFollowUpAction,
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
): Promise<void> => {
|
||||
const { properties } = followUpAction;
|
||||
const { to, subject, body, replyTo } = properties;
|
||||
const toValueFromResponse = response.data[to];
|
||||
const logoUrl = organization.whitelabel?.logoUrl || "";
|
||||
|
||||
// Check if 'to' is a direct email address (team member or user email)
|
||||
const parsedEmailTo = z.string().email().safeParse(to);
|
||||
if (parsedEmailTo.success) {
|
||||
// 'to' is a valid email address, send email directly
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedEmailTo.data,
|
||||
replyTo,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
logoUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If not a direct email, check if it's a question ID or hidden field ID
|
||||
if (!toValueFromResponse) {
|
||||
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
}
|
||||
|
||||
if (typeof toValueFromResponse === "string") {
|
||||
// parse this string to check for an email:
|
||||
const parsedResult = z.string().email().safeParse(toValueFromResponse);
|
||||
if (parsedResult.data) {
|
||||
// send email to this email address
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
} else if (Array.isArray(toValueFromResponse)) {
|
||||
const emailAddress = toValueFromResponse[2];
|
||||
if (!emailAddress) {
|
||||
throw new Error(`Email address not found in response data for followup: ${followUpId}`);
|
||||
}
|
||||
const parsedResult = z.string().email().safeParse(emailAddress);
|
||||
if (parsedResult.data) {
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const sendSurveyFollowUps = async (
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
): Promise<FollowUpResult[]> => {
|
||||
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
|
||||
const { trigger } = followUp;
|
||||
|
||||
// Check if we should skip this follow-up based on ending IDs
|
||||
if (trigger.properties) {
|
||||
const { endingIds } = trigger.properties;
|
||||
const { endingId } = response;
|
||||
|
||||
if (!endingId || !endingIds.includes(endingId)) {
|
||||
return Promise.resolve({
|
||||
followUpId: followUp.id,
|
||||
status: "skipped",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
|
||||
.then(() => ({
|
||||
followUpId: followUp.id,
|
||||
status: "success" as const,
|
||||
}))
|
||||
.catch((error) => ({
|
||||
followUpId: followUp.id,
|
||||
status: "error" as const,
|
||||
error: error instanceof Error ? error.message : "Something went wrong",
|
||||
}));
|
||||
});
|
||||
|
||||
const followUpResults = await Promise.all(followUpPromises);
|
||||
|
||||
// Log all errors
|
||||
const errors = followUpResults
|
||||
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
|
||||
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.error(errors, "Follow-up processing errors");
|
||||
}
|
||||
|
||||
return followUpResults;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
@@ -11,7 +10,8 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -164,11 +164,15 @@ export const POST = async (request: Request) => {
|
||||
select: { email: true, locale: true },
|
||||
});
|
||||
|
||||
// send follow up emails
|
||||
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
|
||||
if (surveyFollowUpsPermission) {
|
||||
await sendSurveyFollowUps(survey, response, organization);
|
||||
if (survey.followUps?.length > 0) {
|
||||
// send follow up emails
|
||||
const followUpsResult = await sendFollowUpsForResponse(response.id);
|
||||
if (!followUpsResult.ok) {
|
||||
const { error: followUpsError } = followUpsResult;
|
||||
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
|
||||
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emailPromises = usersWithNotifications.map((user) =>
|
||||
|
||||
@@ -254,7 +254,7 @@ describe("getEnvironmentState", () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject);
|
||||
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue([mockSurveys[0]]); // Only return the app, inProgress survey
|
||||
vi.mocked(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
|
||||
});
|
||||
|
||||
@@ -99,12 +99,8 @@ export const getEnvironmentState = async (
|
||||
getActionClassesForEnvironmentState(environmentId),
|
||||
]);
|
||||
|
||||
const filteredSurveys = surveys.filter(
|
||||
(survey) => survey.type === "app" && survey.status === "inProgress"
|
||||
);
|
||||
|
||||
const data: TJsEnvironmentState["data"] = {
|
||||
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
|
||||
surveys: !isMonthlyResponsesLimitReached ? surveys : [],
|
||||
actionClasses,
|
||||
project: project,
|
||||
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
|
||||
|
||||
@@ -100,8 +100,14 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
where: {
|
||||
environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
},
|
||||
select: expect.any(Object), // Check if select is called, specific fields are in the original code
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
|
||||
expect(result).toEqual([mockTransformedSurvey]);
|
||||
@@ -114,8 +120,14 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
const result = await getSurveysForEnvironmentState(environmentId);
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
where: {
|
||||
environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
expect(transformPrismaSurvey).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([]);
|
||||
|
||||
@@ -20,7 +20,13 @@ export const getSurveysForEnvironmentState = reactCache(
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 30,
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
|
||||
@@ -33,6 +33,7 @@ export const responseSelection = {
|
||||
singleUseId: true,
|
||||
language: true,
|
||||
displayId: true,
|
||||
endingId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 && {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
43
apps/web/app/lib/actionClass/actionClass.test.ts
Normal file
43
apps/web/app/lib/actionClass/actionClass.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
366
apps/web/app/lib/api/response.test.ts
Normal file
366
apps/web/app/lib/api/response.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
apps/web/app/lib/api/validator.test.ts
Normal file
83
apps/web/app/lib/api/validator.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
736
apps/web/app/lib/surveys/surveys.test.ts
Normal file
736
apps/web/app/lib/surveys/surveys.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
99
apps/web/app/middleware/bucket.test.ts
Normal file
99
apps/web/app/middleware/bucket.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
140
apps/web/app/middleware/endpoint-validator.test.ts
Normal file
140
apps/web/app/middleware/endpoint-validator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
37
apps/web/app/not-found.test.tsx
Normal file
37
apps/web/app/not-found.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 couldn’t find the page you’re 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't find the page you'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
391
apps/web/app/page.test.tsx
Normal 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}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
97
apps/web/cache-handler.js
Normal 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();
|
||||
@@ -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;
|
||||
213
apps/web/lib/actionClass/service.test.ts
Normal file
213
apps/web/lib/actionClass/service.test.ts
Normal 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
219
apps/web/lib/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
86
apps/web/lib/environment/auth.test.ts
Normal file
86
apps/web/lib/environment/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
206
apps/web/lib/environment/service.test.ts
Normal file
206
apps/web/lib/environment/service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
46
apps/web/lib/getSurveyUrl.test.ts
Normal file
46
apps/web/lib/getSurveyUrl.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
51
apps/web/lib/hashString.test.ts
Normal file
51
apps/web/lib/hashString.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
291
apps/web/lib/integration/service.test.ts
Normal file
291
apps/web/lib/integration/service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
195
apps/web/lib/jwt.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
53
apps/web/lib/membership/hooks/useMembershipRole.test.tsx
Normal file
53
apps/web/lib/membership/hooks/useMembershipRole.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
184
apps/web/lib/membership/service.test.ts
Normal file
184
apps/web/lib/membership/service.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
59
apps/web/lib/membership/utils.test.ts
Normal file
59
apps/web/lib/membership/utils.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
129
apps/web/lib/organization/auth.test.ts
Normal file
129
apps/web/lib/organization/auth.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
273
apps/web/lib/organization/service.test.ts
Normal file
273
apps/web/lib/organization/service.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
421
apps/web/lib/project/service.test.ts
Normal file
421
apps/web/lib/project/service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
557
apps/web/lib/response/utils.test.ts
Normal file
557
apps/web/lib/response/utils.test.ts
Normal 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 – data‐field 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
353
apps/web/lib/responses.test.ts
Normal file
353
apps/web/lib/responses.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
134
apps/web/lib/storage/service.test.ts
Normal file
134
apps/web/lib/storage/service.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
42
apps/web/lib/storage/utils.test.ts
Normal file
42
apps/web/lib/storage/utils.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
137
apps/web/lib/tag/service.test.ts
Normal file
137
apps/web/lib/tag/service.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { tagCache } from "./cache";
|
||||
import { createTag, getTag, getTagsByEnvironmentId } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
tagCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `tag-${id}`),
|
||||
byEnvironmentId: vi.fn((envId) => `env-${envId}-tags`),
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Tag Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getTagsByEnvironmentId", () => {
|
||||
test("should return tags for a given environment ID", async () => {
|
||||
const mockTags: TTag[] = [
|
||||
{
|
||||
id: "tag1",
|
||||
name: "Tag 1",
|
||||
environmentId: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags);
|
||||
|
||||
const result = await getTagsByEnvironmentId("env1");
|
||||
expect(result).toEqual(mockTags);
|
||||
expect(prisma.tag.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: "env1",
|
||||
},
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle pagination correctly", async () => {
|
||||
const mockTags: TTag[] = [
|
||||
{
|
||||
id: "tag1",
|
||||
name: "Tag 1",
|
||||
environmentId: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags);
|
||||
|
||||
const result = await getTagsByEnvironmentId("env1", 1);
|
||||
expect(result).toEqual(mockTags);
|
||||
expect(prisma.tag.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: "env1",
|
||||
},
|
||||
take: 30,
|
||||
skip: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTag", () => {
|
||||
test("should return a tag by ID", async () => {
|
||||
const mockTag: TTag = {
|
||||
id: "tag1",
|
||||
name: "Tag 1",
|
||||
environmentId: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.tag.findUnique).mockResolvedValue(mockTag);
|
||||
|
||||
const result = await getTag("tag1");
|
||||
expect(result).toEqual(mockTag);
|
||||
expect(prisma.tag.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "tag1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null when tag is not found", async () => {
|
||||
vi.mocked(prisma.tag.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getTag("nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTag", () => {
|
||||
test("should create a new tag", async () => {
|
||||
const mockTag: TTag = {
|
||||
id: "tag1",
|
||||
name: "New Tag",
|
||||
environmentId: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.tag.create).mockResolvedValue(mockTag);
|
||||
|
||||
const result = await createTag("env1", "New Tag");
|
||||
expect(result).toEqual(mockTag);
|
||||
expect(prisma.tag.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "New Tag",
|
||||
environmentId: "env1",
|
||||
},
|
||||
});
|
||||
expect(tagCache.revalidate).toHaveBeenCalledWith({
|
||||
id: "tag1",
|
||||
environmentId: "env1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,7 @@ import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
188
apps/web/lib/tagOnResponse/service.test.ts
Normal file
188
apps/web/lib/tagOnResponse/service.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { responseCache } from "../response/cache";
|
||||
import { getResponse } from "../response/service";
|
||||
import { tagOnResponseCache } from "./cache";
|
||||
import { addTagToRespone, deleteTagOnResponse, getTagsOnResponsesCount } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
tagsOnResponses: {
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../response/service", () => ({
|
||||
getResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../response/cache", () => ({
|
||||
responseCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
tagOnResponseCache: {
|
||||
revalidate: vi.fn(),
|
||||
tag: {
|
||||
byEnvironmentId: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("TagOnResponse Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("addTagToRespone should add a tag to a response", async () => {
|
||||
const mockResponse = {
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
contact: { id: "contact1" },
|
||||
};
|
||||
|
||||
const mockTagOnResponse = {
|
||||
tag: {
|
||||
environmentId: "env1",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getResponse).mockResolvedValue(mockResponse as any);
|
||||
vi.mocked(prisma.tagsOnResponses.create).mockResolvedValue(mockTagOnResponse as any);
|
||||
|
||||
const result = await addTagToRespone("response1", "tag1");
|
||||
|
||||
expect(result).toEqual({
|
||||
responseId: "response1",
|
||||
tagId: "tag1",
|
||||
});
|
||||
|
||||
expect(prisma.tagsOnResponses.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
responseId: "response1",
|
||||
tagId: "tag1",
|
||||
},
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(responseCache.revalidate).toHaveBeenCalledWith({
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
contactId: "contact1",
|
||||
});
|
||||
|
||||
expect(tagOnResponseCache.revalidate).toHaveBeenCalledWith({
|
||||
tagId: "tag1",
|
||||
responseId: "response1",
|
||||
environmentId: "env1",
|
||||
});
|
||||
});
|
||||
|
||||
test("deleteTagOnResponse should delete a tag from a response", async () => {
|
||||
const mockResponse = {
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
contact: { id: "contact1" },
|
||||
};
|
||||
|
||||
const mockDeletedTag = {
|
||||
tag: {
|
||||
environmentId: "env1",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getResponse).mockResolvedValue(mockResponse as any);
|
||||
vi.mocked(prisma.tagsOnResponses.delete).mockResolvedValue(mockDeletedTag as any);
|
||||
|
||||
const result = await deleteTagOnResponse("response1", "tag1");
|
||||
|
||||
expect(result).toEqual({
|
||||
responseId: "response1",
|
||||
tagId: "tag1",
|
||||
});
|
||||
|
||||
expect(prisma.tagsOnResponses.delete).toHaveBeenCalledWith({
|
||||
where: {
|
||||
responseId_tagId: {
|
||||
responseId: "response1",
|
||||
tagId: "tag1",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(responseCache.revalidate).toHaveBeenCalledWith({
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
contactId: "contact1",
|
||||
});
|
||||
|
||||
expect(tagOnResponseCache.revalidate).toHaveBeenCalledWith({
|
||||
tagId: "tag1",
|
||||
responseId: "response1",
|
||||
environmentId: "env1",
|
||||
});
|
||||
});
|
||||
|
||||
test("getTagsOnResponsesCount should return tag counts for an environment", async () => {
|
||||
const mockTagsCount = [
|
||||
{ tagId: "tag1", _count: { _all: 5 } },
|
||||
{ tagId: "tag2", _count: { _all: 3 } },
|
||||
];
|
||||
|
||||
vi.mocked(prisma.tagsOnResponses.groupBy).mockResolvedValue(mockTagsCount as any);
|
||||
vi.mocked(tagOnResponseCache.tag.byEnvironmentId).mockReturnValue("env1");
|
||||
|
||||
const result = await getTagsOnResponsesCount("env1");
|
||||
|
||||
expect(result).toEqual([
|
||||
{ tagId: "tag1", count: 5 },
|
||||
{ tagId: "tag2", count: 3 },
|
||||
]);
|
||||
|
||||
expect(prisma.tagsOnResponses.groupBy).toHaveBeenCalledWith({
|
||||
by: ["tagId"],
|
||||
where: {
|
||||
response: {
|
||||
survey: {
|
||||
environment: {
|
||||
id: "env1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma operation fails", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.tagsOnResponses.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(addTagToRespone("response1", "tag1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,9 @@
|
||||
and how we can improve it. All data including the IP address is collected anonymously
|
||||
and we cannot trace anything back to you or your customers. If you still want to
|
||||
disable telemetry, set the environment variable TELEMETRY_DISABLED=1 */
|
||||
import { env } from "@/lib/env";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { IS_PRODUCTION } from "./constants";
|
||||
import { env } from "./env";
|
||||
|
||||
const crypto = require("crypto");
|
||||
|
||||
|
||||
131
apps/web/lib/time.test.ts
Normal file
131
apps/web/lib/time.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
convertDateString,
|
||||
convertDateTimeString,
|
||||
convertDateTimeStringShort,
|
||||
convertDatesInObject,
|
||||
convertTimeString,
|
||||
formatDate,
|
||||
getTodaysDateFormatted,
|
||||
getTodaysDateTimeFormatted,
|
||||
timeSince,
|
||||
timeSinceDate,
|
||||
} from "./time";
|
||||
|
||||
describe("Time Utilities", () => {
|
||||
describe("convertDateString", () => {
|
||||
test("should format date string correctly", () => {
|
||||
expect(convertDateString("2024-03-20")).toBe("Mar 20, 2024");
|
||||
});
|
||||
|
||||
test("should return empty string for empty input", () => {
|
||||
expect(convertDateString("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertDateTimeString", () => {
|
||||
test("should format date and time string correctly", () => {
|
||||
expect(convertDateTimeString("2024-03-20T15:30:00")).toBe("Wednesday, March 20, 2024 at 3:30 PM");
|
||||
});
|
||||
|
||||
test("should return empty string for empty input", () => {
|
||||
expect(convertDateTimeString("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertDateTimeStringShort", () => {
|
||||
test("should format date and time string in short format", () => {
|
||||
expect(convertDateTimeStringShort("2024-03-20T15:30:00")).toBe("March 20, 2024 at 3:30 PM");
|
||||
});
|
||||
|
||||
test("should return empty string for empty input", () => {
|
||||
expect(convertDateTimeStringShort("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertTimeString", () => {
|
||||
test("should format time string correctly", () => {
|
||||
expect(convertTimeString("2024-03-20T15:30:45")).toBe("3:30:45 PM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeSince", () => {
|
||||
test("should format time since in English", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "en-US")).toBe("about 1 hour ago");
|
||||
});
|
||||
|
||||
test("should format time since in German", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSince(oneHourAgo.toISOString(), "de-DE")).toBe("vor etwa 1 Stunde");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timeSinceDate", () => {
|
||||
test("should format time since from Date object", () => {
|
||||
const now = new Date();
|
||||
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
expect(timeSinceDate(oneHourAgo)).toBe("about 1 hour ago");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
test("should format date correctly", () => {
|
||||
const date = new Date("2024-03-20");
|
||||
expect(formatDate(date)).toBe("March 20, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTodaysDateFormatted", () => {
|
||||
test("should format today's date with specified separator", () => {
|
||||
const today = new Date();
|
||||
const expected = today.toISOString().split("T")[0].split("-").join(".");
|
||||
expect(getTodaysDateFormatted(".")).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTodaysDateTimeFormatted", () => {
|
||||
test("should format today's date and time with specified separator", () => {
|
||||
const today = new Date();
|
||||
const datePart = today.toISOString().split("T")[0].split("-").join(".");
|
||||
const timePart = today.toTimeString().split(" ")[0].split(":").join(".");
|
||||
const expected = `${datePart}.${timePart}`;
|
||||
expect(getTodaysDateTimeFormatted(".")).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertDatesInObject", () => {
|
||||
test("should convert date strings to Date objects in an object", () => {
|
||||
const input = {
|
||||
id: 1,
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
updatedAt: "2024-03-20T16:30:00",
|
||||
nested: {
|
||||
createdAt: "2024-03-20T17:30:00",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.updatedAt).toBeInstanceOf(Date);
|
||||
expect(result.nested.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.id).toBe(1);
|
||||
});
|
||||
|
||||
test("should handle arrays", () => {
|
||||
const input = [{ createdAt: "2024-03-20T15:30:00" }, { createdAt: "2024-03-20T16:30:00" }];
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
expect(result[0].createdAt).toBeInstanceOf(Date);
|
||||
expect(result[1].createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
test("should return non-objects as is", () => {
|
||||
expect(convertDatesInObject(null)).toBe(null);
|
||||
expect(convertDatesInObject("string")).toBe("string");
|
||||
expect(convertDatesInObject(123)).toBe(123);
|
||||
});
|
||||
});
|
||||
});
|
||||
271
apps/web/lib/user/service.test.ts
Normal file
271
apps/web/lib/user/service.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { IdentityProvider, Objective, Prisma, Role } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
|
||||
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/fileValidation", () => ({
|
||||
isValidImageFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
|
||||
deleteOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("User Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockPrismaUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: Role.project_manager,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: IdentityProvider.email,
|
||||
objective: Objective.increase_conversion,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
locale: "en-US" as TUserLocale,
|
||||
lastLoginAt: new Date(),
|
||||
isActive: true,
|
||||
twoFactorSecret: null,
|
||||
backupCodes: null,
|
||||
password: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
};
|
||||
|
||||
const mockOrganizations: TOrganization[] = [
|
||||
{
|
||||
id: "org1",
|
||||
name: "Organization 1",
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: "org2",
|
||||
name: "Organization 2",
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
describe("getUser", () => {
|
||||
test("should return user when found", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockPrismaUser);
|
||||
|
||||
const result = await getUser("user1");
|
||||
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null when user not found", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getUser("nonexistent");
|
||||
|
||||
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.user.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getUser("user1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserByEmail", () => {
|
||||
test("should return user when found by email", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(mockPrismaUser);
|
||||
|
||||
const result = await getUserByEmail("test@example.com");
|
||||
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
||||
where: { email: "test@example.com" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null when user not found by email", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await getUserByEmail("nonexistent@example.com");
|
||||
|
||||
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.user.findFirst).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getUserByEmail("test@example.com")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateUser", () => {
|
||||
test("should update user successfully", async () => {
|
||||
const updatedPrismaUser = {
|
||||
...mockPrismaUser,
|
||||
name: "Updated User",
|
||||
};
|
||||
|
||||
const updateData: TUserUpdateInput = {
|
||||
name: "Updated User",
|
||||
};
|
||||
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(updatedPrismaUser);
|
||||
|
||||
const result = await updateUser("user1", updateData);
|
||||
|
||||
expect(result).toEqual(updatedPrismaUser);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
data: updateData,
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when user not found", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.user.update).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(updateUser("nonexistent", { name: "New Name" })).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError when invalid image URL is provided", async () => {
|
||||
const { isValidImageFile } = await import("@/lib/fileValidation");
|
||||
vi.mocked(isValidImageFile).mockReturnValue(false);
|
||||
|
||||
await expect(updateUser("user1", { imageUrl: "invalid-image-url" })).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteUser", () => {
|
||||
test("should delete user and their organizations when they are single owner", async () => {
|
||||
vi.mocked(prisma.user.delete).mockResolvedValue(mockPrismaUser);
|
||||
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValue(mockOrganizations);
|
||||
vi.mocked(deleteOrganization).mockResolvedValue();
|
||||
|
||||
const result = await deleteUser("user1");
|
||||
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("user1");
|
||||
expect(deleteOrganization).toHaveBeenCalledWith("org1");
|
||||
expect(prisma.user.delete).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValue([]);
|
||||
vi.mocked(prisma.user.delete).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteUser("user1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsersWithOrganization", () => {
|
||||
test("should return users in an organization", async () => {
|
||||
const mockUsers = [mockPrismaUser];
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers);
|
||||
|
||||
const result = await getUsersWithOrganization("org1");
|
||||
|
||||
expect(result).toEqual(mockUsers);
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
memberships: {
|
||||
some: {
|
||||
organizationId: "org1",
|
||||
},
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.user.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getUsersWithOrganization("org1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
apps/web/lib/utils/rate-limit.test.ts
Normal file
58
apps/web/lib/utils/rate-limit.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
describe("in-memory rate limiter", () => {
|
||||
test("allows requests within limit and throws after limit", async () => {
|
||||
const { rateLimit } = await import("./rate-limit");
|
||||
const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 });
|
||||
await expect(limiterFn("a")).resolves.toBeUndefined();
|
||||
await expect(limiterFn("a")).resolves.toBeUndefined();
|
||||
await expect(limiterFn("a")).rejects.toThrow("Rate limit exceeded");
|
||||
});
|
||||
|
||||
test("separate tokens have separate counts", async () => {
|
||||
const { rateLimit } = await import("./rate-limit");
|
||||
const limiterFn = rateLimit({ interval: 1, allowedPerInterval: 2 });
|
||||
await expect(limiterFn("x")).resolves.toBeUndefined();
|
||||
await expect(limiterFn("y")).resolves.toBeUndefined();
|
||||
await expect(limiterFn("x")).resolves.toBeUndefined();
|
||||
await expect(limiterFn("y")).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("redis rate limiter", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...constants,
|
||||
REDIS_HTTP_URL: "http://redis",
|
||||
}));
|
||||
});
|
||||
|
||||
test("sets expire on first use and does not throw", async () => {
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 1 }) })
|
||||
.mockResolvedValueOnce({ ok: true });
|
||||
const { rateLimit } = await import("./rate-limit");
|
||||
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
|
||||
await expect(limiter("t")).resolves.toBeUndefined();
|
||||
expect(fetch).toHaveBeenCalledTimes(2);
|
||||
expect(fetch).toHaveBeenCalledWith("http://redis/INCR/t");
|
||||
expect(fetch).toHaveBeenCalledWith("http://redis/EXPIRE/t/10");
|
||||
});
|
||||
|
||||
test("does not throw when redis INCR response not ok", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({ ok: false });
|
||||
const { rateLimit } = await import("./rate-limit");
|
||||
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
|
||||
await expect(limiter("t")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("throws when INCR exceeds limit", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValueOnce({ ok: true, json: async () => ({ INCR: 3 }) });
|
||||
const { rateLimit } = await import("./rate-limit");
|
||||
const limiter = rateLimit({ interval: 10, allowedPerInterval: 2 });
|
||||
await expect(limiter("t")).rejects.toThrow("Rate limit exceeded for IP: t");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ENTERPRISE_LICENSE_KEY, REDIS_HTTP_URL } from "@/lib/constants";
|
||||
import { REDIS_HTTP_URL } from "@/lib/constants";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
@@ -13,7 +13,7 @@ const inMemoryRateLimiter = (options: Options) => {
|
||||
ttl: options.interval * 1000, // converts to expected input of milliseconds
|
||||
});
|
||||
|
||||
return (token: string) => {
|
||||
return async (token: string) => {
|
||||
const currentUsage = tokenCache.get(token) ?? 0;
|
||||
if (currentUsage >= options.allowedPerInterval) {
|
||||
throw new Error("Rate limit exceeded");
|
||||
@@ -40,12 +40,13 @@ const redisRateLimiter = (options: Options) => async (token: string) => {
|
||||
throw new Error();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error({ error: e }, "Rate limit exceeded");
|
||||
throw new Error("Rate limit exceeded for IP: " + token);
|
||||
}
|
||||
};
|
||||
|
||||
export const rateLimit = (options: Options) => {
|
||||
if (REDIS_HTTP_URL && ENTERPRISE_LICENSE_KEY) {
|
||||
if (REDIS_HTTP_URL) {
|
||||
return redisRateLimiter(options);
|
||||
} else {
|
||||
return inMemoryRateLimiter(options);
|
||||
@@ -67,24 +67,24 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyRateLimiting = (request: NextRequest, ip: string) => {
|
||||
const applyRateLimiting = async (request: NextRequest, ip: string) => {
|
||||
if (isLoginRoute(request.nextUrl.pathname)) {
|
||||
loginLimiter(`login-${ip}`);
|
||||
await loginLimiter(`login-${ip}`);
|
||||
} else if (isSignupRoute(request.nextUrl.pathname)) {
|
||||
signupLimiter(`signup-${ip}`);
|
||||
await signupLimiter(`signup-${ip}`);
|
||||
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
|
||||
verifyEmailLimiter(`verify-email-${ip}`);
|
||||
await verifyEmailLimiter(`verify-email-${ip}`);
|
||||
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
|
||||
forgotPasswordLimiter(`forgot-password-${ip}`);
|
||||
await forgotPasswordLimiter(`forgot-password-${ip}`);
|
||||
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
|
||||
clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
|
||||
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
|
||||
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
|
||||
if (envIdAndUserId) {
|
||||
const { environmentId, userId } = envIdAndUserId;
|
||||
syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
|
||||
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
|
||||
}
|
||||
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
|
||||
shareUrlLimiter(`share-${ip}`);
|
||||
await shareUrlLimiter(`share-${ip}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -153,7 +153,7 @@ export const middleware = async (originalRequest: NextRequest) => {
|
||||
|
||||
if (ip) {
|
||||
try {
|
||||
applyRateLimiting(request, ip);
|
||||
await applyRateLimiting(request, ip);
|
||||
return nextResponseWithCustomHeader;
|
||||
} catch (e) {
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/modules/ui/components/response-badges", () => ({
|
||||
ResponseBadges: ({ items }: any) => <div data-testid="ResponseBadges">{items.join(",")}</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/ranking-response", () => ({
|
||||
RankingRespone: ({ value }: any) => <div data-testid="RankingRespone">{value.join(",")}</div>,
|
||||
RankingResponse: ({ value }: any) => <div data-testid="RankingResponse">{value.join(",")}</div>,
|
||||
}));
|
||||
vi.mock("@/modules/analysis/utils", () => ({
|
||||
renderHyperlinkedContent: vi.fn((text: string) => "hyper:" + text),
|
||||
@@ -236,7 +236,7 @@ describe("RenderResponse", () => {
|
||||
expect(screen.getByTestId("ResponseBadges")).toHaveTextContent("9");
|
||||
});
|
||||
|
||||
test("renders RankingRespone for 'Ranking' question", () => {
|
||||
test("renders RankingResponse for 'Ranking' question", () => {
|
||||
const question = { ...defaultQuestion, type: "ranking" };
|
||||
render(
|
||||
<RenderResponse
|
||||
@@ -246,7 +246,7 @@ describe("RenderResponse", () => {
|
||||
language={dummyLanguage}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("RankingRespone")).toHaveTextContent("first,second");
|
||||
expect(screen.getByTestId("RankingResponse")).toHaveTextContent("first,second");
|
||||
});
|
||||
|
||||
test("renders default branch for unknown question type with string", () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { ArrayResponse } from "@/modules/ui/components/array-response";
|
||||
import { FileUploadResponse } from "@/modules/ui/components/file-upload-response";
|
||||
import { PictureSelectionResponse } from "@/modules/ui/components/picture-selection-response";
|
||||
import { RankingRespone } from "@/modules/ui/components/ranking-response";
|
||||
import { RankingResponse } from "@/modules/ui/components/ranking-response";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||
import { CheckCheckIcon, MousePointerClickIcon, PhoneIcon } from "lucide-react";
|
||||
@@ -161,7 +161,7 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
break;
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
if (Array.isArray(responseData)) {
|
||||
return <RankingRespone value={responseData} isExpanded={isExpanded} />;
|
||||
return <RankingResponse value={responseData} isExpanded={isExpanded} />;
|
||||
}
|
||||
default:
|
||||
if (
|
||||
|
||||
29
apps/web/modules/api/v2/roles/lib/utils.test.ts
Normal file
29
apps/web/modules/api/v2/roles/lib/utils.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as constants from "@/lib/constants";
|
||||
import { OrganizationRole } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { getRoles } from "./utils";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
}));
|
||||
|
||||
describe("getRoles", () => {
|
||||
test("should return all roles except billing when not in Formbricks Cloud", () => {
|
||||
const result = getRoles();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.data).toEqual(Object.values(OrganizationRole).filter((role) => role !== "billing"));
|
||||
}
|
||||
});
|
||||
|
||||
test("should return all roles including billing when in Formbricks Cloud", () => {
|
||||
const originalValue = constants.IS_FORMBRICKS_CLOUD;
|
||||
Object.defineProperty(constants, "IS_FORMBRICKS_CLOUD", { value: true });
|
||||
const result = getRoles();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.data).toEqual(Object.values(OrganizationRole));
|
||||
}
|
||||
Object.defineProperty(constants, "IS_FORMBRICKS_CLOUD", { value: originalValue });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { BackToLoginButton } from "./back-to-login-button";
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => <a href={href}>{children}</a>,
|
||||
}));
|
||||
|
||||
describe("BackToLoginButton", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders login button with correct link and translation", async () => {
|
||||
const mockTranslate = vi.mocked(getTranslate);
|
||||
const mockT: TFnType = (key) => {
|
||||
if (key === "auth.signup.log_in") return "Back to Login";
|
||||
return key;
|
||||
};
|
||||
mockTranslate.mockResolvedValue(mockT);
|
||||
|
||||
render(await BackToLoginButton());
|
||||
|
||||
const link = screen.getByRole("link", { name: "Back to Login" });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "/auth/login");
|
||||
});
|
||||
});
|
||||
55
apps/web/modules/auth/components/form-wrapper.test.tsx
Normal file
55
apps/web/modules/auth/components/form-wrapper.test.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { FormWrapper } from "./form-wrapper";
|
||||
|
||||
vi.mock("@/modules/ui/components/logo", () => ({
|
||||
Logo: () => <div data-testid="mock-logo">Logo</div>,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}) => (
|
||||
<a href={href} target={target} rel={rel} data-testid="mock-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("FormWrapper", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders logo and children content", () => {
|
||||
render(
|
||||
<FormWrapper>
|
||||
<div data-testid="test-content">Test Content</div>
|
||||
</FormWrapper>
|
||||
);
|
||||
|
||||
// Check if logo is rendered
|
||||
const logo = screen.getByTestId("mock-logo");
|
||||
expect(logo).toBeInTheDocument();
|
||||
|
||||
// Check if logo link has correct attributes
|
||||
const logoLink = screen.getByTestId("mock-link");
|
||||
expect(logoLink).toHaveAttribute("href", "https://formbricks.com?utm_source=ce");
|
||||
expect(logoLink).toHaveAttribute("target", "_blank");
|
||||
expect(logoLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
|
||||
// Check if children content is rendered
|
||||
const content = screen.getByTestId("test-content");
|
||||
expect(content).toBeInTheDocument();
|
||||
expect(content).toHaveTextContent("Test Content");
|
||||
});
|
||||
});
|
||||
59
apps/web/modules/auth/components/testimonial.test.tsx
Normal file
59
apps/web/modules/auth/components/testimonial.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { Testimonial } from "./testimonial";
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: ({ src, alt }: { src: string; alt: string }) => (
|
||||
<img src={src} alt={alt} data-testid="mock-image" />
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Testimonial", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders testimonial content with translations", async () => {
|
||||
const mockTranslate = vi.mocked(getTranslate);
|
||||
const mockT: TFnType = (key) => {
|
||||
const translations: Record<string, string> = {
|
||||
"auth.testimonial_title": "Testimonial Title",
|
||||
"auth.testimonial_all_features_included": "All features included",
|
||||
"auth.testimonial_free_and_open_source": "Free and open source",
|
||||
"auth.testimonial_no_credit_card_required": "No credit card required",
|
||||
"auth.testimonial_1": "Test testimonial quote",
|
||||
};
|
||||
return translations[key] || key;
|
||||
};
|
||||
mockTranslate.mockResolvedValue(mockT);
|
||||
|
||||
render(await Testimonial());
|
||||
|
||||
// Check title
|
||||
expect(screen.getByText("Testimonial Title")).toBeInTheDocument();
|
||||
|
||||
// Check feature points
|
||||
expect(screen.getByText("All features included")).toBeInTheDocument();
|
||||
expect(screen.getByText("Free and open source")).toBeInTheDocument();
|
||||
expect(screen.getByText("No credit card required")).toBeInTheDocument();
|
||||
|
||||
// Check testimonial quote
|
||||
expect(screen.getByText("Test testimonial quote")).toBeInTheDocument();
|
||||
|
||||
// Check testimonial author
|
||||
expect(screen.getByText("Peer Richelsen, Co-Founder Cal.com")).toBeInTheDocument();
|
||||
|
||||
// Check images
|
||||
const images = screen.getAllByTestId("mock-image");
|
||||
expect(images).toHaveLength(2);
|
||||
expect(images[0]).toHaveAttribute("alt", "Cal.com Co-Founder Peer Richelsen");
|
||||
expect(images[1]).toHaveAttribute("alt", "Cal.com Logo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { EmailSentPage } from "./page";
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/components/back-to-login-button", () => ({
|
||||
BackToLoginButton: () => <div>Back to Login</div>,
|
||||
}));
|
||||
|
||||
describe("EmailSentPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the email sent page with correct translations", async () => {
|
||||
render(await EmailSentPage());
|
||||
|
||||
expect(screen.getByText("auth.forgot-password.email-sent.heading")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth.forgot-password.email-sent.text")).toBeInTheDocument();
|
||||
expect(screen.getByText("Back to Login")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
27
apps/web/modules/auth/forgot-password/page.test.tsx
Normal file
27
apps/web/modules/auth/forgot-password/page.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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/components/form-wrapper", () => ({
|
||||
FormWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="form-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/components/forgot-password-form", () => ({
|
||||
ForgotPasswordForm: () => <div data-testid="forgot-password-form">Forgot Password Form</div>,
|
||||
}));
|
||||
|
||||
describe("ForgotPasswordPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the forgot password page with form wrapper and form", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
|
||||
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("forgot-password-form")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { resetPasswordAction } from "@/modules/auth/forgot-password/reset/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ResetPasswordForm } from "./reset-password-form";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
useSearchParams: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/reset/actions", () => ({
|
||||
resetPasswordAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ResetPasswordForm", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockRouter = {
|
||||
push: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
};
|
||||
|
||||
const mockSearchParams = {
|
||||
get: vi.fn(),
|
||||
append: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
set: vi.fn(),
|
||||
sort: vi.fn(),
|
||||
toString: vi.fn(),
|
||||
forEach: vi.fn(),
|
||||
entries: vi.fn(),
|
||||
keys: vi.fn(),
|
||||
values: vi.fn(),
|
||||
has: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
||||
vi.mocked(useSearchParams).mockReturnValue(mockSearchParams as any);
|
||||
vi.mocked(mockSearchParams.get).mockReturnValue("test-token");
|
||||
});
|
||||
|
||||
test("renders the form with password fields", () => {
|
||||
render(<ResetPasswordForm />);
|
||||
|
||||
expect(screen.getByLabelText("auth.forgot-password.reset.new_password")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("auth.forgot-password.reset.confirm_password")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "auth.forgot-password.reset_password" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error when passwords do not match", async () => {
|
||||
render(<ResetPasswordForm />);
|
||||
|
||||
const passwordInput = screen.getByLabelText("auth.forgot-password.reset.new_password");
|
||||
const confirmPasswordInput = screen.getByLabelText("auth.forgot-password.reset.confirm_password");
|
||||
|
||||
await userEvent.type(passwordInput, "Password123!");
|
||||
await userEvent.type(confirmPasswordInput, "Different123!");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("auth.forgot-password.reset.passwords_do_not_match");
|
||||
});
|
||||
});
|
||||
|
||||
test("successfully resets password and redirects", async () => {
|
||||
vi.mocked(resetPasswordAction).mockResolvedValueOnce({ data: { success: true } });
|
||||
|
||||
render(<ResetPasswordForm />);
|
||||
|
||||
const passwordInput = screen.getByLabelText("auth.forgot-password.reset.new_password");
|
||||
const confirmPasswordInput = screen.getByLabelText("auth.forgot-password.reset.confirm_password");
|
||||
|
||||
await userEvent.type(passwordInput, "Password123!");
|
||||
await userEvent.type(confirmPasswordInput, "Password123!");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(resetPasswordAction).toHaveBeenCalledWith({
|
||||
token: "test-token",
|
||||
password: "Password123!",
|
||||
});
|
||||
expect(mockRouter.push).toHaveBeenCalledWith("/auth/forgot-password/reset/success");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows error when no token is provided", async () => {
|
||||
vi.mocked(mockSearchParams.get).mockReturnValueOnce(null);
|
||||
|
||||
render(<ResetPasswordForm />);
|
||||
|
||||
const passwordInput = screen.getByLabelText("auth.forgot-password.reset.new_password");
|
||||
const confirmPasswordInput = screen.getByLabelText("auth.forgot-password.reset.confirm_password");
|
||||
|
||||
await userEvent.type(passwordInput, "Password123!");
|
||||
await userEvent.type(confirmPasswordInput, "Password123!");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "auth.forgot-password.reset_password" });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("auth.forgot-password.reset.no_token_provided");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { ResetPasswordSuccessPage } from "./page";
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/components/back-to-login-button", () => ({
|
||||
BackToLoginButton: () => <button>Back to Login</button>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/components/form-wrapper", () => ({
|
||||
FormWrapper: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
describe("ResetPasswordSuccessPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders success page with correct translations", async () => {
|
||||
render(await ResetPasswordSuccessPage());
|
||||
|
||||
expect(screen.getByText("auth.forgot-password.reset.success.heading")).toBeInTheDocument();
|
||||
expect(screen.getByText("auth.forgot-password.reset.success.text")).toBeInTheDocument();
|
||||
expect(screen.getByText("Back to Login")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { ContentLayout } from "./content-layout";
|
||||
|
||||
describe("ContentLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders headline and description", () => {
|
||||
render(<ContentLayout headline="Test Headline" description="Test Description" />);
|
||||
|
||||
expect(screen.getByText("Test Headline")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders children when provided", () => {
|
||||
render(
|
||||
<ContentLayout headline="Test Headline" description="Test Description">
|
||||
<div>Test Child</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Child")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user