test: backfill variety of test files (#5729)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Johannes
2025-05-09 00:26:41 -07:00
committed by GitHub
parent 3f7dafb65c
commit 0f0b743a10
49 changed files with 5317 additions and 170 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { loginLimiter, signupLimiter } from "./bucket";
// Mock constants
vi.mock("@/lib/constants", () => ({
ENTERPRISE_LICENSE_KEY: undefined,
REDIS_HTTP_URL: undefined,
LOGIN_RATE_LIMIT: {
interval: 15 * 60,
allowedPerInterval: 5,
},
SIGNUP_RATE_LIMIT: {
interval: 60 * 60,
allowedPerInterval: 5,
},
VERIFY_EMAIL_RATE_LIMIT: {
interval: 60 * 60,
allowedPerInterval: 5,
},
FORGET_PASSWORD_RATE_LIMIT: {
interval: 60 * 60,
allowedPerInterval: 5,
},
CLIENT_SIDE_API_RATE_LIMIT: {
interval: 60,
allowedPerInterval: 5,
},
SHARE_RATE_LIMIT: {
interval: 60,
allowedPerInterval: 5,
},
SYNC_USER_IDENTIFICATION_RATE_LIMIT: {
interval: 60,
allowedPerInterval: 5,
},
}));
describe("Rate Limiters", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("loginLimiter allows requests within limit", () => {
const token = "test-token-1";
// Should not throw for first request
expect(() => loginLimiter(token)).not.toThrow();
});
test("loginLimiter throws when limit exceeded", () => {
const token = "test-token-2";
// Make multiple requests to exceed the limit
for (let i = 0; i < 5; i++) {
expect(() => loginLimiter(token)).not.toThrow();
}
// Next request should throw
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded");
});
test("different limiters use different counters", () => {
const token = "test-token-3";
// Exceed login limit
for (let i = 0; i < 5; i++) {
expect(() => loginLimiter(token)).not.toThrow();
}
// Should throw for login
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded");
// Should still be able to use signup limiter
expect(() => signupLimiter(token)).not.toThrow();
});
});

View File

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

View File

@@ -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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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",
});
});
});
});

View File

@@ -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";

View 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);
});
});

View 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);
});
});
});

View 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 });
});
});

View File

@@ -0,0 +1,129 @@
import { getResponsesByContactId } from "@/lib/response/service";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TResponse } from "@formbricks/types/responses";
import { AttributesSection } from "./attributes-section";
vi.mock("@/lib/response/service", () => ({
getResponsesByContactId: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/lib/contact-attributes", () => ({
getContactAttributes: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/lib/contacts", () => ({
getContact: vi.fn(),
}));
const mockGetTranslate = vi.fn(async () => (key: string) => key);
vi.mock("@/tolgee/server", () => ({
getTranslate: () => mockGetTranslate(),
}));
describe("AttributesSection", () => {
afterEach(() => {
cleanup();
});
test("renders contact attributes correctly", async () => {
const mockContact = {
id: "test-contact-id",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env",
};
const mockAttributes = {
email: "test@example.com",
language: "en",
userId: "test-user",
name: "Test User",
};
const mockResponses: TResponse[] = [
{
id: "response1",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey1",
finished: true,
data: {},
meta: {},
ttc: {},
variables: {},
contactAttributes: {},
singleUseId: null,
contact: null,
language: null,
tags: [],
notes: [],
endingId: null,
displayId: null,
},
{
id: "response2",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey1",
finished: true,
data: {},
meta: {},
ttc: {},
variables: {},
contactAttributes: {},
singleUseId: null,
contact: null,
language: null,
tags: [],
notes: [],
endingId: null,
displayId: null,
},
];
vi.mocked(getContact).mockResolvedValue(mockContact);
vi.mocked(getContactAttributes).mockResolvedValue(mockAttributes);
vi.mocked(getResponsesByContactId).mockResolvedValue(mockResponses);
const { container } = render(await AttributesSection({ contactId: "test-contact-id" }));
expect(screen.getByText("common.attributes")).toBeInTheDocument();
expect(screen.getByText("test@example.com")).toBeInTheDocument();
expect(screen.getByText("en")).toBeInTheDocument();
expect(screen.getByText("test-user")).toBeInTheDocument();
expect(screen.getByText("test-contact-id")).toBeInTheDocument();
expect(screen.getByText("Test User")).toBeInTheDocument();
expect(screen.getByText("2")).toBeInTheDocument();
});
test("shows not provided text when attributes are missing", async () => {
const mockContact = {
id: "test-contact-id",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env",
};
const mockAttributes = {
email: "",
language: "",
userId: "",
};
const mockResponses: TResponse[] = [];
vi.mocked(getContact).mockResolvedValue(mockContact);
vi.mocked(getContactAttributes).mockResolvedValue(mockAttributes);
vi.mocked(getResponsesByContactId).mockResolvedValue(mockResponses);
render(await AttributesSection({ contactId: "test-contact-id" }));
const notProvidedElements = screen.getAllByText("environments.contacts.not_provided");
expect(notProvidedElements).toHaveLength(3);
});
});

View File

