mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 22:00:01 -06:00
test: backfill variety of test files (#5729)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import OnboardingLayout from "./layout";
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
POSTHOG_HOST: "mock-posthog-host",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "test-githubID",
|
||||
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||
OIDC_ISSUER: "test-oidc-issuer",
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getOrganizationProjectsCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
|
||||
describe("OnboardingLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("redirects to login if no session", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
|
||||
test("returns not found if user is member or billing", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws error if organization is not found", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getOrganization).mockResolvedValue(null);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await expect(OnboardingLayout(props)).rejects.toThrow("common.organization_not_found");
|
||||
});
|
||||
|
||||
test("redirects to home if project limit is reached", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(3);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
await OnboardingLayout(props);
|
||||
expect(redirect).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
test("renders children when all conditions are met", async () => {
|
||||
const mockSession = {
|
||||
user: { id: "test-user-id" },
|
||||
};
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession as any);
|
||||
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: "test-org-id",
|
||||
userId: "test-user-id",
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "test-org-id",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isAIEnabled: false,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
};
|
||||
vi.mocked(getOrganization).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(3);
|
||||
vi.mocked(getOrganizationProjectsCount).mockResolvedValue(2);
|
||||
|
||||
const props = {
|
||||
params: { organizationId: "test-org-id" },
|
||||
children: <div>Test Child</div>,
|
||||
};
|
||||
|
||||
const result = await OnboardingLayout(props);
|
||||
expect(result).toEqual(<>{props.children}</>);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
35
apps/web/app/(auth)/auth/forgot-password/page.test.tsx
Normal file
35
apps/web/app/(auth)/auth/forgot-password/page.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import ForgotPasswordPage from "./page";
|
||||
|
||||
vi.mock("@/modules/auth/forgot-password/page", () => ({
|
||||
ForgotPasswordPage: () => (
|
||||
<div data-testid="forgot-password-page">
|
||||
<div data-testid="form-wrapper">
|
||||
<div data-testid="forgot-password-form">Forgot Password Form</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ForgotPasswordPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the forgot password page", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("forgot-password-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the form wrapper", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("form-wrapper")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders the forgot password form", () => {
|
||||
render(<ForgotPasswordPage />);
|
||||
expect(screen.getByTestId("forgot-password-form")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
43
apps/web/app/lib/actionClass/actionClass.test.ts
Normal file
43
apps/web/app/lib/actionClass/actionClass.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { isValidCssSelector } from "./actionClass";
|
||||
|
||||
describe("isValidCssSelector", () => {
|
||||
beforeEach(() => {
|
||||
// Mock document.createElement and querySelector
|
||||
const mockElement = {
|
||||
querySelector: vi.fn(),
|
||||
};
|
||||
global.document = {
|
||||
createElement: vi.fn(() => mockElement),
|
||||
} as any;
|
||||
});
|
||||
|
||||
test("should return false for undefined selector", () => {
|
||||
expect(isValidCssSelector(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false for empty string", () => {
|
||||
expect(isValidCssSelector("")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for valid CSS selector", () => {
|
||||
const mockElement = {
|
||||
querySelector: vi.fn(),
|
||||
};
|
||||
(document.createElement as any).mockReturnValue(mockElement);
|
||||
expect(isValidCssSelector(".class")).toBe(true);
|
||||
expect(isValidCssSelector("#id")).toBe(true);
|
||||
expect(isValidCssSelector("div")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for invalid CSS selector", () => {
|
||||
const mockElement = {
|
||||
querySelector: vi.fn(() => {
|
||||
throw new Error("Invalid selector");
|
||||
}),
|
||||
};
|
||||
(document.createElement as any).mockReturnValue(mockElement);
|
||||
expect(isValidCssSelector("..invalid")).toBe(false);
|
||||
expect(isValidCssSelector("##invalid")).toBe(false);
|
||||
});
|
||||
});
|
||||
70
apps/web/app/middleware/bucket.test.ts
Normal file
70
apps/web/app/middleware/bucket.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
140
apps/web/app/middleware/endpoint-validator.test.ts
Normal file
140
apps/web/app/middleware/endpoint-validator.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
isAuthProtectedRoute,
|
||||
isClientSideApiRoute,
|
||||
isForgotPasswordRoute,
|
||||
isLoginRoute,
|
||||
isManagementApiRoute,
|
||||
isShareUrlRoute,
|
||||
isSignupRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
isVerifyEmailRoute,
|
||||
} from "./endpoint-validator";
|
||||
|
||||
describe("endpoint-validator", () => {
|
||||
describe("isLoginRoute", () => {
|
||||
test("should return true for login routes", () => {
|
||||
expect(isLoginRoute("/api/auth/callback/credentials")).toBe(true);
|
||||
expect(isLoginRoute("/auth/login")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-login routes", () => {
|
||||
expect(isLoginRoute("/auth/signup")).toBe(false);
|
||||
expect(isLoginRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSignupRoute", () => {
|
||||
test("should return true for signup route", () => {
|
||||
expect(isSignupRoute("/auth/signup")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-signup routes", () => {
|
||||
expect(isSignupRoute("/auth/login")).toBe(false);
|
||||
expect(isSignupRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVerifyEmailRoute", () => {
|
||||
test("should return true for verify email route", () => {
|
||||
expect(isVerifyEmailRoute("/auth/verify-email")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-verify email routes", () => {
|
||||
expect(isVerifyEmailRoute("/auth/login")).toBe(false);
|
||||
expect(isVerifyEmailRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isForgotPasswordRoute", () => {
|
||||
test("should return true for forgot password route", () => {
|
||||
expect(isForgotPasswordRoute("/auth/forgot-password")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-forgot password routes", () => {
|
||||
expect(isForgotPasswordRoute("/auth/login")).toBe(false);
|
||||
expect(isForgotPasswordRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClientSideApiRoute", () => {
|
||||
test("should return true for client-side API routes", () => {
|
||||
expect(isClientSideApiRoute("/api/packages/something")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/storage")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/other")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v2/client/other")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-client-side API routes", () => {
|
||||
expect(isClientSideApiRoute("/api/v1/management/something")).toBe(false);
|
||||
expect(isClientSideApiRoute("/api/something")).toBe(false);
|
||||
expect(isClientSideApiRoute("/auth/login")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isManagementApiRoute", () => {
|
||||
test("should return true for management API routes", () => {
|
||||
expect(isManagementApiRoute("/api/v1/management/something")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v2/management/other")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-management API routes", () => {
|
||||
expect(isManagementApiRoute("/api/v1/client/something")).toBe(false);
|
||||
expect(isManagementApiRoute("/api/something")).toBe(false);
|
||||
expect(isManagementApiRoute("/auth/login")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isShareUrlRoute", () => {
|
||||
test("should return true for share URL routes", () => {
|
||||
expect(isShareUrlRoute("/share/abc123/summary")).toBe(true);
|
||||
expect(isShareUrlRoute("/share/abc123/responses")).toBe(true);
|
||||
expect(isShareUrlRoute("/share/abc123def456/summary")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-share URL routes", () => {
|
||||
expect(isShareUrlRoute("/share/abc123")).toBe(false);
|
||||
expect(isShareUrlRoute("/share/abc123/other")).toBe(false);
|
||||
expect(isShareUrlRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthProtectedRoute", () => {
|
||||
test("should return true for protected routes", () => {
|
||||
expect(isAuthProtectedRoute("/environments")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/environments/something")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/setup/organization")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/organizations")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/organizations/something")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-protected routes", () => {
|
||||
expect(isAuthProtectedRoute("/auth/login")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/api/something")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSyncWithUserIdentificationEndpoint", () => {
|
||||
test("should return environmentId and userId for valid sync URLs", () => {
|
||||
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
|
||||
expect(result1).toEqual({
|
||||
environmentId: "env123",
|
||||
userId: "user456",
|
||||
});
|
||||
|
||||
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/abc-123/app/sync/xyz-789");
|
||||
expect(result2).toEqual({
|
||||
environmentId: "abc-123",
|
||||
userId: "xyz-789",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false for invalid sync URLs", () => {
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
291
apps/web/lib/integration/service.test.ts
Normal file
291
apps/web/lib/integration/service.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { IntegrationType, Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TIntegrationInput } from "@formbricks/types/integration";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import {
|
||||
createOrUpdateIntegration,
|
||||
deleteIntegration,
|
||||
getIntegration,
|
||||
getIntegrationByType,
|
||||
getIntegrations,
|
||||
} from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
integration: {
|
||||
upsert: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Integration Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockIntegrationConfig = {
|
||||
email: "test@example.com",
|
||||
key: {
|
||||
scope: "https://www.googleapis.com/auth/spreadsheets",
|
||||
token_type: "Bearer" as const,
|
||||
expiry_date: 1234567890,
|
||||
access_token: "mock-access-token",
|
||||
refresh_token: "mock-refresh-token",
|
||||
},
|
||||
data: [
|
||||
{
|
||||
spreadsheetId: "spreadsheet123",
|
||||
spreadsheetName: "Test Spreadsheet",
|
||||
surveyId: "survey123",
|
||||
surveyName: "Test Survey",
|
||||
questionIds: ["q1", "q2"],
|
||||
questions: "Question 1, Question 2",
|
||||
createdAt: new Date(),
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: true,
|
||||
includeCreatedAt: true,
|
||||
includeVariables: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("createOrUpdateIntegration", () => {
|
||||
const mockEnvironmentId = "clg123456789012345678901234";
|
||||
const mockIntegrationData: TIntegrationInput = {
|
||||
type: "googleSheets",
|
||||
config: mockIntegrationConfig,
|
||||
};
|
||||
|
||||
test("should create a new integration", async () => {
|
||||
const mockIntegration = {
|
||||
id: "int_123",
|
||||
environmentId: mockEnvironmentId,
|
||||
...mockIntegrationData,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.integration.upsert).mockResolvedValue(mockIntegration);
|
||||
|
||||
const result = await createOrUpdateIntegration(mockEnvironmentId, mockIntegrationData);
|
||||
|
||||
expect(prisma.integration.upsert).toHaveBeenCalledWith({
|
||||
where: {
|
||||
type_environmentId: {
|
||||
environmentId: mockEnvironmentId,
|
||||
type: mockIntegrationData.type,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
...mockIntegrationData,
|
||||
environment: { connect: { id: mockEnvironmentId } },
|
||||
},
|
||||
create: {
|
||||
...mockIntegrationData,
|
||||
environment: { connect: { id: mockEnvironmentId } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegration);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when Prisma throws an error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.integration.upsert).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createOrUpdateIntegration(mockEnvironmentId, mockIntegrationData)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegrations", () => {
|
||||
const mockEnvironmentId = "clg123456789012345678901234";
|
||||
const mockIntegrations = [
|
||||
{
|
||||
id: "int_123",
|
||||
environmentId: mockEnvironmentId,
|
||||
type: IntegrationType.googleSheets,
|
||||
config: mockIntegrationConfig,
|
||||
},
|
||||
];
|
||||
|
||||
test("should get all integrations for an environment", async () => {
|
||||
vi.mocked(prisma.integration.findMany).mockResolvedValue(mockIntegrations);
|
||||
|
||||
const result = await getIntegrations(mockEnvironmentId);
|
||||
|
||||
expect(prisma.integration.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegrations);
|
||||
});
|
||||
|
||||
test("should get paginated integrations", async () => {
|
||||
const page = 2;
|
||||
vi.mocked(prisma.integration.findMany).mockResolvedValue(mockIntegrations);
|
||||
|
||||
const result = await getIntegrations(mockEnvironmentId, page);
|
||||
|
||||
expect(prisma.integration.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: ITEMS_PER_PAGE * (page - 1),
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegrations);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when Prisma throws an error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.integration.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getIntegrations(mockEnvironmentId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegration", () => {
|
||||
const mockIntegrationId = "int_123";
|
||||
const mockIntegration = {
|
||||
id: mockIntegrationId,
|
||||
environmentId: "clg123456789012345678901234",
|
||||
type: IntegrationType.googleSheets,
|
||||
config: mockIntegrationConfig,
|
||||
};
|
||||
|
||||
test("should get an integration by ID", async () => {
|
||||
vi.mocked(prisma.integration.findUnique).mockResolvedValue(mockIntegration);
|
||||
|
||||
const result = await getIntegration(mockIntegrationId);
|
||||
|
||||
expect(prisma.integration.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockIntegrationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegration);
|
||||
});
|
||||
|
||||
test("should return null when integration is not found", async () => {
|
||||
vi.mocked(prisma.integration.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getIntegration(mockIntegrationId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when Prisma throws an error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.integration.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getIntegration(mockIntegrationId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIntegrationByType", () => {
|
||||
const mockEnvironmentId = "clg123456789012345678901234";
|
||||
const mockType = IntegrationType.googleSheets;
|
||||
const mockIntegration = {
|
||||
id: "int_123",
|
||||
environmentId: mockEnvironmentId,
|
||||
type: mockType,
|
||||
config: mockIntegrationConfig,
|
||||
};
|
||||
|
||||
test("should get an integration by type", async () => {
|
||||
vi.mocked(prisma.integration.findUnique).mockResolvedValue(mockIntegration);
|
||||
|
||||
const result = await getIntegrationByType(mockEnvironmentId, mockType);
|
||||
|
||||
expect(prisma.integration.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
type_environmentId: {
|
||||
environmentId: mockEnvironmentId,
|
||||
type: mockType,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegration);
|
||||
});
|
||||
|
||||
test("should return null when integration is not found", async () => {
|
||||
vi.mocked(prisma.integration.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getIntegrationByType(mockEnvironmentId, mockType);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when Prisma throws an error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.integration.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getIntegrationByType(mockEnvironmentId, mockType)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteIntegration", () => {
|
||||
const mockIntegrationId = "int_123";
|
||||
const mockIntegration = {
|
||||
id: mockIntegrationId,
|
||||
environmentId: "clg123456789012345678901234",
|
||||
type: IntegrationType.googleSheets,
|
||||
config: mockIntegrationConfig,
|
||||
};
|
||||
|
||||
test("should delete an integration", async () => {
|
||||
vi.mocked(prisma.integration.delete).mockResolvedValue(mockIntegration);
|
||||
|
||||
const result = await deleteIntegration(mockIntegrationId);
|
||||
|
||||
expect(prisma.integration.delete).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockIntegrationId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockIntegration);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when Prisma throws an error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.integration.delete).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteIntegration(mockIntegrationId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
|
||||
import { cache } from "../cache";
|
||||
|
||||
53
apps/web/lib/membership/hooks/useMembershipRole.test.tsx
Normal file
53
apps/web/lib/membership/hooks/useMembershipRole.test.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { getMembershipByUserIdOrganizationIdAction } from "./actions";
|
||||
import { useMembershipRole } from "./useMembershipRole";
|
||||
|
||||
vi.mock("./actions", () => ({
|
||||
getMembershipByUserIdOrganizationIdAction: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("useMembershipRole", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should fetch and return membership role", async () => {
|
||||
const mockRole: TOrganizationRole = "owner";
|
||||
vi.mocked(getMembershipByUserIdOrganizationIdAction).mockResolvedValue(mockRole);
|
||||
|
||||
const { result } = renderHook(() => useMembershipRole("env-123", "user-123"));
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.membershipRole).toBeUndefined();
|
||||
expect(result.current.error).toBe("");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.membershipRole).toBe(mockRole);
|
||||
expect(result.current.error).toBe("");
|
||||
expect(getMembershipByUserIdOrganizationIdAction).toHaveBeenCalledWith("env-123", "user-123");
|
||||
});
|
||||
|
||||
test("should handle error when fetching membership role fails", async () => {
|
||||
const errorMessage = "Failed to fetch role";
|
||||
vi.mocked(getMembershipByUserIdOrganizationIdAction).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const { result } = renderHook(() => useMembershipRole("env-123", "user-123"));
|
||||
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
expect(result.current.membershipRole).toBeUndefined();
|
||||
expect(result.current.error).toBe("");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.membershipRole).toBeUndefined();
|
||||
expect(result.current.error).toBe(errorMessage);
|
||||
expect(getMembershipByUserIdOrganizationIdAction).toHaveBeenCalledWith("env-123", "user-123");
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const useMembershipRole = (environmentId: string, userId: string) => {
|
||||
} catch (err: any) {
|
||||
const error = err?.message || "Something went wrong";
|
||||
setError(error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
getRole();
|
||||
|
||||
184
apps/web/lib/membership/service.test.ts
Normal file
184
apps/web/lib/membership/service.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { membershipCache } from "./cache";
|
||||
import { createMembership, getMembershipByUserIdOrganizationId } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
membership: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
membershipCache: {
|
||||
tag: {
|
||||
byUserId: vi.fn(),
|
||||
byOrganizationId: vi.fn(),
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Membership Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getMembershipByUserIdOrganizationId", () => {
|
||||
const mockUserId = "user123";
|
||||
const mockOrgId = "org123";
|
||||
|
||||
test("returns membership when found", async () => {
|
||||
const mockMembership: TMembership = {
|
||||
organizationId: mockOrgId,
|
||||
userId: mockUserId,
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
};
|
||||
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(mockMembership);
|
||||
|
||||
const result = await getMembershipByUserIdOrganizationId(mockUserId, mockOrgId);
|
||||
expect(result).toEqual(mockMembership);
|
||||
expect(prisma.membership.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null when membership not found", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getMembershipByUserIdOrganizationId(mockUserId, mockOrgId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.membership.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws UnknownError on unknown error", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockRejectedValue(new Error("Unknown error"));
|
||||
|
||||
await expect(getMembershipByUserIdOrganizationId(mockUserId, mockOrgId)).rejects.toThrow(UnknownError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createMembership", () => {
|
||||
const mockUserId = "user123";
|
||||
const mockOrgId = "org123";
|
||||
const mockMembershipData: Partial<TMembership> = {
|
||||
accepted: true,
|
||||
role: "member",
|
||||
};
|
||||
|
||||
test("creates new membership when none exists", async () => {
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(null);
|
||||
|
||||
const mockCreatedMembership = {
|
||||
organizationId: mockOrgId,
|
||||
userId: mockUserId,
|
||||
accepted: true,
|
||||
role: "member",
|
||||
} as TMembership;
|
||||
|
||||
vi.mocked(prisma.membership.create).mockResolvedValue(mockCreatedMembership as any);
|
||||
|
||||
const result = await createMembership(mockOrgId, mockUserId, mockMembershipData);
|
||||
expect(result).toEqual(mockCreatedMembership);
|
||||
expect(prisma.membership.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
accepted: mockMembershipData.accepted,
|
||||
role: mockMembershipData.role,
|
||||
},
|
||||
});
|
||||
expect(membershipCache.revalidate).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns existing membership if role matches", async () => {
|
||||
const existingMembership = {
|
||||
organizationId: mockOrgId,
|
||||
userId: mockUserId,
|
||||
accepted: true,
|
||||
role: "member",
|
||||
} as TMembership;
|
||||
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(existingMembership as any);
|
||||
|
||||
const result = await createMembership(mockOrgId, mockUserId, mockMembershipData);
|
||||
expect(result).toEqual(existingMembership);
|
||||
expect(prisma.membership.create).not.toHaveBeenCalled();
|
||||
expect(prisma.membership.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("updates existing membership if role differs", async () => {
|
||||
const existingMembership = {
|
||||
organizationId: mockOrgId,
|
||||
userId: mockUserId,
|
||||
accepted: true,
|
||||
role: "member",
|
||||
} as TMembership;
|
||||
|
||||
const updatedMembership = {
|
||||
...existingMembership,
|
||||
role: "owner",
|
||||
} as TMembership;
|
||||
|
||||
vi.mocked(prisma.membership.findUnique).mockResolvedValue(existingMembership as any);
|
||||
vi.mocked(prisma.membership.update).mockResolvedValue(updatedMembership as any);
|
||||
|
||||
const result = await createMembership(mockOrgId, mockUserId, { ...mockMembershipData, role: "owner" });
|
||||
expect(result).toEqual(updatedMembership);
|
||||
expect(prisma.membership.update).toHaveBeenCalledWith({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
accepted: mockMembershipData.accepted,
|
||||
role: "owner",
|
||||
},
|
||||
});
|
||||
expect(membershipCache.revalidate).toHaveBeenCalledWith({
|
||||
userId: mockUserId,
|
||||
organizationId: mockOrgId,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.membership.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(createMembership(mockOrgId, mockUserId, mockMembershipData)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
59
apps/web/lib/membership/utils.test.ts
Normal file
59
apps/web/lib/membership/utils.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { getAccessFlags } from "./utils";
|
||||
|
||||
describe("getAccessFlags", () => {
|
||||
test("should return correct flags for owner role", () => {
|
||||
const role: TOrganizationRole = "owner";
|
||||
const flags = getAccessFlags(role);
|
||||
expect(flags).toEqual({
|
||||
isManager: false,
|
||||
isOwner: true,
|
||||
isBilling: false,
|
||||
isMember: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct flags for manager role", () => {
|
||||
const role: TOrganizationRole = "manager";
|
||||
const flags = getAccessFlags(role);
|
||||
expect(flags).toEqual({
|
||||
isManager: true,
|
||||
isOwner: false,
|
||||
isBilling: false,
|
||||
isMember: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct flags for billing role", () => {
|
||||
const role: TOrganizationRole = "billing";
|
||||
const flags = getAccessFlags(role);
|
||||
expect(flags).toEqual({
|
||||
isManager: false,
|
||||
isOwner: false,
|
||||
isBilling: true,
|
||||
isMember: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return correct flags for member role", () => {
|
||||
const role: TOrganizationRole = "member";
|
||||
const flags = getAccessFlags(role);
|
||||
expect(flags).toEqual({
|
||||
isManager: false,
|
||||
isOwner: false,
|
||||
isBilling: false,
|
||||
isMember: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return all flags as false when role is undefined", () => {
|
||||
const flags = getAccessFlags(undefined);
|
||||
expect(flags).toEqual({
|
||||
isManager: false,
|
||||
isOwner: false,
|
||||
isBilling: false,
|
||||
isMember: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
421
apps/web/lib/project/service.test.ts
Normal file
421
apps/web/lib/project/service.test.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { getProject, getProjectByEnvironmentId, getProjects, getUserProjects } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
membership: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Project Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("getProject should return a project when it exists", async () => {
|
||||
const mockProject = {
|
||||
id: createId(),
|
||||
name: "Test Project",
|
||||
organizationId: createId(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.project.findUnique).mockResolvedValue(mockProject);
|
||||
|
||||
const result = await getProject(mockProject.id);
|
||||
|
||||
expect(result).toEqual(mockProject);
|
||||
expect(prisma.project.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockProject.id,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("getProject should return null when project does not exist", async () => {
|
||||
vi.mocked(prisma.project.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getProject(createId());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("getProject should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.project.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getProject(createId())).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("getProjectByEnvironmentId should return a project when it exists", async () => {
|
||||
const mockProject = {
|
||||
id: createId(),
|
||||
name: "Test Project",
|
||||
organizationId: createId(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
};
|
||||
|
||||
vi.mocked(prisma.project.findFirst).mockResolvedValue(mockProject);
|
||||
|
||||
const result = await getProjectByEnvironmentId(createId());
|
||||
|
||||
expect(result).toEqual(mockProject);
|
||||
expect(prisma.project.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environments: {
|
||||
some: {
|
||||
id: expect.any(String),
|
||||
},
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("getProjectByEnvironmentId should return null when project does not exist", async () => {
|
||||
vi.mocked(prisma.project.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await getProjectByEnvironmentId(createId());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("getProjectByEnvironmentId should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.project.findFirst).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getProjectByEnvironmentId(createId())).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("getUserProjects should return projects for admin user", async () => {
|
||||
const userId = createId();
|
||||
const organizationId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 1",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 2",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
id: createId(),
|
||||
userId,
|
||||
organizationId,
|
||||
role: "admin",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
|
||||
const result = await getUserProjects(userId, organizationId);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("getUserProjects should return projects for member user", async () => {
|
||||
const userId = createId();
|
||||
const organizationId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 1",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
id: createId(),
|
||||
userId,
|
||||
organizationId,
|
||||
role: "member",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
|
||||
const result = await getUserProjects(userId, organizationId);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId,
|
||||
projectTeams: {
|
||||
some: {
|
||||
team: {
|
||||
teamUsers: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("getUserProjects should throw ValidationError when user is not a member of organization", async () => {
|
||||
const userId = createId();
|
||||
const organizationId = createId();
|
||||
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue(null);
|
||||
|
||||
await expect(getUserProjects(userId, organizationId)).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("getUserProjects should handle pagination", async () => {
|
||||
const userId = createId();
|
||||
const organizationId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 1",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.membership.findFirst).mockResolvedValue({
|
||||
id: createId(),
|
||||
userId,
|
||||
organizationId,
|
||||
role: "admin",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
|
||||
const page = 2;
|
||||
const result = await getUserProjects(userId, organizationId, page);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: ITEMS_PER_PAGE * (page - 1),
|
||||
});
|
||||
});
|
||||
|
||||
test("getProjects should return all projects for an organization", async () => {
|
||||
const organizationId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 1",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 2",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
|
||||
const result = await getProjects(organizationId);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("getProjects should handle pagination", async () => {
|
||||
const organizationId = createId();
|
||||
const mockProjects = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Test Project 1",
|
||||
organizationId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
languages: ["en"],
|
||||
recontactDays: 0,
|
||||
linkSurveyBranding: true,
|
||||
inAppSurveyBranding: true,
|
||||
config: {},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
darkOverlay: false,
|
||||
environments: [],
|
||||
styling: {},
|
||||
logo: null,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects);
|
||||
|
||||
const page = 2;
|
||||
const result = await getProjects(organizationId, page);
|
||||
|
||||
expect(result).toEqual(mockProjects);
|
||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
select: expect.any(Object),
|
||||
take: ITEMS_PER_PAGE,
|
||||
skip: ITEMS_PER_PAGE * (page - 1),
|
||||
});
|
||||
});
|
||||
|
||||
test("getProjects should throw DatabaseError when prisma throws", async () => {
|
||||
const organizationId = createId();
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getProjects(organizationId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import type { TProject } from "@formbricks/types/project";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
|
||||
@@ -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";
|
||||
|
||||
137
apps/web/lib/tag/service.test.ts
Normal file
137
apps/web/lib/tag/service.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { tagCache } from "./cache";
|
||||
import { createTag, getTag, getTagsByEnvironmentId } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
tagCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `tag-${id}`),
|
||||
byEnvironmentId: vi.fn((envId) => `env-${envId}-tags`),
|
||||
},
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Tag Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getTagsByEnvironmentId", () => {
|
||||
test("should return tags for a given environment ID", async () => {
|
||||
const mockTags: TTag[] = [
|
||||
{
|
||||
id: "tag1",
|
||||
name: "Tag 1",
|
||||
environmentId: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags);
|
||||
|
||||
const result = await getTagsByEnvironmentId("env1");
|
||||
expect(result).toEqual(mockTags);
|
||||
expect(prisma.tag.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: "env1",
|
||||
},
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle pagination correctly", async () => {
|
||||
const mockTags: TTag[] = [
|
||||
{
|
||||
id: "tag1",
|
||||
name: "Tag 1",
|
||||
environmentId: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(prisma.tag.findMany).mockResolvedValue(mockTags);
|
||||
|
||||
const result = await getTagsByEnvironmentId("env1", 1);
|
||||
expect(result).toEqual(mockTags);
|
||||
expect(prisma.tag.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: "env1",
|
||||
},
|
||||
take: 30,
|
||||
skip: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTag", () => {
|
||||
test("should return a tag by ID", async () => {
|
||||
const mockTag: TTag = {
|
||||
id: "tag1",
|
||||
name: "Tag 1",
|
||||
environmentId: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.tag.findUnique).mockResolvedValue(mockTag);
|
||||
|
||||
const result = await getTag("tag1");
|
||||
expect(result).toEqual(mockTag);
|
||||
expect(prisma.tag.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "tag1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null when tag is not found", async () => {
|
||||
vi.mocked(prisma.tag.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getTag("nonexistent");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTag", () => {
|
||||
test("should create a new tag", async () => {
|
||||
const mockTag: TTag = {
|
||||
id: "tag1",
|
||||
name: "New Tag",
|
||||
environmentId: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
vi.mocked(prisma.tag.create).mockResolvedValue(mockTag);
|
||||
|
||||
const result = await createTag("env1", "New Tag");
|
||||
expect(result).toEqual(mockTag);
|
||||
expect(prisma.tag.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "New Tag",
|
||||
environmentId: "env1",
|
||||
},
|
||||
});
|
||||
expect(tagCache.revalidate).toHaveBeenCalledWith({
|
||||
id: "tag1",
|
||||
environmentId: "env1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,7 @@ import "server-only";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
188
apps/web/lib/tagOnResponse/service.test.ts
Normal file
188
apps/web/lib/tagOnResponse/service.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { responseCache } from "../response/cache";
|
||||
import { getResponse } from "../response/service";
|
||||
import { tagOnResponseCache } from "./cache";
|
||||
import { addTagToRespone, deleteTagOnResponse, getTagsOnResponsesCount } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
tagsOnResponses: {
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
groupBy: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../response/service", () => ({
|
||||
getResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../response/cache", () => ({
|
||||
responseCache: {
|
||||
revalidate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./cache", () => ({
|
||||
tagOnResponseCache: {
|
||||
revalidate: vi.fn(),
|
||||
tag: {
|
||||
byEnvironmentId: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("TagOnResponse Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("addTagToRespone should add a tag to a response", async () => {
|
||||
const mockResponse = {
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
contact: { id: "contact1" },
|
||||
};
|
||||
|
||||
const mockTagOnResponse = {
|
||||
tag: {
|
||||
environmentId: "env1",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getResponse).mockResolvedValue(mockResponse as any);
|
||||
vi.mocked(prisma.tagsOnResponses.create).mockResolvedValue(mockTagOnResponse as any);
|
||||
|
||||
const result = await addTagToRespone("response1", "tag1");
|
||||
|
||||
expect(result).toEqual({
|
||||
responseId: "response1",
|
||||
tagId: "tag1",
|
||||
});
|
||||
|
||||
expect(prisma.tagsOnResponses.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
responseId: "response1",
|
||||
tagId: "tag1",
|
||||
},
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(responseCache.revalidate).toHaveBeenCalledWith({
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
contactId: "contact1",
|
||||
});
|
||||
|
||||
expect(tagOnResponseCache.revalidate).toHaveBeenCalledWith({
|
||||
tagId: "tag1",
|
||||
responseId: "response1",
|
||||
environmentId: "env1",
|
||||
});
|
||||
});
|
||||
|
||||
test("deleteTagOnResponse should delete a tag from a response", async () => {
|
||||
const mockResponse = {
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
contact: { id: "contact1" },
|
||||
};
|
||||
|
||||
const mockDeletedTag = {
|
||||
tag: {
|
||||
environmentId: "env1",
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(getResponse).mockResolvedValue(mockResponse as any);
|
||||
vi.mocked(prisma.tagsOnResponses.delete).mockResolvedValue(mockDeletedTag as any);
|
||||
|
||||
const result = await deleteTagOnResponse("response1", "tag1");
|
||||
|
||||
expect(result).toEqual({
|
||||
responseId: "response1",
|
||||
tagId: "tag1",
|
||||
});
|
||||
|
||||
expect(prisma.tagsOnResponses.delete).toHaveBeenCalledWith({
|
||||
where: {
|
||||
responseId_tagId: {
|
||||
responseId: "response1",
|
||||
tagId: "tag1",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
tag: {
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(responseCache.revalidate).toHaveBeenCalledWith({
|
||||
id: "response1",
|
||||
surveyId: "survey1",
|
||||
contactId: "contact1",
|
||||
});
|
||||
|
||||
expect(tagOnResponseCache.revalidate).toHaveBeenCalledWith({
|
||||
tagId: "tag1",
|
||||
responseId: "response1",
|
||||
environmentId: "env1",
|
||||
});
|
||||
});
|
||||
|
||||
test("getTagsOnResponsesCount should return tag counts for an environment", async () => {
|
||||
const mockTagsCount = [
|
||||
{ tagId: "tag1", _count: { _all: 5 } },
|
||||
{ tagId: "tag2", _count: { _all: 3 } },
|
||||
];
|
||||
|
||||
vi.mocked(prisma.tagsOnResponses.groupBy).mockResolvedValue(mockTagsCount as any);
|
||||
vi.mocked(tagOnResponseCache.tag.byEnvironmentId).mockReturnValue("env1");
|
||||
|
||||
const result = await getTagsOnResponsesCount("env1");
|
||||
|
||||
expect(result).toEqual([
|
||||
{ tagId: "tag1", count: 5 },
|
||||
{ tagId: "tag2", count: 3 },
|
||||
]);
|
||||
|
||||
expect(prisma.tagsOnResponses.groupBy).toHaveBeenCalledWith({
|
||||
by: ["tagId"],
|
||||
where: {
|
||||
response: {
|
||||
survey: {
|
||||
environment: {
|
||||
id: "env1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma operation fails", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.tagsOnResponses.create).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(addTagToRespone("response1", "tag1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
271
apps/web/lib/user/service.test.ts
Normal file
271
apps/web/lib/user/service.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { IdentityProvider, Objective, Prisma, Role } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
|
||||
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/fileValidation", () => ({
|
||||
isValidImageFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
|
||||
deleteOrganization: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("User Service", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockPrismaUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: Role.project_manager,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: IdentityProvider.email,
|
||||
objective: Objective.increase_conversion,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
locale: "en-US" as TUserLocale,
|
||||
lastLoginAt: new Date(),
|
||||
isActive: true,
|
||||
twoFactorSecret: null,
|
||||
backupCodes: null,
|
||||
password: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
};
|
||||
|
||||
const mockOrganizations: TOrganization[] = [
|
||||
{
|
||||
id: "org1",
|
||||
name: "Organization 1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "org2",
|
||||
name: "Organization 2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
projects: 3,
|
||||
monthly: {
|
||||
responses: 1500,
|
||||
miu: 2000,
|
||||
},
|
||||
},
|
||||
periodStart: new Date(),
|
||||
},
|
||||
isAIEnabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe("getUser", () => {
|
||||
test("should return user when found", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(mockPrismaUser);
|
||||
|
||||
const result = await getUser("user1");
|
||||
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null when user not found", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getUser("nonexistent");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.user.findUnique).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getUser("user1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserByEmail", () => {
|
||||
test("should return user when found by email", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(mockPrismaUser);
|
||||
|
||||
const result = await getUserByEmail("test@example.com");
|
||||
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
||||
where: { email: "test@example.com" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null when user not found by email", async () => {
|
||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await getUserByEmail("nonexistent@example.com");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.user.findFirst).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getUserByEmail("test@example.com")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateUser", () => {
|
||||
test("should update user successfully", async () => {
|
||||
const updatedPrismaUser = {
|
||||
...mockPrismaUser,
|
||||
name: "Updated User",
|
||||
};
|
||||
|
||||
const updateData: TUserUpdateInput = {
|
||||
name: "Updated User",
|
||||
};
|
||||
|
||||
vi.mocked(prisma.user.update).mockResolvedValue(updatedPrismaUser);
|
||||
|
||||
const result = await updateUser("user1", updateData);
|
||||
|
||||
expect(result).toEqual(updatedPrismaUser);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
data: updateData,
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when user not found", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.user.update).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(updateUser("nonexistent", { name: "New Name" })).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError when invalid image URL is provided", async () => {
|
||||
const { isValidImageFile } = await import("@/lib/fileValidation");
|
||||
vi.mocked(isValidImageFile).mockReturnValue(false);
|
||||
|
||||
await expect(updateUser("user1", { imageUrl: "invalid-image-url" })).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteUser", () => {
|
||||
test("should delete user and their organizations when they are single owner", async () => {
|
||||
vi.mocked(prisma.user.delete).mockResolvedValue(mockPrismaUser);
|
||||
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValue(mockOrganizations);
|
||||
vi.mocked(deleteOrganization).mockResolvedValue();
|
||||
|
||||
const result = await deleteUser("user1");
|
||||
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("user1");
|
||||
expect(deleteOrganization).toHaveBeenCalledWith("org1");
|
||||
expect(prisma.user.delete).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValue([]);
|
||||
vi.mocked(prisma.user.delete).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteUser("user1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUsersWithOrganization", () => {
|
||||
test("should return users in an organization", async () => {
|
||||
const mockUsers = [mockPrismaUser];
|
||||
vi.mocked(prisma.user.findMany).mockResolvedValue(mockUsers);
|
||||
|
||||
const result = await getUsersWithOrganization("org1");
|
||||
|
||||
expect(result).toEqual(mockUsers);
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
memberships: {
|
||||
some: {
|
||||
organizationId: "org1",
|
||||
},
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw DatabaseError when prisma throws", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "5.0.0",
|
||||
});
|
||||
vi.mocked(prisma.user.findMany).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getUsersWithOrganization("org1")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
});
|
||||
29
apps/web/modules/api/v2/roles/lib/utils.test.ts
Normal file
29
apps/web/modules/api/v2/roles/lib/utils.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as constants from "@/lib/constants";
|
||||
import { OrganizationRole } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { getRoles } from "./utils";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
}));
|
||||
|
||||
describe("getRoles", () => {
|
||||
test("should return all roles except billing when not in Formbricks Cloud", () => {
|
||||
const result = getRoles();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.data).toEqual(Object.values(OrganizationRole).filter((role) => role !== "billing"));
|
||||
}
|
||||
});
|
||||
|
||||
test("should return all roles including billing when in Formbricks Cloud", () => {
|
||||
const originalValue = constants.IS_FORMBRICKS_CLOUD;
|
||||
Object.defineProperty(constants, "IS_FORMBRICKS_CLOUD", { value: true });
|
||||
const result = getRoles();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.data).toEqual(Object.values(OrganizationRole));
|
||||
}
|
||||
Object.defineProperty(constants, "IS_FORMBRICKS_CLOUD", { value: originalValue });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
80
apps/web/modules/ee/role-management/lib/invite.test.ts
Normal file
80
apps/web/modules/ee/role-management/lib/invite.test.ts
Normal 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")
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
apps/web/modules/ee/role-management/lib/membership.test.ts
Normal file
142
apps/web/modules/ee/role-management/lib/membership.test.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
378
apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.test.ts
Normal file
378
apps/web/modules/ee/two-factor-auth/lib/two-factor-auth.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
37
apps/web/modules/survey/follow-ups/lib/utils.test.ts
Normal file
37
apps/web/modules/survey/follow-ups/lib/utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user