@@ -0,0 +1,124 @@
import { deleteContactAction } from "@/modules/ee/contacts/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useTranslate } from "@tolgee/react";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DeleteContactButton } from "./delete-contact-button";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/actions", () => ({
deleteContactAction: vi.fn(),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((result: any) => {
if (result.serverError) return result.serverError;
if (result.validationErrors) {
return Object.entries(result.validationErrors)
.map(([key, value]: [string, any]) => {
if (key === "_errors") return Array.isArray(value) ? value.join(", ") : "";
return `${key}${value?._errors?.join(", ") || ""}`;
})
.join("\n");
}
return "Unknown error";
}),
}));
describe("DeleteContactButton", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockRouter: Partial<AppRouterInstance> = {
refresh: vi.fn(),
push: vi.fn(),
};
const mockTranslate = {
t: vi.fn((key) => key),
isLoading: false,
};
beforeEach(() => {
vi.mocked(useRouter).mockReturnValue(mockRouter as AppRouterInstance);
vi.mocked(useTranslate).mockReturnValue(mockTranslate);
});
test("should not render when isReadOnly is true", () => {
render(<DeleteContactButton environmentId="env-123" contactId="contact-123" isReadOnly={true} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
test("should render delete button when isReadOnly is false", () => {
render(<DeleteContactButton environmentId="env-123" contactId="contact-123" isReadOnly={false} />);
expect(screen.getByRole("button")).toBeInTheDocument();
});
test("should open delete dialog when clicking delete button", async () => {
const user = userEvent.setup();
render(<DeleteContactButton environmentId="env-123" contactId="contact-123" isReadOnly={false} />);
await user.click(screen.getByRole("button"));
expect(screen.getByText("common.delete person")).toBeInTheDocument();
});
test("should handle successful contact deletion", async () => {
const user = userEvent.setup();
vi.mocked(deleteContactAction).mockResolvedValue({
data: {
environmentId: "env-123",
id: "contact-123",
createdAt: new Date(),
updatedAt: new Date(),
},
});
render(<DeleteContactButton environmentId="env-123" contactId="contact-123" isReadOnly={false} />);
await user.click(screen.getByRole("button"));
await user.click(screen.getByText("common.delete"));
expect(deleteContactAction).toHaveBeenCalledWith({ contactId: "contact-123" });
expect(mockRouter.refresh).toHaveBeenCalled();
expect(mockRouter.push).toHaveBeenCalledWith("/environments/env-123/contacts");
expect(toast.success).toHaveBeenCalledWith("environments.contacts.contact_deleted_successfully");
});
test("should handle failed contact deletion", async () => {
const user = userEvent.setup();
const errorResponse = {
serverError: "Failed to delete contact",
};
vi.mocked(deleteContactAction).mockResolvedValue(errorResponse);
render(<DeleteContactButton environmentId="env-123" contactId="contact-123" isReadOnly={false} />);
await user.click(screen.getByRole("button"));
await user.click(screen.getByText("common.delete"));
expect(deleteContactAction).toHaveBeenCalledWith({ contactId: "contact-123" });
expect(toast.error).toHaveBeenCalledWith("Failed to delete contact");
});
});

View File

@@ -0,0 +1,148 @@
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { ResponseFeed } from "./response-feed";
// Mock the hooks and components
vi.mock("@/lib/membership/hooks/useMembershipRole", () => ({
useMembershipRole: () => ({
membershipRole: "owner",
}),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: TSurvey) => survey,
}));
vi.mock("@/modules/analysis/components/SingleResponseCard", () => ({
SingleResponseCard: ({ response }: { response: TResponse }) => (
<div data-testid="single-response-card">{response.id}</div>
),
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: () => <div data-testid="empty-space-filler">No responses</div>,
}));
describe("ResponseFeed", () => {
afterEach(() => {
cleanup();
});
const mockProps = {
surveys: [
{
id: "survey1",
name: "Test Survey",
environmentId: "env1",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
type: "link",
createdBy: null,
status: "draft",
autoClose: null,
triggers: [],
redirectUrl: null,
recontactDays: null,
welcomeCard: {
enabled: false,
headline: "",
html: "",
},
verifyEmail: {
name: "",
subheading: "",
},
closeOnDate: null,
displayLimit: null,
autoComplete: null,
runOnDate: null,
productOverwrites: null,
styling: null,
pin: null,
endings: [],
hiddenFields: {},
variables: [],
followUps: [],
thankYouCard: {
enabled: false,
headline: "",
subheader: "",
},
delay: 0,
displayPercentage: 100,
surveyClosedMessage: "",
singleUse: {
enabled: false,
heading: "",
subheading: "",
},
attributeFilters: [],
responseCount: 0,
displayOption: "displayOnce",
recurring: {
enabled: false,
frequency: 0,
},
language: "en",
isDraft: true,
} as unknown as TSurvey,
],
user: {
id: "user1",
} as TUser,
responses: [
{
id: "response1",
surveyId: "survey1",
} as TResponse,
],
environment: {
id: "env1",
} as TEnvironment,
environmentTags: [] as TTag[],
locale: "en" as TUserLocale,
projectPermission: null as TTeamPermission | null,
};
test("renders empty state when no responses", () => {
render(<ResponseFeed {...mockProps} responses={[]} />);
expect(screen.getByTestId("empty-space-filler")).toBeInTheDocument();
});
test("renders response cards when responses exist", () => {
render(<ResponseFeed {...mockProps} />);
expect(screen.getByTestId("single-response-card")).toBeInTheDocument();
expect(screen.getByText("response1")).toBeInTheDocument();
});
test("updates responses when deleteResponses is called", () => {
const { rerender } = render(<ResponseFeed {...mockProps} />);
expect(screen.getByText("response1")).toBeInTheDocument();
// Simulate response deletion
rerender(<ResponseFeed {...mockProps} responses={[]} />);
expect(screen.getByTestId("empty-space-filler")).toBeInTheDocument();
});
test("updates single response when updateResponse is called", () => {
const updatedResponse = {
...mockProps.responses[0],
id: "response1-updated",
} as TResponse;
const { rerender } = render(<ResponseFeed {...mockProps} />);
expect(screen.getByText("response1")).toBeInTheDocument();
// Simulate response update
rerender(<ResponseFeed {...mockProps} responses={[updatedResponse]} />);
expect(screen.getByText("response1-updated")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,190 @@
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getResponsesByContactId } from "@/lib/response/service";
import { getSurveys } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
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 { getServerSession } from "next-auth";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TTag } from "@formbricks/types/tags";
import { ResponseSection } from "./response-section";
vi.mock("@/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/response/service", () => ({
getResponsesByContactId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("./response-timeline", () => ({
ResponseTimeline: () => <div data-testid="response-timeline">Response Timeline</div>,
}));
describe("ResponseSection", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockEnvironment: TEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "project1",
appSetupCompleted: true,
};
const mockProps = {
environment: mockEnvironment,
contactId: "contact1",
environmentTags: [] as TTag[],
};
test("renders ResponseTimeline component when all data is available", async () => {
const mockSession = {
user: { id: "user1" },
};
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
};
const mockResponses = [
{
id: "response1",
surveyId: "survey1",
},
];
const mockSurveys = [
{
id: "survey1",
name: "Test Survey",
},
];
const mockProject = {
id: "project1",
};
const mockProjectPermission = {
role: "owner",
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
vi.mocked(getUser).mockResolvedValue(mockUser as any);
vi.mocked(getResponsesByContactId).mockResolvedValue(mockResponses as any);
vi.mocked(getSurveys).mockResolvedValue(mockSurveys as any);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission as any);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
vi.mocked(getTranslate).mockResolvedValue({
t: (key: string) => key,
} as any);
const { container } = render(await ResponseSection(mockProps));
expect(screen.getByTestId("response-timeline")).toBeInTheDocument();
});
test("throws error when session is not found", async () => {
vi.mocked(getServerSession).mockResolvedValue(null);
vi.mocked(getTranslate).mockResolvedValue(((key: string) => key) as TFnType);
await expect(ResponseSection(mockProps)).rejects.toThrow("common.session_not_found");
});
test("throws error when user is not found", async () => {
const mockSession = {
user: { id: "user1" },
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
vi.mocked(getUser).mockResolvedValue(null);
vi.mocked(getTranslate).mockResolvedValue(((key: string) => key) as TFnType);
await expect(ResponseSection(mockProps)).rejects.toThrow("common.user_not_found");
});
test("throws error when no responses are found", async () => {
const mockSession = {
user: { id: "user1" },
};
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
};
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
vi.mocked(getUser).mockResolvedValue(mockUser as any);
vi.mocked(getResponsesByContactId).mockResolvedValue(null);
vi.mocked(getTranslate).mockResolvedValue(((key: string) => key) as TFnType);
await expect(ResponseSection(mockProps)).rejects.toThrow("environments.contacts.no_responses_found");
});
test("throws error when project is not found", async () => {
const mockSession = {
user: { id: "user1" },
};
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
};
const mockResponses = [
{
id: "response1",
surveyId: "survey1",
},
];
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
vi.mocked(getUser).mockResolvedValue(mockUser as any);
vi.mocked(getResponsesByContactId).mockResolvedValue(mockResponses as any);
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
vi.mocked(getTranslate).mockResolvedValue(((key: string) => key) as TFnType);
await expect(ResponseSection(mockProps)).rejects.toThrow("common.project_not_found");
});
});

View File

@@ -0,0 +1,101 @@
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { useTranslate } from "@tolgee/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser } from "@formbricks/types/user";
import { ResponseTimeline } from "./response-timeline";
vi.mock("@tolgee/react", () => ({
useTranslate: vi.fn(),
}));
vi.mock("./response-feed", () => ({
ResponseFeed: () => <div data-testid="response-feed">Response Feed</div>,
}));
describe("ResponseTimeline", () => {
afterEach(() => {
cleanup();
});
const mockUser: TUser = {
id: "user1",
name: "Test User",
createdAt: new Date(),
updatedAt: new Date(),
imageUrl: null,
objective: null,
role: "founder",
email: "test@example.com",
emailVerified: new Date(),
twoFactorEnabled: false,
identityProvider: "email",
isActive: true,
notificationSettings: {
alert: {},
weeklySummary: {},
},
locale: "en-US",
lastLoginAt: new Date(),
};
const mockResponse: TResponse = {
id: "response1",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey1",
contact: null,
contactAttributes: null,
finished: true,
data: {},
meta: {},
variables: {},
singleUseId: null,
language: "en",
ttc: {},
notes: [],
tags: [],
};
const mockEnvironment: TEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "project1",
appSetupCompleted: true,
};
const mockProps = {
surveys: [] as TSurvey[],
user: mockUser,
responses: [mockResponse, { ...mockResponse, id: "response2" }],
environment: mockEnvironment,
environmentTags: [] as TTag[],
locale: "en-US" as const,
projectPermission: null as TTeamPermission | null,
};
test("renders the component with responses title", () => {
vi.mocked(useTranslate).mockReturnValue({
t: (key: string) => key,
} as any);
render(<ResponseTimeline {...mockProps} />);
expect(screen.getByText("common.responses")).toBeInTheDocument();
});
test("renders ResponseFeed component", () => {
vi.mocked(useTranslate).mockReturnValue({
t: (key: string) => key,
} as any);
render(<ResponseTimeline {...mockProps} />);
expect(screen.getByTestId("response-feed")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,80 @@
import { inviteCache } from "@/lib/cache/invite";
import { OrganizationRole, Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { updateInvite } from "./invite";
vi.mock("@formbricks/database", () => ({
prisma: {
invite: {
update: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/invite", () => ({
inviteCache: {
revalidate: vi.fn(),
},
}));
describe("invite.ts", () => {
afterEach(() => {
vi.resetAllMocks();
});
describe("updateInvite", () => {
test("should successfully update an invite", async () => {
const mockInvite = {
id: "invite-123",
email: "test@example.com",
name: "Test User",
organizationId: "org-123",
creatorId: "creator-123",
acceptorId: null,
createdAt: new Date(),
expiresAt: new Date(),
deprecatedRole: null,
role: OrganizationRole.member,
teamIds: [],
};
vi.mocked(prisma.invite.update).mockResolvedValue(mockInvite);
const result = await updateInvite("invite-123", { role: "member" });
expect(result).toBe(true);
expect(prisma.invite.update).toHaveBeenCalledWith({
where: { id: "invite-123" },
data: { role: "member" },
});
expect(inviteCache.revalidate).toHaveBeenCalledWith({
id: "invite-123",
organizationId: "org-123",
});
});
test("should throw ResourceNotFoundError when invite is null", async () => {
vi.mocked(prisma.invite.update).mockResolvedValue(null as any);
await expect(updateInvite("invite-123", { role: "member" })).rejects.toThrow(
new ResourceNotFoundError("Invite", "invite-123")
);
});
test("should throw ResourceNotFoundError when invite does not exist", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.update).mockRejectedValue(prismaError);
await expect(updateInvite("invite-123", { role: "member" })).rejects.toThrow(
new ResourceNotFoundError("Invite", "invite-123")
);
});
});
});

View File

@@ -0,0 +1,142 @@
import { membershipCache } from "@/lib/cache/membership";
import { teamCache } from "@/lib/cache/team";
import { organizationCache } from "@/lib/organization/cache";
import { projectCache } from "@/lib/project/cache";
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { updateMembership } from "./membership";
vi.mock("@formbricks/database", () => ({
prisma: {
membership: {
update: vi.fn(),
findMany: vi.fn(),
},
teamUser: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/membership", () => ({
membershipCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/cache/team", () => ({
teamCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/organization/cache", () => ({
organizationCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: {
revalidate: vi.fn(),
},
}));
describe("updateMembership", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("should update membership and related caches", async () => {
const mockMembership = {
id: "1",
userId: "user1",
organizationId: "org1",
role: "owner" as TOrganizationRole,
accepted: true,
deprecatedRole: null,
};
const mockTeamMemberships = [{ teamId: "team1" }, { teamId: "team2" }];
const mockOrganizationMembers = [{ userId: "user1" }, { userId: "user2" }];
vi.mocked(prisma.membership.update).mockResolvedValue(mockMembership);
vi.mocked(prisma.teamUser.findMany).mockResolvedValue(mockTeamMemberships);
vi.mocked(prisma.membership.findMany).mockResolvedValue(mockOrganizationMembers);
const result = await updateMembership("user1", "org1", { role: "owner" });
expect(result).toEqual(mockMembership);
expect(prisma.membership.update).toHaveBeenCalledWith({
where: {
userId_organizationId: {
userId: "user1",
organizationId: "org1",
},
},
data: { role: "owner" },
});
expect(membershipCache.revalidate).toHaveBeenCalledWith({
userId: "user1",
organizationId: "org1",
});
expect(teamCache.revalidate).toHaveBeenCalledTimes(3);
expect(organizationCache.revalidate).toHaveBeenCalledTimes(2);
expect(projectCache.revalidate).toHaveBeenCalledWith({
userId: "user1",
});
});
test("should throw ResourceNotFoundError when membership doesn't exist", async () => {
const error = new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
});
vi.mocked(prisma.membership.update).mockRejectedValue(error);
await expect(updateMembership("user1", "org1", { role: "owner" })).rejects.toThrow(
new ResourceNotFoundError("Membership", "userId: user1, organizationId: org1")
);
});
test("should update team roles when role is changed to manager", async () => {
const mockMembership = {
id: "1",
userId: "user1",
organizationId: "org1",
role: "manager" as TOrganizationRole,
accepted: true,
deprecatedRole: null,
};
const mockTeamMemberships = [{ teamId: "team1" }, { teamId: "team2" }];
const mockOrganizationMembers = [{ userId: "user1" }, { userId: "user2" }];
vi.mocked(prisma.membership.update).mockResolvedValue(mockMembership);
vi.mocked(prisma.teamUser.findMany).mockResolvedValue(mockTeamMemberships);
vi.mocked(prisma.membership.findMany).mockResolvedValue(mockOrganizationMembers);
const result = await updateMembership("user1", "org1", { role: "manager" });
expect(result).toEqual(mockMembership);
expect(prisma.teamUser.updateMany).toHaveBeenCalledWith({
where: {
userId: "user1",
team: {
organizationId: "org1",
},
},
data: {
role: "admin",
},
});
});
});

View File

@@ -0,0 +1,80 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ConfirmPasswordForm } from "./confirm-password-form";
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
},
}));
vi.mock("@/modules/ee/two-factor-auth/actions", () => ({
setupTwoFactorAuthAction: vi.fn(),
}));
describe("ConfirmPasswordForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockProps = {
setCurrentStep: vi.fn(),
setBackupCodes: vi.fn(),
setDataUri: vi.fn(),
setSecret: vi.fn(),
setOpen: vi.fn(),
};
test("renders the form with password input", () => {
render(<ConfirmPasswordForm {...mockProps} />);
expect(screen.getByText("environments.settings.profile.two_factor_authentication")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.profile.confirm_your_current_password_to_get_started")
).toBeInTheDocument();
expect(screen.getByLabelText("common.password")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.confirm" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.cancel" })).toBeInTheDocument();
});
test("handles form submission successfully", async () => {
const user = userEvent.setup();
const mockResponse = {
data: {
backupCodes: ["code1", "code2"],
dataUri: "data:image/png;base64,test",
secret: "test-secret",
keyUri: "otpauth://totp/test",
},
};
const { setupTwoFactorAuthAction } = await import("@/modules/ee/two-factor-auth/actions");
vi.mocked(setupTwoFactorAuthAction).mockResolvedValueOnce(mockResponse);
render(<ConfirmPasswordForm {...mockProps} />);
const passwordInput = screen.getByLabelText("common.password");
await user.type(passwordInput, "testPassword123!");
const submitButton = screen.getByRole("button", { name: "common.confirm" });
await user.click(submitButton);
await waitFor(() => {
expect(setupTwoFactorAuthAction).toHaveBeenCalledWith({ password: "testPassword123!" });
expect(mockProps.setBackupCodes).toHaveBeenCalledWith(["code1", "code2"]);
expect(mockProps.setDataUri).toHaveBeenCalledWith("data:image/png;base64,test");
expect(mockProps.setSecret).toHaveBeenCalledWith("test-secret");
expect(mockProps.setCurrentStep).toHaveBeenCalledWith("scanQRCode");
});
});
test("handles cancel button click", async () => {
const user = userEvent.setup();
render(<ConfirmPasswordForm {...mockProps} />);
await user.click(screen.getByRole("button", { name: "common.cancel" }));
expect(mockProps.setOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -7,8 +7,7 @@ import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/compon
import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { SubmitHandler, useForm } from "react-hook-form";
import { FormProvider } from "react-hook-form";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
import { ZUserPassword } from "@formbricks/types/user";

View File

@@ -0,0 +1,111 @@
import { disableTwoFactorAuthAction } from "@/modules/ee/two-factor-auth/actions";
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 { DisableTwoFactorModal } from "./disable-two-factor-modal";
vi.mock("@/modules/ee/two-factor-auth/actions", () => ({
disableTwoFactorAuthAction: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
}));
describe("DisableTwoFactorModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders modal with correct title and description", () => {
render(<DisableTwoFactorModal open={true} setOpen={() => {}} />);
expect(
screen.getByText("environments.settings.profile.disable_two_factor_authentication")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.profile.disable_two_factor_authentication_description")
).toBeInTheDocument();
});
test("shows password input field", () => {
render(<DisableTwoFactorModal open={true} setOpen={() => {}} />);
expect(screen.getByLabelText("common.password")).toBeInTheDocument();
});
test("toggles between 2FA code and backup code", async () => {
render(<DisableTwoFactorModal open={true} setOpen={() => {}} />);
// Initially shows 2FA code input
expect(screen.getByText("environments.settings.profile.two_factor_code")).toBeInTheDocument();
// Click to show backup code
await userEvent.click(screen.getByText("environments.settings.profile.lost_access"));
// Now shows backup code input
expect(screen.getByText("environments.settings.profile.backup_code")).toBeInTheDocument();
// Click to go back
await userEvent.click(screen.getByText("common.go_back"));
// Back to 2FA code
expect(screen.getByText("environments.settings.profile.two_factor_code")).toBeInTheDocument();
});
test("submits form with 2FA code", async () => {
const mockSetOpen = vi.fn();
vi.mocked(disableTwoFactorAuthAction).mockResolvedValue({ data: { message: "Success" } });
render(<DisableTwoFactorModal open={true} setOpen={mockSetOpen} />);
// Fill in password
await userEvent.type(screen.getByLabelText("common.password"), "testPassword123!");
// Fill in 2FA code
const otpInputs = screen.getAllByRole("textbox");
for (let i = 0; i < 6; i++) {
await userEvent.type(otpInputs[i], "1");
}
// Submit form
await userEvent.click(screen.getByText("common.disable"));
expect(disableTwoFactorAuthAction).toHaveBeenCalledWith({
password: "testPassword123!",
code: "111111",
backupCode: "",
});
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("submits form with backup code", async () => {
const mockSetOpen = vi.fn();
vi.mocked(disableTwoFactorAuthAction).mockResolvedValue({ data: { message: "Success" } });
render(<DisableTwoFactorModal open={true} setOpen={mockSetOpen} />);
// Fill in password
await userEvent.type(screen.getByLabelText("common.password"), "testPassword123!");
// Switch to backup code
await userEvent.click(screen.getByText("environments.settings.profile.lost_access"));
// Fill in backup code
await userEvent.type(screen.getByPlaceholderText("XXXXX-XXXXX"), "12345-67890");
// Submit form
await userEvent.click(screen.getByText("common.disable"));
expect(disableTwoFactorAuthAction).toHaveBeenCalledWith({
password: "testPassword123!",
code: "",
backupCode: "12345-67890",
});
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,80 @@
/**
* @vitest-environment jsdom
*/
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 { DisplayBackupCodes } from "./display-backup-codes";
vi.mock("react-hot-toast", () => ({
toast: {
success: vi.fn(),
},
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, size, "data-testid": testId }: any) => (
<button onClick={onClick} data-testid={testId} type="button">
{children}
</button>
),
}));
const translations: Record<string, string> = {
"environments.settings.profile.enable_two_factor_authentication": "Enable Two-Factor Authentication",
"environments.settings.profile.save_the_following_backup_codes_in_a_safe_place":
"Save the following backup codes in a safe place",
"common.close": "Close",
"common.copy": "Copy",
"common.download": "Download",
"common.copied_to_clipboard": "Copied to clipboard",
};
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => translations[key] || key,
}),
}));
describe("DisplayBackupCodes", () => {
const mockBackupCodes = ["1234567890", "0987654321"];
const mockSetOpen = vi.fn();
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders component structure correctly", () => {
render(<DisplayBackupCodes backupCodes={mockBackupCodes} setOpen={mockSetOpen} />);
// Check main structural elements
expect(screen.getByTestId("backup-codes-title")).toBeInTheDocument();
expect(screen.getByTestId("backup-codes-description")).toBeInTheDocument();
expect(screen.getByTestId("backup-codes-grid")).toBeInTheDocument();
// Check buttons
expect(screen.getByTestId("close-button")).toBeInTheDocument();
expect(screen.getByTestId("copy-button")).toBeInTheDocument();
expect(screen.getByTestId("download-button")).toBeInTheDocument();
});
test("displays formatted backup codes", () => {
render(<DisplayBackupCodes backupCodes={mockBackupCodes} setOpen={mockSetOpen} />);
mockBackupCodes.forEach((code) => {
const formattedCode = `${code.slice(0, 5)}-${code.slice(5, 10)}`;
const codeElement = screen.getByTestId(`backup-code-${code}`);
expect(codeElement).toHaveTextContent(formattedCode);
});
});
test("closes modal when close button is clicked", async () => {
const user = userEvent.setup();
render(<DisplayBackupCodes backupCodes={mockBackupCodes} setOpen={mockSetOpen} />);
await user.click(screen.getByTestId("close-button"));
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -29,24 +29,31 @@ export const DisplayBackupCodes = ({ backupCodes, setOpen }: DisplayBackupCodesP
return (
<div>
<div className="p-6">
<h1 className="text-lg font-semibold">
<h1 className="text-lg font-semibold" data-testid="backup-codes-title">
{t("environments.settings.profile.enable_two_factor_authentication")}
</h1>
<h3 className="text-sm text-slate-700">
<h3 className="text-sm text-slate-700" data-testid="backup-codes-description">
{t("environments.settings.profile.save_the_following_backup_codes_in_a_safe_place")}
</h3>
</div>
<div className="mx-auto mb-6 grid max-w-[60%] grid-cols-2 gap-1 text-center">
<div
className="mx-auto mb-6 grid max-w-[60%] grid-cols-2 gap-1 text-center"
data-testid="backup-codes-grid">
{backupCodes.map((code) => (
<p key={code} className="text-sm font-medium text-slate-700">
<p key={code} className="text-sm font-medium text-slate-700" data-testid={`backup-code-${code}`}>
{formatBackupCode(code)}
</p>
))}
</div>
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4">
<Button variant="secondary" type="button" size="sm" onClick={() => setOpen(false)}>
<Button
variant="secondary"
type="button"
size="sm"
onClick={() => setOpen(false)}
data-testid="close-button">
{t("common.close")}
</Button>
@@ -55,7 +62,8 @@ export const DisplayBackupCodes = ({ backupCodes, setOpen }: DisplayBackupCodesP
onClick={() => {
navigator.clipboard.writeText(backupCodes.map((code) => formatBackupCode(code)).join("\n"));
toast.success(t("common.copied_to_clipboard"));
}}>
}}
data-testid="copy-button">
{t("common.copy")}
</Button>
@@ -63,7 +71,8 @@ export const DisplayBackupCodes = ({ backupCodes, setOpen }: DisplayBackupCodesP
size="sm"
onClick={() => {
handleDownloadBackupCode();
}}>
}}
data-testid="download-button">
{t("common.download")}
</Button>
</div>

View File

@@ -0,0 +1,151 @@
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 { EnableTwoFactorModal } from "./enable-two-factor-modal";
// Mock the Modal component to expose the close functionality
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen }: { children: React.ReactNode; open: boolean; setOpen: () => void }) =>
open ? (
<div data-testid="modal">
{children}
<button data-testid="modal-close" onClick={setOpen}>
Close
</button>
</div>
) : null,
}));
// Mock the child components
vi.mock("./confirm-password-form", () => ({
ConfirmPasswordForm: ({
setCurrentStep,
setDataUri,
setSecret,
}: {
setCurrentStep: (step: string) => void;
setDataUri: (uri: string) => void;
setSecret: (secret: string) => void;
}) => (
<div data-testid="confirm-password-form">
<button
onClick={() => {
setDataUri("test-uri");
setSecret("test-secret");
setCurrentStep("scanQRCode");
}}>
Next
</button>
</div>
),
}));
vi.mock("./scan-qr-code", () => ({
ScanQRCode: ({
setCurrentStep,
dataUri,
secret,
}: {
setCurrentStep: (step: string) => void;
dataUri: string;
secret: string;
}) => (
<div data-testid="scan-qr-code">
<div data-testid="data-uri">{dataUri}</div>
<div data-testid="secret">{secret}</div>
<button onClick={() => setCurrentStep("enterCode")}>Next</button>
</div>
),
}));
vi.mock("./enter-code", () => ({
EnterCode: ({ setCurrentStep }: { setCurrentStep: (step: string) => void }) => (
<div data-testid="enter-code">
<button onClick={() => setCurrentStep("backupCodes")}>Next</button>
</div>
),
}));
vi.mock("./display-backup-codes", () => ({
DisplayBackupCodes: ({ backupCodes }: { backupCodes: string[] }) => (
<div data-testid="display-backup-codes">
{backupCodes.map((code, index) => (
<div key={index} data-testid={`backup-code-${index}`}>
{code}
</div>
))}
</div>
),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
}));
describe("EnableTwoFactorModal", () => {
afterEach(() => {
cleanup();
});
test("renders confirm password form when open", () => {
const setOpen = vi.fn();
render(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
expect(screen.getByTestId("confirm-password-form")).toBeInTheDocument();
});
test("does not render when closed", () => {
const setOpen = vi.fn();
render(<EnableTwoFactorModal open={false} setOpen={setOpen} />);
expect(screen.queryByTestId("confirm-password-form")).not.toBeInTheDocument();
});
test("transitions through all steps correctly", async () => {
const setOpen = vi.fn();
const user = userEvent.setup();
render(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
// Start at confirm password
expect(screen.getByTestId("confirm-password-form")).toBeInTheDocument();
// Move to scan QR code
await user.click(screen.getByText("Next"));
expect(screen.getByTestId("scan-qr-code")).toBeInTheDocument();
expect(screen.getByTestId("data-uri")).toHaveTextContent("test-uri");
expect(screen.getByTestId("secret")).toHaveTextContent("test-secret");
// Move to enter code
await user.click(screen.getByText("Next"));
expect(screen.getByTestId("enter-code")).toBeInTheDocument();
// Move to backup codes
await user.click(screen.getByText("Next"));
expect(screen.getByTestId("display-backup-codes")).toBeInTheDocument();
});
test("resets state when modal is closed", async () => {
const setOpen = vi.fn();
const user = userEvent.setup();
const { rerender } = render(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
// Move to scan QR code
await user.click(screen.getByText("Next"));
expect(screen.getByTestId("scan-qr-code")).toBeInTheDocument();
// Close modal using the close button
await user.click(screen.getByTestId("modal-close"));
// Verify setOpen was called with false
expect(setOpen).toHaveBeenCalledWith(false);
// Reopen modal
rerender(<EnableTwoFactorModal open={true} setOpen={setOpen} />);
// Should be back at the first step
expect(screen.getByTestId("confirm-password-form")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,96 @@
import { enableTwoFactorAuthAction } from "@/modules/ee/two-factor-auth/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EnterCode } from "./enter-code";
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@/modules/ee/two-factor-auth/actions", () => ({
enableTwoFactorAuthAction: vi.fn(),
}));
describe("EnterCode", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const mockProps = {
setCurrentStep: vi.fn(),
setOpen: vi.fn(),
refreshData: vi.fn(),
};
test("renders the component with correct title and description", () => {
render(<EnterCode {...mockProps} />);
expect(
screen.getByText("environments.settings.profile.enable_two_factor_authentication")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.profile.enter_the_code_from_your_authenticator_app_below")
).toBeInTheDocument();
});
test("handles successful code submission", async () => {
const user = userEvent.setup();
const mockResponse = { data: { message: "2FA enabled successfully" } };
vi.mocked(enableTwoFactorAuthAction).mockResolvedValue(mockResponse);
render(<EnterCode {...mockProps} />);
// Find all input fields
const inputs = screen.getAllByRole("textbox");
expect(inputs).toHaveLength(6);
// Enter a valid 6-digit code
for (let i = 0; i < 6; i++) {
await user.type(inputs[i], "1");
}
const confirmButton = screen.getByText("common.confirm");
await user.click(confirmButton);
await waitFor(() => {
expect(enableTwoFactorAuthAction).toHaveBeenCalledWith({ code: "111111" });
expect(mockProps.setCurrentStep).toHaveBeenCalledWith("backupCodes");
expect(mockProps.refreshData).toHaveBeenCalled();
});
});
test("handles error during code submission", async () => {
const user = userEvent.setup();
const mockError = { message: "Invalid code" };
vi.mocked(enableTwoFactorAuthAction).mockRejectedValue(mockError);
render(<EnterCode {...mockProps} />);
// Find all input fields
const inputs = screen.getAllByRole("textbox");
// Enter a valid 6-digit code
for (let i = 0; i < 6; i++) {
await user.type(inputs[i], "1");
}
const confirmButton = screen.getByText("common.confirm");
await user.click(confirmButton);
await waitFor(() => {
expect(enableTwoFactorAuthAction).toHaveBeenCalledWith({ code: "111111" });
});
});
});

View File

@@ -49,55 +49,53 @@ export const EnterCode = ({ setCurrentStep, setOpen, refreshData }: EnterCodePro
};
return (
<>
<div>
<div className="p-6">
<h1 className="text-lg font-semibold">
{t("environments.settings.profile.enable_two_factor_authentication")}
</h1>
<h3 className="text-sm text-slate-700">
{t("environments.settings.profile.enter_the_code_from_your_authenticator_app_below")}
</h3>
<div>
<div className="p-6">
<h1 className="text-lg font-semibold">
{t("environments.settings.profile.enable_two_factor_authentication")}
</h1>
<h3 className="text-sm text-slate-700">
{t("environments.settings.profile.enter_the_code_from_your_authenticator_app_below")}
</h3>
</div>
<form className="flex flex-col space-y-10" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2 px-6">
<label htmlFor="code" className="text-sm font-medium text-slate-700">
{t("common.code")}
</label>
<Controller
name="code"
control={control}
render={({ field, formState: { errors } }) => (
<>
<OTPInput
value={field.value}
onChange={field.onChange}
valueLength={6}
containerClassName="justify-start"
/>
{errors.code && (
<p className="mt-2 text-sm text-red-600" id="code-error">
{errors.code.message}
</p>
)}
</>
)}
/>
</div>
<form className="flex flex-col space-y-10" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2 px-6">
<label htmlFor="code" className="text-sm font-medium text-slate-700">
{t("common.code")}
</label>
<Controller
name="code"
control={control}
render={({ field, formState: { errors } }) => (
<>
<OTPInput
value={field.value}
onChange={field.onChange}
valueLength={6}
containerClassName="justify-start"
/>
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4">
<Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
{errors.code && (
<p className="mt-2 text-sm text-red-600" id="code-error">
{errors.code.message}
</p>
)}
</>
)}
/>
</div>
<div className="flex w-full items-center justify-end space-x-4 border-t border-slate-300 p-4">
<Button variant="secondary" size="sm" type="button" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button size="sm" loading={formState.isSubmitting}>
{t("common.confirm")}
</Button>
</div>
</form>
</div>
</>
<Button size="sm" loading={formState.isSubmitting}>
{t("common.confirm")}
</Button>
</div>
</form>
</div>
);
};

View File

@@ -0,0 +1,83 @@
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 { ScanQRCode } from "./scan-qr-code";
describe("ScanQRCode", () => {
afterEach(() => {
cleanup();
});
const mockProps = {
setCurrentStep: vi.fn(),
dataUri: "data:image/png;base64,test",
secret: "TEST123",
setOpen: vi.fn(),
};
test("renders the component with correct title and instructions", () => {
render(<ScanQRCode {...mockProps} />);
expect(
screen.getByText("environments.settings.profile.enable_two_factor_authentication")
).toBeInTheDocument();
expect(
screen.getByText("environments.settings.profile.scan_the_qr_code_below_with_your_authenticator_app")
).toBeInTheDocument();
});
test("displays the QR code image", () => {
render(<ScanQRCode {...mockProps} />);
const qrCodeImage = screen.getByAltText("QR code");
expect(qrCodeImage).toBeInTheDocument();
expect(qrCodeImage).toHaveAttribute("src", mockProps.dataUri);
});
test("displays the secret code and copy button", () => {
render(<ScanQRCode {...mockProps} />);
expect(screen.getByText(mockProps.secret)).toBeInTheDocument();
expect(
screen.getByText("environments.settings.profile.or_enter_the_following_code_manually")
).toBeInTheDocument();
});
test("copies secret to clipboard when copy button is clicked", async () => {
const user = userEvent.setup();
const mockWriteText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", {
clipboard: {
writeText: mockWriteText,
},
});
render(<ScanQRCode {...mockProps} />);
const copyButton = screen.getAllByRole("button")[0];
await user.click(copyButton);
expect(mockWriteText).toHaveBeenCalledWith(mockProps.secret);
});
test("navigates to next step when next button is clicked", async () => {
const user = userEvent.setup();
render(<ScanQRCode {...mockProps} />);
const nextButton = screen.getByText("common.next");
await user.click(nextButton);
expect(mockProps.setCurrentStep).toHaveBeenCalledWith("enterCode");
});
test("closes modal when cancel button is clicked", async () => {
const user = userEvent.setup();
render(<ScanQRCode {...mockProps} />);
const cancelButton = screen.getByText("common.cancel");
await user.click(cancelButton);
expect(mockProps.setOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,49 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TwoFactorBackup } from "./two-factor-backup";
const mockUseTranslate = vi.fn(() => ({
t: (key: string) => key,
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => mockUseTranslate(),
}));
type FormValues = {
email: string;
password: string;
totpCode?: string;
backupCode?: string;
};
const TestComponent = () => {
const form = useForm<FormValues>({
defaultValues: {
email: "",
password: "",
},
});
return (
<FormProvider {...form}>
<TwoFactorBackup form={form} />
</FormProvider>
);
};
describe("TwoFactorBackup", () => {
afterEach(() => {
cleanup();
});
test("renders backup code input field", () => {
render(<TestComponent />);
const input = screen.getByPlaceholderText("XXXXX-XXXXX");
expect(input).toBeInTheDocument();
expect(input).toHaveAttribute("required");
});
});

View File

@@ -1,7 +1,6 @@
"use client";
import { FormField, FormItem } from "@/modules/ui/components/form";
import { FormControl } from "@/modules/ui/components/form";
import { FormControl, FormField, FormItem } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { UseFormReturn } from "react-hook-form";
@@ -22,30 +21,28 @@ export const TwoFactorBackup = ({ form }: TwoFactorBackupProps) => {
const { t } = useTranslate();
return (
<>
<div className="mb-2 transition-all duration-500 ease-in-out">
<label htmlFor="totpBackup" className="sr-only">
{t("auth.login.backup_code")}
</label>
<FormField
control={form.control}
name="backupCode"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input
id="totpBackup"
required
placeholder="XXXXX-XXXXX"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</>
<div className="mb-2 transition-all duration-500 ease-in-out">
<label htmlFor="totpBackup" className="sr-only">
{t("auth.login.backup_code")}
</label>
<FormField
control={form.control}
name="backupCode"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input
id="totpBackup"
required
placeholder="XXXXX-XXXXX"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
/>
</FormControl>
</FormItem>
)}
/>
</div>
);
};

View File

@@ -0,0 +1,52 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TwoFactor } from "./two-factor";
const mockUseTranslate = vi.fn(() => ({
t: (key: string) => key,
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => mockUseTranslate(),
}));
type FormValues = {
email: string;
password: string;
totpCode?: string;
backupCode?: string;
};
const TestWrapper = () => {
const form = useForm<FormValues>({
defaultValues: {
email: "",
password: "",
},
});
return (
<FormProvider {...form}>
<TwoFactor form={form} />
</FormProvider>
);
};
describe("TwoFactor", () => {
afterEach(() => {
cleanup();
});
test("renders OTP input fields", () => {
render(<TestWrapper />);
const inputs = screen.getAllByRole("textbox");
expect(inputs).toHaveLength(6);
inputs.forEach((input) => {
expect(input).toHaveAttribute("inputmode", "numeric");
expect(input).toHaveAttribute("maxlength", "6");
expect(input).toHaveAttribute("pattern", "\\d{1}");
});
});
});

View File

@@ -21,24 +21,22 @@ export const TwoFactor = ({ form }: TwoFactorProps) => {
const { t } = useTranslate();
return (
<>
<div className="mb-2 transition-all duration-500 ease-in-out">
<label htmlFor="totp" className="sr-only">
{t("auth.login.enter_your_two_factor_authentication_code")}
</label>
<div className="mb-2 transition-all duration-500 ease-in-out">
<label htmlFor="totp" className="sr-only">
{t("auth.login.enter_your_two_factor_authentication_code")}
</label>
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<OTPInput value={field.value ?? ""} onChange={field.onChange} valueLength={6} />
</FormControl>
</FormItem>
)}
/>
</div>
</>
<FormField
control={form.control}
name="totpCode"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<OTPInput value={field.value ?? ""} onChange={field.onChange} valueLength={6} />
</FormControl>
</FormItem>
)}
/>
</div>
);
};

View File

@@ -0,0 +1,378 @@
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { userCache } from "@/lib/user/cache";
import { totpAuthenticatorCheck } from "@/modules/auth/lib/totp";
import { verifyPassword } from "@/modules/auth/lib/utils";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { disableTwoFactorAuth, enableTwoFactorAuth, setupTwoFactorAuth } from "./two-factor-auth";
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
symmetricDecrypt: vi.fn(),
}));
vi.mock("@/modules/auth/lib/utils", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@/modules/auth/lib/totp", () => ({
totpAuthenticatorCheck: vi.fn(),
}));
vi.mock("@/lib/user/cache", () => ({
userCache: {
revalidate: vi.fn(),
},
}));
describe("Two Factor Auth", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("setupTwoFactorAuth should throw ResourceNotFoundError when user not found", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
await expect(setupTwoFactorAuth("user123", "password123")).rejects.toThrow(ResourceNotFoundError);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user123" },
});
});
test("setupTwoFactorAuth should throw InvalidInputError when user has no password", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: null,
identityProvider: "email",
} as any);
await expect(setupTwoFactorAuth("user123", "password123")).rejects.toThrow(
new InvalidInputError("User does not have a password set")
);
});
test("setupTwoFactorAuth should throw InvalidInputError when user has third party login", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "google",
} as any);
await expect(setupTwoFactorAuth("user123", "password123")).rejects.toThrow(
new InvalidInputError("Third party login is already enabled")
);
});
test("setupTwoFactorAuth should throw InvalidInputError when password is incorrect", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "email",
} as any);
vi.mocked(verifyPassword).mockResolvedValue(false);
await expect(setupTwoFactorAuth("user123", "wrongPassword")).rejects.toThrow(
new InvalidInputError("Incorrect password")
);
});
test("setupTwoFactorAuth should successfully setup 2FA", async () => {
const mockUser = {
id: "user123",
password: "hashedPassword",
identityProvider: "email",
email: "test@example.com",
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any);
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(symmetricEncrypt).mockImplementation((data) => `encrypted_${data}`);
vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any);
const result = await setupTwoFactorAuth("user123", "correctPassword");
expect(result).toHaveProperty("secret");
expect(result).toHaveProperty("keyUri");
expect(result).toHaveProperty("dataUri");
expect(result).toHaveProperty("backupCodes");
expect(result.backupCodes).toHaveLength(10);
expect(prisma.user.update).toHaveBeenCalled();
});
test("enableTwoFactorAuth should throw ResourceNotFoundError when user not found", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
await expect(enableTwoFactorAuth("user123", "123456")).rejects.toThrow(ResourceNotFoundError);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user123" },
});
});
test("enableTwoFactorAuth should throw InvalidInputError when user has no password", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: null,
identityProvider: "email",
} as any);
await expect(enableTwoFactorAuth("user123", "123456")).rejects.toThrow(
new InvalidInputError("User does not have a password set")
);
});
test("enableTwoFactorAuth should throw InvalidInputError when user has third party login", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "google",
} as any);
await expect(enableTwoFactorAuth("user123", "123456")).rejects.toThrow(
new InvalidInputError("Third party login is already enabled")
);
});
test("enableTwoFactorAuth should throw InvalidInputError when 2FA is already enabled", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "email",
twoFactorEnabled: true,
} as any);
await expect(enableTwoFactorAuth("user123", "123456")).rejects.toThrow(
new InvalidInputError("Two factor authentication is already enabled")
);
});
test("enableTwoFactorAuth should throw InvalidInputError when 2FA setup is not completed", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "email",
twoFactorEnabled: false,
twoFactorSecret: null,
} as any);
await expect(enableTwoFactorAuth("user123", "123456")).rejects.toThrow(
new InvalidInputError("Two factor setup has not been completed")
);
});
test("enableTwoFactorAuth should throw InvalidInputError when secret is invalid", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "email",
twoFactorEnabled: false,
twoFactorSecret: "encrypted_secret",
} as any);
vi.mocked(symmetricDecrypt).mockReturnValue("invalid_secret");
await expect(enableTwoFactorAuth("user123", "123456")).rejects.toThrow(
new InvalidInputError("Invalid secret")
);
});
test("enableTwoFactorAuth should throw InvalidInputError when code is invalid", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "email",
twoFactorEnabled: false,
twoFactorSecret: "encrypted_secret",
} as any);
vi.mocked(symmetricDecrypt).mockReturnValue("12345678901234567890123456789012");
vi.mocked(totpAuthenticatorCheck).mockReturnValue(false);
await expect(enableTwoFactorAuth("user123", "123456")).rejects.toThrow(
new InvalidInputError("Invalid code")
);
});
test("enableTwoFactorAuth should successfully enable 2FA", async () => {
const mockUser = {
id: "user123",
password: "hashedPassword",
identityProvider: "email",
twoFactorEnabled: false,
twoFactorSecret: "encrypted_secret",
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any);
vi.mocked(symmetricDecrypt).mockReturnValue("12345678901234567890123456789012");
vi.mocked(totpAuthenticatorCheck).mockReturnValue(true);
vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any);
const result = await enableTwoFactorAuth("user123", "123456");
expect(result).toEqual({ message: "Two factor authentication enabled" });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user123" },
data: { twoFactorEnabled: true },
});
expect(userCache.revalidate).toHaveBeenCalledWith({ id: "user123" });
});
test("disableTwoFactorAuth should throw ResourceNotFoundError when user not found", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
await expect(disableTwoFactorAuth("user123", { password: "password123" })).rejects.toThrow(
ResourceNotFoundError
);
expect(prisma.user.findUnique).toHaveBeenCalledWith({
where: { id: "user123" },
});
});
test("disableTwoFactorAuth should throw InvalidInputError when user has no password", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: null,
identityProvider: "email",
} as any);
await expect(disableTwoFactorAuth("user123", { password: "password123" })).rejects.toThrow(
new InvalidInputError("User does not have a password set")
);
});
test("disableTwoFactorAuth should throw InvalidInputError when user has third party login", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "google",
twoFactorEnabled: true,
} as any);
await expect(disableTwoFactorAuth("user123", { password: "password123" })).rejects.toThrow(
new InvalidInputError("Third party login is already enabled")
);
});
test("disableTwoFactorAuth should throw InvalidInputError when 2FA is not enabled", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "email",
twoFactorEnabled: false,
} as any);
await expect(disableTwoFactorAuth("user123", { password: "password123" })).rejects.toThrow(
new InvalidInputError("Two factor authentication is not enabled")
);
});
test("disableTwoFactorAuth should throw InvalidInputError when password is incorrect", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "email",
twoFactorEnabled: true,
} as any);
vi.mocked(verifyPassword).mockResolvedValue(false);
await expect(disableTwoFactorAuth("user123", { password: "wrongPassword" })).rejects.toThrow(
new InvalidInputError("Incorrect password")
);
});
test("disableTwoFactorAuth should throw InvalidInputError when backup code is invalid", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "email",
twoFactorEnabled: true,
backupCodes: "encrypted_backup_codes",
} as any);
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(symmetricDecrypt).mockReturnValue(JSON.stringify(["code1", "code2"]));
await expect(
disableTwoFactorAuth("user123", { password: "password123", backupCode: "invalid-code" })
).rejects.toThrow(new InvalidInputError("Incorrect backup code"));
});
test("disableTwoFactorAuth should throw InvalidInputError when 2FA code is invalid", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
id: "user123",
password: "hashedPassword",
identityProvider: "email",
twoFactorEnabled: true,
twoFactorSecret: "encrypted_secret",
} as any);
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(symmetricDecrypt).mockReturnValue("12345678901234567890123456789012");
vi.mocked(totpAuthenticatorCheck).mockReturnValue(false);
await expect(
disableTwoFactorAuth("user123", { password: "password123", code: "123456" })
).rejects.toThrow(new InvalidInputError("Invalid code"));
});
test("disableTwoFactorAuth should successfully disable 2FA with backup code", async () => {
const mockUser = {
id: "user123",
password: "hashedPassword",
identityProvider: "email",
twoFactorEnabled: true,
backupCodes: "encrypted_backup_codes",
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any);
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(symmetricDecrypt).mockReturnValue(JSON.stringify(["validcode", "code2"]));
vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any);
const result = await disableTwoFactorAuth("user123", {
password: "password123",
backupCode: "valid-code",
});
expect(result).toEqual({ message: "Two factor authentication disabled" });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user123" },
data: {
backupCodes: null,
twoFactorEnabled: false,
twoFactorSecret: null,
},
});
expect(userCache.revalidate).toHaveBeenCalledWith({ id: "user123" });
});
test("disableTwoFactorAuth should successfully disable 2FA with 2FA code", async () => {
const mockUser = {
id: "user123",
password: "hashedPassword",
identityProvider: "email",
twoFactorEnabled: true,
twoFactorSecret: "encrypted_secret",
};
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as any);
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(symmetricDecrypt).mockReturnValue("12345678901234567890123456789012");
vi.mocked(totpAuthenticatorCheck).mockReturnValue(true);
vi.mocked(prisma.user.update).mockResolvedValue(mockUser as any);
const result = await disableTwoFactorAuth("user123", { password: "password123", code: "123456" });
expect(result).toEqual({ message: "Two factor authentication disabled" });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user123" },
data: {
backupCodes: null,
twoFactorEnabled: false,
twoFactorSecret: null,
},
});
expect(userCache.revalidate).toHaveBeenCalledWith({ id: "user123" });
});
});

View File

@@ -0,0 +1,257 @@
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 {
getOrganizationLogoUrl,
removeOrganizationEmailLogoUrl,
updateOrganizationEmailLogoUrl,
} from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/organization/cache", () => ({
organizationCache: {
revalidate: vi.fn(),
tag: {
byId: vi.fn(),
},
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/cache", () => ({
cache: vi.fn((fn) => fn),
}));
vi.mock("react", () => ({
cache: vi.fn((fn) => fn),
}));
describe("organization", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("updateOrganizationEmailLogoUrl", () => {
test("should update organization email logo URL", async () => {
const mockOrganization = {
id: "clg123456789012345678901234",
whitelabel: {
logoUrl: "old-logo.png",
},
};
const mockUpdatedOrganization = {
projects: [
{
id: "clp123456789012345678901234",
environments: [{ id: "cle123456789012345678901234" }],
},
],
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as any);
vi.mocked(prisma.organization.update).mockResolvedValue(mockUpdatedOrganization as any);
const result = await updateOrganizationEmailLogoUrl("clg123456789012345678901234", "new-logo.png");
expect(result).toBe(true);
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
});
expect(prisma.organization.update).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
data: {
whitelabel: {
...mockOrganization.whitelabel,
logoUrl: "new-logo.png",
},
},
select: {
projects: {
select: {
id: true,
environments: {
select: {
id: true,
},
},
},
},
},
});
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
await expect(
updateOrganizationEmailLogoUrl("clg123456789012345678901234", "new-logo.png")
).rejects.toThrow(ResourceNotFoundError);
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
});
expect(prisma.organization.update).not.toHaveBeenCalled();
});
});
describe("removeOrganizationEmailLogoUrl", () => {
test("should remove organization email logo URL", async () => {
const mockOrganization = {
id: "clg123456789012345678901234",
whitelabel: {
logoUrl: "old-logo.png",
},
projects: [
{
id: "clp123456789012345678901234",
environments: [{ id: "cle123456789012345678901234" }],
},
],
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as any);
vi.mocked(prisma.organization.update).mockResolvedValue({} as any);
const result = await removeOrganizationEmailLogoUrl("clg123456789012345678901234");
expect(result).toBe(true);
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
select: {
whitelabel: true,
projects: {
select: {
id: true,
environments: {
select: {
id: true,
},
},
},
},
},
});
expect(prisma.organization.update).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
data: {
whitelabel: {
...mockOrganization.whitelabel,
logoUrl: null,
},
},
});
});
test("should throw ResourceNotFoundError when organization is not found", async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
await expect(removeOrganizationEmailLogoUrl("clg123456789012345678901234")).rejects.toThrow(
ResourceNotFoundError
);
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
select: {
whitelabel: true,
projects: {
select: {
id: true,
environments: {
select: {
id: true,
},
},
},
},
},
});
expect(prisma.organization.update).not.toHaveBeenCalled();
});
});
describe("getOrganizationLogoUrl", () => {
test("should return logo URL when organization exists", async () => {
const mockOrganization = {
whitelabel: {
logoUrl: "logo.png",
},
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as any);
const result = await getOrganizationLogoUrl("clg123456789012345678901234");
expect(result).toBe("logo.png");
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
select: {
whitelabel: true,
},
});
});
test("should return null when organization exists but has no logo URL", async () => {
const mockOrganization = {
whitelabel: {},
};
vi.mocked(prisma.organization.findUnique).mockResolvedValue(mockOrganization as any);
const result = await getOrganizationLogoUrl("clg123456789012345678901234");
expect(result).toBeNull();
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
select: {
whitelabel: true,
},
});
});
test("should return null when organization does not exist", async () => {
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
const result = await getOrganizationLogoUrl("clg123456789012345678901234");
expect(result).toBeNull();
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
select: {
whitelabel: true,
},
});
});
test("should throw DatabaseError when prisma throws a known error", async () => {
const mockError = new Prisma.PrismaClientKnownRequestError("Test error", {
code: "P2002",
clientVersion: "2.0.0",
});
vi.mocked(prisma.organization.findUnique).mockRejectedValue(mockError);
await expect(getOrganizationLogoUrl("clg123456789012345678901234")).rejects.toThrow(DatabaseError);
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
where: { id: "clg123456789012345678901234" },
select: {
whitelabel: true,
},
});
});
});
});

View File

@@ -0,0 +1,146 @@
import { projectCache } from "@/lib/project/cache";
import { validateInputs } from "@/lib/utils/validate";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { ValidationError } from "@formbricks/types/errors";
import { TProjectUpdateBrandingInput } from "../types/project";
import { updateProjectBranding } from "./project";
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
update: vi.fn(),
},
},
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: {
revalidate: vi.fn(),
},
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
describe("updateProjectBranding", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("should update project branding successfully", async () => {
const mockProject = {
id: "test-project-id",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
organizationId: "test-org-id",
brandColor: null,
highlightBorderColor: null,
styling: {
allowStyleOverwrite: true,
brandColor: { light: "#64748b" },
questionColor: { light: "#2b2524" },
inputColor: { light: "#ffffff" },
inputBorderColor: { light: "#cbd5e1" },
cardBackgroundColor: { light: "#ffffff" },
cardBorderColor: { light: "#f8fafc" },
cardShadowColor: { light: "#000000" },
isLogoHidden: false,
isDarkModeEnabled: false,
background: { bg: "#fff", bgType: "color" as const },
roundness: 8,
cardArrangement: {
linkSurveys: "straight" as const,
appSurveys: "straight" as const,
},
},
recontactDays: 7,
linkSurveyBranding: true,
inAppSurveyBranding: true,
config: {
channel: "link" as const,
industry: "other" as const,
},
placement: "bottomRight" as const,
clickOutsideClose: true,
darkOverlay: false,
environments: [{ id: "test-env-id" }],
languages: [],
logo: null,
};
vi.mocked(prisma.project.update).mockResolvedValue(mockProject);
vi.mocked(validateInputs).mockReturnValue([
"test-project-id",
{ linkSurveyBranding: false, inAppSurveyBranding: false },
]);
const inputProject: TProjectUpdateBrandingInput = {
linkSurveyBranding: false,
inAppSurveyBranding: false,
};
const result = await updateProjectBranding("test-project-id", inputProject);
expect(result).toBe(true);
expect(validateInputs).toHaveBeenCalledWith(
["test-project-id", expect.any(Object)],
[inputProject, expect.any(Object)]
);
expect(prisma.project.update).toHaveBeenCalledWith({
where: {
id: "test-project-id",
},
data: inputProject,
select: {
id: true,
organizationId: true,
environments: {
select: {
id: true,
},
},
},
});
expect(projectCache.revalidate).toHaveBeenCalledWith({
id: "test-project-id",
organizationId: "test-org-id",
});
expect(projectCache.revalidate).toHaveBeenCalledWith({
environmentId: "test-env-id",
});
});
test("should throw ValidationError when validation fails", async () => {
vi.mocked(validateInputs).mockImplementation(() => {
throw new ValidationError("Validation failed");
});
const inputProject: TProjectUpdateBrandingInput = {
linkSurveyBranding: false,
inAppSurveyBranding: false,
};
await expect(updateProjectBranding("test-project-id", inputProject)).rejects.toThrow(ValidationError);
expect(prisma.project.update).not.toHaveBeenCalled();
expect(projectCache.revalidate).not.toHaveBeenCalled();
});
test("should throw ValidationError when prisma update fails", async () => {
vi.mocked(validateInputs).mockReturnValue([
"test-project-id",
{ linkSurveyBranding: false, inAppSurveyBranding: false },
]);
vi.mocked(prisma.project.update).mockRejectedValue(new Error("Database error"));
const inputProject: TProjectUpdateBrandingInput = {
linkSurveyBranding: false,
inAppSurveyBranding: false,
};
await expect(updateProjectBranding("test-project-id", inputProject)).rejects.toThrow(ValidationError);
expect(projectCache.revalidate).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,116 @@
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 FollowUpActionMultiEmailInput from "./follow-up-action-multi-email-input";
describe("FollowUpActionMultiEmailInput", () => {
afterEach(() => {
cleanup();
});
test("renders empty input initially", () => {
const setEmails = vi.fn();
render(<FollowUpActionMultiEmailInput emails={[]} setEmails={setEmails} />);
const input = screen.getByPlaceholderText("Write an email & press space bar");
expect(input).toBeInTheDocument();
});
test("adds valid email when pressing space", async () => {
const setEmails = vi.fn();
const user = userEvent.setup();
render(<FollowUpActionMultiEmailInput emails={[]} setEmails={setEmails} />);
const input = screen.getByPlaceholderText("Write an email & press space bar");
await user.type(input, "test@example.com ");
expect(setEmails).toHaveBeenCalledWith(["test@example.com"]);
});
test("shows error for invalid email", async () => {
const setEmails = vi.fn();
const user = userEvent.setup();
render(<FollowUpActionMultiEmailInput emails={[]} setEmails={setEmails} />);
const input = screen.getByPlaceholderText("Write an email & press space bar");
await user.type(input, "invalid-email ");
expect(screen.getByText("Please enter a valid email address")).toBeInTheDocument();
expect(setEmails).not.toHaveBeenCalled();
});
test("shows error for duplicate email", async () => {
const setEmails = vi.fn();
const user = userEvent.setup();
render(<FollowUpActionMultiEmailInput emails={["test@example.com"]} setEmails={setEmails} />);
const input = screen.getByPlaceholderText("");
await user.type(input, "test@example.com ");
expect(screen.getByText("This email has already been added")).toBeInTheDocument();
expect(setEmails).not.toHaveBeenCalled();
});
test("removes email when clicking remove button", async () => {
const setEmails = vi.fn();
const user = userEvent.setup();
render(<FollowUpActionMultiEmailInput emails={["test@example.com"]} setEmails={setEmails} />);
const removeButton = screen.getByText("×");
await user.click(removeButton);
expect(setEmails).toHaveBeenCalledWith([]);
});
test("removes last email when pressing backspace on empty input", async () => {
const setEmails = vi.fn();
const user = userEvent.setup();
render(<FollowUpActionMultiEmailInput emails={["test@example.com"]} setEmails={setEmails} />);
const input = screen.getByPlaceholderText("");
await user.type(input, "{backspace}");
expect(setEmails).toHaveBeenCalledWith([]);
});
test("shows red border when isInvalid is true", () => {
const setEmails = vi.fn();
render(<FollowUpActionMultiEmailInput emails={[]} setEmails={setEmails} isInvalid={true} />);
const container = screen.getByRole("textbox").parentElement;
expect(container).toHaveClass("border-red-500");
});
test("adds email on blur if input is not empty", async () => {
const setEmails = vi.fn();
const user = userEvent.setup();
render(<FollowUpActionMultiEmailInput emails={[]} setEmails={setEmails} />);
const input = screen.getByPlaceholderText("Write an email & press space bar");
await user.type(input, "test@example.com");
await user.tab();
expect(setEmails).toHaveBeenCalledWith(["test@example.com"]);
});
test("clears error when typing after error is shown", async () => {
const setEmails = vi.fn();
const user = userEvent.setup();
render(<FollowUpActionMultiEmailInput emails={[]} setEmails={setEmails} />);
const input = screen.getByPlaceholderText("Write an email & press space bar");
await user.type(input, "invalid-email ");
expect(screen.getByText("Please enter a valid email address")).toBeInTheDocument();
await user.type(input, "a");
expect(screen.queryByText("Please enter a valid email address")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,201 @@
import { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { FollowUpModal } from "./follow-up-modal";
// Mock react-hook-form
vi.mock("react-hook-form", () => ({
useForm: () => ({
formState: {
errors: {},
isSubmitting: false,
},
watch: vi.fn(),
setError: vi.fn(),
handleSubmit: (fn: any) => fn,
reset: vi.fn(),
getValues: vi.fn(),
setValue: vi.fn(),
register: vi.fn(),
}),
}));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
},
}));
// Mock isomorphic-dompurify
vi.mock("isomorphic-dompurify", () => ({
default: {
sanitize: (input: string) => input,
},
}));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock the Editor component
vi.mock("@/modules/ui/components/editor", () => ({
Editor: ({ getText, setText, placeholder }: any) => (
<div data-testid="editor">
<textarea
data-testid="editor-textarea"
value={getText()}
onChange={(e) => setText(e.target.value)}
placeholder={placeholder}
/>
</div>
),
}));
// Mock the Select component
vi.mock("@/modules/ui/components/select", () => ({
Select: ({ children, defaultValue, onValueChange }: any) => (
<div data-testid="select">
<select
data-testid="select-native"
defaultValue={defaultValue}
onChange={(e) => onValueChange?.(e.target.value)}>
{children}
</select>
</div>
),
SelectTrigger: ({ children }: any) => <div data-testid="select-trigger">{children}</div>,
SelectValue: () => <div data-testid="select-value" />,
SelectContent: ({ children }: any) => <div data-testid="select-content">{children}</div>,
SelectItem: ({ children, value }: any) => (
<div data-testid="select-item" data-value={value}>
{children}
</div>
),
}));
// Mock the FollowUpActionMultiEmailInput component
vi.mock("./follow-up-action-multi-email-input", () => ({
default: ({ emails, setEmails }: any) => (
<div data-testid="multi-email-input">
<input
data-testid="email-input"
value={emails.join(", ")}
onChange={(e) => setEmails(e.target.value.split(", "))}
/>
</div>
),
}));
// Mock the Modal component
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open }: any) => (open ? <div data-testid="modal">{children}</div> : null),
}));
// Mock the Form components
vi.mock("@/modules/ui/components/form", () => ({
FormProvider: ({ children }: any) => <div data-testid="form-provider">{children}</div>,
FormField: ({ children }: any) => <div data-testid="form-field">{children}</div>,
FormItem: ({ children }: any) => <div data-testid="form-item">{children}</div>,
FormLabel: ({ children }: any) => <label data-testid="form-label">{children}</label>,
FormControl: ({ children }: any) => <div data-testid="form-control">{children}</div>,
FormDescription: ({ children }: any) => <div data-testid="form-description">{children}</div>,
}));
describe("FollowUpModal", () => {
afterEach(() => {
cleanup();
});
const mockSurvey: TSurvey = {
id: "survey-1",
name: "Test Survey",
createdAt: new Date(),
updatedAt: new Date(),
status: "draft",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: {
default: "What would you like to know?",
},
required: true,
charLimit: {},
inputType: "email",
longAnswer: false,
buttonLabel: {
default: "Next",
},
placeholder: {
default: "example@email.com",
},
},
],
hiddenFields: {
enabled: true,
fieldIds: ["hidden1"],
},
endings: [],
followUps: [],
} as unknown as TSurvey;
const mockTeamMemberDetails: TFollowUpEmailToUser[] = [
{ email: "team1@example.com", name: "Team 1" },
{ email: "team2@example.com", name: "Team 2" },
];
const defaultProps = {
localSurvey: mockSurvey,
open: true,
setOpen: vi.fn(),
selectedLanguageCode: "default",
mailFrom: "noreply@example.com",
userEmail: "user@example.com",
teamMemberDetails: mockTeamMemberDetails,
setLocalSurvey: vi.fn(),
locale: "en-US" as TUserLocale,
};
test("renders modal with create heading when mode is create", () => {
render(<FollowUpModal {...defaultProps} />);
expect(screen.getByText("environments.surveys.edit.follow_ups_modal_create_heading")).toBeInTheDocument();
});
test("renders modal with edit heading when mode is edit", () => {
render(
<FollowUpModal
{...defaultProps}
mode="edit"
defaultValues={{
surveyFollowUpId: "followup-1",
followUpName: "Test Follow-up",
triggerType: "response",
emailTo: "q1",
replyTo: ["user@example.com"],
subject: "Test Subject",
body: "Test Body",
attachResponseData: false,
}}
/>
);
expect(screen.getByText("environments.surveys.edit.follow_ups_modal_edit_heading")).toBeInTheDocument();
});
test("renders form fields in create mode", () => {
render(<FollowUpModal {...defaultProps} />);
expect(screen.getByText("environments.surveys.edit.follow_ups_modal_trigger_label")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.follow_ups_modal_action_label")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.edit.follow_ups_modal_action_email_settings")
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,37 @@
import * as constants from "@/lib/constants";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
import { getSurveyFollowUpsPermission } from "./utils";
vi.mock("@/lib/constants", async () => {
const actual = (await vi.importActual("@/lib/constants")) as any;
return {
...actual,
IS_FORMBRICKS_CLOUD: true,
PROJECT_FEATURE_KEYS: {
FREE: "free",
},
};
});
describe("getSurveyFollowUpsPermission", () => {
beforeEach(() => {
vi.spyOn(constants, "IS_FORMBRICKS_CLOUD", "get").mockReturnValue(true);
});
test("should return false for free plan on Formbricks Cloud", async () => {
const result = await getSurveyFollowUpsPermission("free" as TOrganizationBillingPlan);
expect(result).toBe(false);
});
test("should return true for non-free plan on Formbricks Cloud", async () => {
const result = await getSurveyFollowUpsPermission("startup" as TOrganizationBillingPlan);
expect(result).toBe(true);
});
test("should return true for any plan when not on Formbricks Cloud", async () => {
vi.spyOn(constants, "IS_FORMBRICKS_CLOUD", "get").mockReturnValue(false);
const result = await getSurveyFollowUpsPermission("free" as TOrganizationBillingPlan);
expect(result).toBe(true);
});
});

View File

@@ -17,67 +17,84 @@ export default defineConfig({
reportsDirectory: "./coverage", // Output coverage reports to the coverage/ directory
include: ["app/**/*.{ts,tsx}", "modules/**/*.{ts,tsx}", "lib/**/*.{ts,tsx}"],
exclude: [
// Build and configuration files
"**/.next/**", // Next.js build output
"**/*.config.{js,ts,mjs,mts,cjs}", // All configuration files
"**/Dockerfile", // Dockerfiles
"**/vitestSetup.ts", // Vitest setup files
"**/*.setup.*", // Setup files
// Test and mock related files
"**/*.spec.*", // Test files
"**/*.test.*", // Test files
"**/*.mock.*", // Mock files
"**/mocks/**", // Mock directories
"**/__mocks__/**", // Jest-style mock directories
"**/constants.ts", // Constants files
"**/playwright/**", // Playwright E2E test files
// Next.js specific files
"**/route.{ts,tsx}", // Next.js API routes
"**/openapi.ts", // OpenAPI spec files
"**/openapi-document.ts", // OpenAPI-related document files
"**/types/**", // Type definition folders
"**/types.ts", // Files named 'types.ts'
"**/actions.ts", // Server actions (plural)
"**/action.ts", // Server actions (singular)
"**/stories.*", // Storybook files (e.g., .stories.tsx)
"**/*.config.{js,ts,mjs,mts,cjs}", // All configuration files
"**/middleware.ts", // Next.js middleware
"**/instrumentation.ts", // Next.js instrumentation files
"**/instrumentation-node.ts", // Next.js Node.js instrumentation files
"**/vitestSetup.ts", // Vitest setup files
"**/*.setup.*", // Vitest setup files
// Documentation and static files
"**/openapi.ts", // OpenAPI spec files
"**/openapi-document.ts", // OpenAPI-related document files
"**/*.json", // JSON files
"**/*.mdx", // MDX files
"**/playwright/**", // Playwright E2E test files
"**/Dockerfile", // Dockerfiles
"**/*.css", // CSS files
// Type definitions and constants
"**/types/**", // Type definition folders
"**/types.ts", // Files named 'types.ts'
"**/constants.ts", // Constants files
// Server-side code
"**/actions.ts", // Server actions (plural)
"**/action.ts", // Server actions (singular)
"lib/env.ts", // Environment configuration
"lib/posthogServer.ts", // PostHog server integration
"**/cache.ts", // Cache files
"**/cache/**", // Cache directories
// UI Components and Templates
"**/stories.*", // Storybook files
"**/templates.ts", // Project-specific template files
"**/*.setup.*", // Setup files
"modules/ui/components/icons/*", // Icon components
"modules/ui/components/icons/**", // Icon components (nested)
// Feature-specific modules
"app/**/billing-confirmation/**", // Billing confirmation pages
"modules/ee/billing/**", // Enterprise billing features
"modules/ee/multi-language-surveys/**", // Multi-language survey features
"modules/email/**", // Email functionality
"modules/integrations/**", // Integration modules
"modules/setup/**/intro/**", // Setup intro pages
"modules/setup/**/signup/**", // Setup signup pages
"modules/setup/**/layout.tsx", // Setup layouts
"app/share/**", // Share functionality
"lib/shortUrl/**", // Short URL functionality
"app/[shortUrlId]", // Short URL pages
"modules/ee/contacts/components/**", // Contact components
// Third-party integrations
"lib/slack/**", // Slack integration
"lib/notion/**", // Notion integration
"lib/googleSheet/**", // Google Sheets integration
"app/api/google-sheet/**", // Google Sheets API
"app/api/billing/**", // Billing API
"lib/airtable/**", // Airtable integration
"app/api/v1/integrations/**", // Integration APIs
// Specific components
"packages/surveys/src/components/general/smileys.tsx", // Smiley components
"modules/analysis/components/SingleResponseCard/components/Smileys.tsx", // Analysis smiley components
"modules/auth/lib/mock-data.ts", // Mock data for authentication
// Other
"**/scripts/**", // Utility scripts
"modules/ui/components/icons/*",
"**/cache.ts", // Exclude cache files
"packages/surveys/src/components/general/smileys.tsx",
"modules/auth/lib/mock-data.ts", // Exclude mock data files
"modules/analysis/components/SingleResponseCard/components/Smileys.tsx",
"**/*.mjs",
"app/**/billing-confirmation/**",
"modules/ee/billing/**",
"modules/ee/multi-language-surveys/**",
"modules/email/**",
"modules/integrations/**",
"modules/setup/**/intro/**",
"modules/setup/**/signup/**",
"modules/setup/**/layout.tsx",
"modules/survey/follow-ups/**",
"modules/ui/components/icons/**",
"app/share/**",
"lib/shortUrl/**",
"app/[shortUrlId]",
"modules/ee/contacts/[contactId]/**",
"modules/ee/contacts/components/**",
"modules/ee/two-factor-auth/**",
"lib/posthogServer.ts",
"lib/slack/**",
"lib/notion/**",
"lib/googleSheet/**",
"app/api/google-sheet/**",
"app/api/billing/**",
"lib/airtable/**",
"app/api/v1/integrations/**",
"lib/env.ts",
"**/cache/**",
"**/*.mjs", // ES modules
],
},
},