chore: adds unit tests for ee/contact/lib and modules/organization (#5713)

This commit is contained in:
Piyush Gupta
2025-05-08 18:36:39 +05:30
committed by GitHub
parent 65da25a626
commit d1cdf6e216
27 changed files with 2476 additions and 8 deletions
@@ -0,0 +1,155 @@
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { hasEmailAttribute } from "@/modules/ee/contacts/lib/contact-attributes";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { updateAttributes } from "./attributes";
vi.mock("@/lib/cache/contact-attribute", () => ({
contactAttributeCache: { revalidate: vi.fn() },
}));
vi.mock("@/lib/cache/contact-attribute-key", () => ({
contactAttributeKeyCache: { revalidate: vi.fn() },
}));
vi.mock("@/lib/constants", () => ({
MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT: 2,
}));
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/lib/contact-attribute-keys", () => ({
getContactAttributeKeys: vi.fn(),
}));
vi.mock("@/modules/ee/contacts/lib/contact-attributes", () => ({
hasEmailAttribute: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
contactAttribute: { upsert: vi.fn() },
contactAttributeKey: { create: vi.fn() },
},
}));
const contactId = "contact-1";
const userId = "user-1";
const environmentId = "env-1";
const attributeKeys: TContactAttributeKey[] = [
{
id: "key-1",
key: "name",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: false,
name: "Name",
description: null,
type: "default",
environmentId,
},
{
id: "key-2",
key: "email",
createdAt: new Date(),
updatedAt: new Date(),
isUnique: false,
name: "Email",
description: null,
type: "default",
environmentId,
},
];
describe("updateAttributes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("updates existing attributes and revalidates cache", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
const attributes = { name: "John", email: "john@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
expect(contactAttributeCache.revalidate).toHaveBeenCalledWith({
environmentId,
contactId,
userId,
key: "name",
});
expect(contactAttributeCache.revalidate).toHaveBeenCalledWith({
environmentId,
contactId,
userId,
key: "email",
});
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
});
test("skips updating email if it already exists", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
const attributes = { name: "John", email: "john@example.com" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
expect(contactAttributeCache.revalidate).toHaveBeenCalledWith({
environmentId,
contactId,
userId,
key: "name",
});
expect(contactAttributeCache.revalidate).not.toHaveBeenCalledWith({
environmentId,
contactId,
userId,
key: "email",
});
expect(result.success).toBe(true);
expect(result.messages).toContain("The email already exists for this environment and was not updated.");
});
test("creates new attributes if under limit and revalidates caches", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0]]);
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
const attributes = { name: "John", newAttr: "val" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(prisma.$transaction).toHaveBeenCalled();
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({ environmentId, key: "newAttr" });
expect(contactAttributeCache.revalidate).toHaveBeenCalledWith({
environmentId,
contactId,
userId,
key: "newAttr",
});
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({ environmentId });
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
});
test("does not create new attributes if over the limit", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
const attributes = { name: "John", newAttr: "val" };
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(result.success).toBe(true);
expect(result.messages?.[0]).toMatch(/Could not create 1 new attribute/);
expect(contactAttributeKeyCache.revalidate).not.toHaveBeenCalledWith({ environmentId, key: "newAttr" });
});
test("returns success with no attributes to update or create", async () => {
vi.mocked(getContactAttributeKeys).mockResolvedValue([]);
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
const attributes = {};
const result = await updateAttributes(contactId, userId, environmentId, attributes);
expect(result.success).toBe(true);
expect(result.messages).toEqual([]);
});
});
@@ -0,0 +1,39 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContactAttributeKeys } from "./contact-attribute-keys";
vi.mock("@formbricks/database", () => ({
prisma: {
contactAttributeKey: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/cache", () => ({ cache: (fn) => fn }));
vi.mock("@/lib/cache/contact-attribute-key", () => ({
contactAttributeKeyCache: { tag: { byEnvironmentId: (envId) => `env-${envId}` } },
}));
vi.mock("react", () => ({ cache: (fn) => fn }));
const environmentId = "env-1";
const mockKeys = [
{ id: "id-1", key: "email", environmentId },
{ id: "id-2", key: "name", environmentId },
];
describe("getContactAttributeKeys", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns attribute keys for environment", async () => {
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue(mockKeys);
const result = await getContactAttributeKeys(environmentId);
expect(prisma.contactAttributeKey.findMany).toHaveBeenCalledWith({ where: { environmentId } });
expect(result).toEqual(mockKeys);
});
test("returns empty array if none found", async () => {
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([]);
const result = await getContactAttributeKeys(environmentId);
expect(result).toEqual([]);
});
});
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getContactAttributes, hasEmailAttribute } from "./contact-attributes";
vi.mock("@formbricks/database", () => ({
prisma: {
contactAttribute: {
findMany: vi.fn(),
findFirst: vi.fn(),
deleteMany: vi.fn(),
},
},
}));
vi.mock("@/lib/cache", () => ({ cache: (fn) => fn }));
vi.mock("@/lib/cache/contact-attribute", () => ({
contactAttributeCache: {
tag: { byContactId: (id) => `contact-${id}`, byEnvironmentId: (env) => `env-${env}` },
},
}));
vi.mock("@/lib/cache/contact-attribute-key", () => ({
contactAttributeKeyCache: { tag: { byEnvironmentIdAndKey: (env, key) => `env-${env}-key-${key}` } },
}));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
vi.mock("react", () => ({ cache: (fn) => fn }));
const contactId = "contact-1";
const environmentId = "env-1";
const email = "john@example.com";
const mockAttributes = [
{ value: "john@example.com", attributeKey: { key: "email", name: "Email" } },
{ value: "John", attributeKey: { key: "name", name: "Name" } },
];
describe("getContactAttributes", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns attributes as object", async () => {
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue(mockAttributes);
const result = await getContactAttributes(contactId);
expect(prisma.contactAttribute.findMany).toHaveBeenCalledWith({
where: { contactId },
select: { value: true, attributeKey: { select: { key: true, name: true } } },
});
expect(result).toEqual({ email: "john@example.com", name: "John" });
});
test("returns empty object if no attributes", async () => {
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
const result = await getContactAttributes(contactId);
expect(result).toEqual({});
});
});
describe("hasEmailAttribute", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns true if email attribute exists", async () => {
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue({ id: "attr-1" });
const result = await hasEmailAttribute(email, environmentId, contactId);
expect(prisma.contactAttribute.findFirst).toHaveBeenCalledWith({
where: {
AND: [{ attributeKey: { key: "email", environmentId }, value: email }, { NOT: { contactId } }],
},
select: { id: true },
});
expect(result).toBe(true);
});
test("returns false if email attribute does not exist", async () => {
vi.mocked(prisma.contactAttribute.findFirst).mockResolvedValue(null);
const result = await hasEmailAttribute(email, environmentId, contactId);
expect(result).toBe(false);
});
});
@@ -0,0 +1,347 @@
import { Contact, Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import {
buildContactWhereClause,
createContactsFromCSV,
deleteContact,
getContact,
getContacts,
} from "./contacts";
vi.mock("@formbricks/database", () => ({
prisma: {
contact: {
findMany: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
update: vi.fn(),
create: vi.fn(),
},
contactAttribute: {
findMany: vi.fn(),
createMany: vi.fn(),
findFirst: vi.fn(),
deleteMany: vi.fn(),
},
contactAttributeKey: {
findMany: vi.fn(),
createMany: vi.fn(),
},
},
}));
vi.mock("@/lib/cache", () => ({ cache: (fn) => fn }));
vi.mock("@/lib/cache/contact", () => ({
contactCache: {
revalidate: vi.fn(),
tag: { byEnvironmentId: (env) => `env-${env}`, byId: (id) => `id-${id}` },
},
}));
vi.mock("@/lib/cache/contact-attribute", () => ({
contactAttributeCache: { revalidate: vi.fn() },
}));
vi.mock("@/lib/cache/contact-attribute-key", () => ({
contactAttributeKeyCache: { revalidate: vi.fn() },
}));
vi.mock("@/lib/constants", () => ({ ITEMS_PER_PAGE: 2 }));
vi.mock("react", () => ({ cache: (fn) => fn }));
const environmentId = "env1";
const contactId = "contact1";
const userId = "user1";
const mockContact: Contact & {
attributes: { value: string; attributeKey: { key: string; name: string } }[];
} = {
id: contactId,
createdAt: new Date(),
updatedAt: new Date(),
environmentId,
userId,
attributes: [
{ value: "john@example.com", attributeKey: { key: "email", name: "Email" } },
{ value: "John", attributeKey: { key: "name", name: "Name" } },
{ value: userId, attributeKey: { key: "userId", name: "User ID" } },
],
};
describe("getContacts", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns contacts with attributes", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue([mockContact]);
const result = await getContacts(environmentId, 0, "");
expect(Array.isArray(result)).toBe(true);
expect(result[0].id).toBe(contactId);
expect(result[0].attributes.email).toBe("john@example.com");
});
test("returns empty array if no contacts", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
const result = await getContacts(environmentId, 0, "");
expect(result).toEqual([]);
});
test("throws DatabaseError on Prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.contact.findMany).mockRejectedValue(prismaError);
await expect(getContacts(environmentId, 0, "")).rejects.toThrow(DatabaseError);
});
test("throws original error on unknown error", async () => {
const genericError = new Error("Unknown error");
vi.mocked(prisma.contact.findMany).mockRejectedValue(genericError);
await expect(getContacts(environmentId, 0, "")).rejects.toThrow(genericError);
});
});
describe("getContact", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns contact if found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(mockContact);
const result = await getContact(contactId);
expect(result).toEqual(mockContact);
});
test("returns null if not found", async () => {
vi.mocked(prisma.contact.findUnique).mockResolvedValue(null);
const result = await getContact(contactId);
expect(result).toBeNull();
});
test("throws DatabaseError on Prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.contact.findUnique).mockRejectedValue(prismaError);
await expect(getContact(contactId)).rejects.toThrow(DatabaseError);
});
test("throws original error on unknown error", async () => {
const genericError = new Error("Unknown error");
vi.mocked(prisma.contact.findUnique).mockRejectedValue(genericError);
await expect(getContact(contactId)).rejects.toThrow(genericError);
});
});
describe("deleteContact", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("deletes contact and revalidates caches", async () => {
vi.mocked(prisma.contact.delete).mockResolvedValue(mockContact);
const result = await deleteContact(contactId);
expect(result).toEqual(mockContact);
});
test("throws DatabaseError on Prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.contact.delete).mockRejectedValue(prismaError);
await expect(deleteContact(contactId)).rejects.toThrow(DatabaseError);
});
test("throws original error on unknown error", async () => {
const genericError = new Error("Unknown error");
vi.mocked(prisma.contact.delete).mockRejectedValue(genericError);
await expect(deleteContact(contactId)).rejects.toThrow(genericError);
});
});
describe("createContactsFromCSV", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("creates new contacts and missing attribute keys", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.create).mockResolvedValue({
id: "c1",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
attributes: [
{ attributeKey: { key: "email" }, value: "john@example.com" },
{ attributeKey: { key: "name" }, value: "John" },
],
} as any);
const csvData = [{ email: "john@example.com", name: "John" }];
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
email: "email",
name: "name",
});
expect(Array.isArray(result)).toBe(true);
expect(result[0].id).toBe("c1");
});
test("skips duplicate contact with 'skip' action", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue([
{ id: "c1", attributes: [{ attributeKey: { key: "email" }, value: "john@example.com" }] },
]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
const csvData = [{ email: "john@example.com", name: "John" }];
const result = await createContactsFromCSV(csvData, environmentId, "skip", {
email: "email",
name: "name",
});
expect(result).toEqual([]);
});
test("updates contact with 'update' action", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue([
{
id: "c1",
attributes: [
{ attributeKey: { key: "email" }, value: "john@example.com" },
{ attributeKey: { key: "name" }, value: "Old" },
],
},
]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
vi.mocked(prisma.contact.update).mockResolvedValue({
id: "c1",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
attributes: [
{ attributeKey: { key: "email" }, value: "john@example.com" },
{ attributeKey: { key: "name" }, value: "John" },
],
} as any);
const csvData = [{ email: "john@example.com", name: "John" }];
const result = await createContactsFromCSV(csvData, environmentId, "update", {
email: "email",
name: "name",
});
expect(result[0].id).toBe("c1");
});
test("overwrites contact with 'overwrite' action", async () => {
vi.mocked(prisma.contact.findMany).mockResolvedValue([
{
id: "c1",
attributes: [
{ attributeKey: { key: "email" }, value: "john@example.com" },
{ attributeKey: { key: "name" }, value: "Old" },
],
},
]);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]);
vi.mocked(prisma.contactAttributeKey.findMany).mockResolvedValue([
{ key: "email", id: "id-email" },
{ key: "name", id: "id-name" },
]);
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 });
vi.mocked(prisma.contact.update).mockResolvedValue({
id: "c1",
environmentId,
createdAt: new Date(),
updatedAt: new Date(),
attributes: [
{ attributeKey: { key: "email" }, value: "john@example.com" },
{ attributeKey: { key: "name" }, value: "John" },
],
} as any);
const csvData = [{ email: "john@example.com", name: "John" }];
const result = await createContactsFromCSV(csvData, environmentId, "overwrite", {
email: "email",
name: "name",
});
expect(result[0].id).toBe("c1");
});
test("throws ValidationError if email is missing in CSV", async () => {
const csvData = [{ name: "John" }];
await expect(
createContactsFromCSV(csvData as any, environmentId, "skip", { name: "name" })
).rejects.toThrow(ValidationError);
});
test("throws DatabaseError on Prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.contact.findMany).mockRejectedValue(prismaError);
const csvData = [{ email: "john@example.com", name: "John" }];
await expect(
createContactsFromCSV(csvData, environmentId, "skip", { email: "email", name: "name" })
).rejects.toThrow(DatabaseError);
});
test("throws original error on unknown error", async () => {
const genericError = new Error("Unknown error");
vi.mocked(prisma.contact.findMany).mockRejectedValue(genericError);
const csvData = [{ email: "john@example.com", name: "John" }];
await expect(
createContactsFromCSV(csvData, environmentId, "skip", { email: "email", name: "name" })
).rejects.toThrow(genericError);
});
});
describe("buildContactWhereClause", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns where clause for email", () => {
const environmentId = "env-1";
const search = "john";
const result = buildContactWhereClause(environmentId, search);
expect(result).toEqual({
environmentId,
OR: [
{
attributes: {
some: {
value: {
contains: search,
mode: "insensitive",
},
},
},
},
{
id: {
contains: search,
mode: "insensitive",
},
},
],
});
});
test("returns where clause without search", () => {
const environmentId = "env-1";
const result = buildContactWhereClause(environmentId);
expect(result).toEqual({ environmentId });
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ const selectContact = {
},
} satisfies Prisma.ContactSelect;
const buildContactWhereClause = (environmentId: string, search?: string): Prisma.ContactWhereInput => {
export const buildContactWhereClause = (environmentId: string, search?: string): Prisma.ContactWhereInput => {
const whereClause: Prisma.ContactWhereInput = { environmentId };
if (search) {
@@ -0,0 +1,54 @@
import { TTransformPersonInput } from "@/modules/ee/contacts/types/contact";
import { describe, expect, test } from "vitest";
import { convertPrismaContactAttributes, getContactIdentifier, transformPrismaContact } from "./utils";
const mockPrismaAttributes = [
{ value: "john@example.com", attributeKey: { key: "email", name: "Email" } },
{ value: "John", attributeKey: { key: "name", name: "Name" } },
];
describe("utils", () => {
test("getContactIdentifier returns email if present", () => {
expect(getContactIdentifier({ email: "a@b.com", userId: "u1" })).toBe("a@b.com");
});
test("getContactIdentifier returns userId if no email", () => {
expect(getContactIdentifier({ userId: "u1" })).toBe("u1");
});
test("getContactIdentifier returns empty string if neither", () => {
expect(getContactIdentifier(null)).toBe("");
expect(getContactIdentifier({})).toBe("");
});
test("convertPrismaContactAttributes returns correct object", () => {
const result = convertPrismaContactAttributes(mockPrismaAttributes);
expect(result).toEqual({
email: { name: "Email", value: "john@example.com" },
name: { name: "Name", value: "John" },
});
});
test("transformPrismaContact returns correct structure", () => {
const person: TTransformPersonInput = {
id: "c1",
environmentId: "env-1",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-02T00:00:00.000Z"),
attributes: [
{
attributeKey: { key: "email", name: "Email" },
value: "john@example.com",
},
{
attributeKey: { key: "name", name: "Name" },
value: "John",
},
],
};
const result = transformPrismaContact(person);
expect(result.id).toBe("c1");
expect(result.environmentId).toBe("env-1");
expect(result.attributes).toEqual({ email: "john@example.com", name: "John" });
expect(result.createdAt).toBeInstanceOf(Date);
expect(result.updatedAt).toBeInstanceOf(Date);
});
});
@@ -0,0 +1,115 @@
import { createOrganizationAction } from "@/modules/organization/actions";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CreateOrganizationModal } from "./index";
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }) => (open ? <div data-testid="modal">{children}</div> : null),
}));
vi.mock("lucide-react", () => ({
PlusCircleIcon: () => <svg data-testid="plus-icon" />,
}));
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({
push: mockPush,
})),
}));
vi.mock("@/modules/organization/actions", () => ({
createOrganizationAction: vi.fn(),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(() => "Formatted error"),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (k) => k }),
}));
describe("CreateOrganizationModal", () => {
afterEach(() => {
cleanup();
});
test("renders modal and form fields", () => {
render(<CreateOrganizationModal open={true} setOpen={vi.fn()} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByPlaceholderText("environments.settings.general.organization_name_placeholder")
).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
});
test("disables submit button if organization name is empty", () => {
render(<CreateOrganizationModal open={true} setOpen={vi.fn()} />);
const submitBtn = screen.getByText("environments.settings.general.create_new_organization", {
selector: "button[type='submit']",
});
expect(submitBtn).toBeDisabled();
});
test("enables submit button when organization name is entered", async () => {
render(<CreateOrganizationModal open={true} setOpen={vi.fn()} />);
const input = screen.getByPlaceholderText("environments.settings.general.organization_name_placeholder");
const submitBtn = screen.getByText("environments.settings.general.create_new_organization", {
selector: "button[type='submit']",
});
await userEvent.type(input, "Formbricks Org");
expect(submitBtn).not.toBeDisabled();
});
test("calls createOrganizationAction and closes modal on success", async () => {
const setOpen = vi.fn();
vi.mocked(createOrganizationAction).mockResolvedValue({ data: { id: "org-1" } } as any);
render(<CreateOrganizationModal open={true} setOpen={setOpen} />);
const input = screen.getByPlaceholderText("environments.settings.general.organization_name_placeholder");
await userEvent.type(input, "Formbricks Org");
const submitBtn = screen.getByText("environments.settings.general.create_new_organization", {
selector: "button[type='submit']",
});
await userEvent.click(submitBtn);
await waitFor(() => {
expect(createOrganizationAction).toHaveBeenCalledWith({ organizationName: "Formbricks Org" });
expect(setOpen).toHaveBeenCalledWith(false);
expect(mockPush).toHaveBeenCalledWith("/organizations/org-1");
});
});
test("shows error toast on failure", async () => {
const setOpen = vi.fn();
vi.mocked(createOrganizationAction).mockResolvedValue({});
render(<CreateOrganizationModal open={true} setOpen={setOpen} />);
const input = screen.getByPlaceholderText("environments.settings.general.organization_name_placeholder");
await userEvent.type(input, "Fail Org");
const submitBtn = screen.getByText("environments.settings.general.create_new_organization", {
selector: "button[type='submit']",
});
await userEvent.click(submitBtn);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Formatted error");
});
});
test("does not submit if name is only whitespace", async () => {
const setOpen = vi.fn();
render(<CreateOrganizationModal open={true} setOpen={setOpen} />);
const input = screen.getByPlaceholderText("environments.settings.general.organization_name_placeholder");
await userEvent.type(input, " ");
const submitBtn = screen.getByText("environments.settings.general.create_new_organization", {
selector: "button[type='submit']",
});
await userEvent.click(submitBtn);
expect(createOrganizationAction).not.toHaveBeenCalled();
});
test("calls setOpen(false) when cancel is clicked", async () => {
const setOpen = vi.fn();
render(<CreateOrganizationModal open={true} setOpen={setOpen} />);
const cancelBtn = screen.getByText("common.cancel");
await userEvent.click(cancelBtn);
expect(setOpen).toHaveBeenCalledWith(false);
});
});
@@ -0,0 +1,77 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganization } from "@/lib/organization/service";
import { getServerSession } from "next-auth";
import { describe, expect, test, vi } from "vitest";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { getOrganizationAuth } from "./utils";
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(() => ({
isMember: true,
isOwner: false,
isManager: false,
isBilling: false,
})),
}));
vi.mock("@/lib/organization/service", () => ({
getOrganization: vi.fn(),
}));
vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: vi.fn(() => Promise.resolve((k: string) => k)),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("react", () => ({ cache: (fn) => fn }));
describe("getOrganizationAuth", () => {
const mockSession = { user: { id: "user-1" } };
const mockOrg = { id: "org-1" } as TOrganization;
const mockMembership: TMembership = {
role: "member",
organizationId: "org-1",
userId: "user-1",
accepted: true,
};
test("returns organization auth object on success", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(mockSession);
vi.mocked(getOrganization).mockResolvedValue(mockOrg);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
const result = await getOrganizationAuth("org-1");
expect(result.organization).toBe(mockOrg);
expect(result.session).toBe(mockSession);
expect(result.currentUserMembership).toBe(mockMembership);
expect(result.isMember).toBe(true);
expect(result.isOwner).toBe(false);
expect(result.isManager).toBe(false);
expect(result.isBilling).toBe(false);
});
test("throws if session is missing", async () => {
vi.mocked(getServerSession).mockResolvedValueOnce(null);
vi.mocked(getOrganization).mockResolvedValue(mockOrg);
await expect(getOrganizationAuth("org-1")).rejects.toThrow("common.session_not_found");
});
test("throws if organization is missing", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getOrganization).mockResolvedValue(null);
await expect(getOrganizationAuth("org-1")).rejects.toThrow("common.organization_not_found");
});
test("throws if membership is missing", async () => {
vi.mocked(getServerSession).mockResolvedValue(mockSession);
vi.mocked(getOrganization).mockResolvedValue(mockOrg);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null);
await expect(getOrganizationAuth("org-1")).rejects.toThrow("common.membership_not_found");
});
});
@@ -4,7 +4,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { TApiKeyWithEnvironmentPermission } from "../types/api-keys";
import { createApiKey, deleteApiKey, getApiKeysWithEnvironmentPermissions, updateApiKey } from "./api-key";
import {
createApiKey,
deleteApiKey,
getApiKeyWithPermissions,
getApiKeysWithEnvironmentPermissions,
updateApiKey,
} from "./api-key";
const mockApiKey: ApiKey = {
id: "apikey123",
@@ -36,6 +42,8 @@ const mockApiKeyWithEnvironments: TApiKeyWithEnvironmentPermission = {
vi.mock("@formbricks/database", () => ({
prisma: {
apiKey: {
findFirst: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
delete: vi.fn(),
create: vi.fn(),
@@ -49,6 +57,7 @@ vi.mock("@/lib/cache/api-key", () => ({
revalidate: vi.fn(),
tag: {
byOrganizationId: vi.fn(),
byHashedKey: vi.fn(),
},
},
}));
@@ -105,6 +114,68 @@ describe("API Key Management", () => {
await expect(getApiKeysWithEnvironmentPermissions("org123")).rejects.toThrow(DatabaseError);
});
test("throws error if prisma throws an error", async () => {
const errToThrow = new Error("Mock error message");
vi.mocked(prisma.apiKey.findMany).mockRejectedValueOnce(errToThrow);
vi.mocked(apiKeyCache.tag.byOrganizationId).mockReturnValue("org-tag");
await expect(getApiKeysWithEnvironmentPermissions("org123")).rejects.toThrow(errToThrow);
});
});
describe("getApiKeyWithPermissions", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns api key with permissions if found", async () => {
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue({ ...mockApiKey });
const result = await getApiKeyWithPermissions("apikey123");
expect(result).toMatchObject({
...mockApiKey,
});
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { hashedKey: "hashed_key_value" },
include: {
apiKeyEnvironments: {
include: {
environment: {
include: {
project: {
select: {
id: true,
name: true,
},
},
},
},
},
},
},
});
});
test("returns null if api key not found", async () => {
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
const result = await getApiKeyWithPermissions("invalid-key");
expect(result).toBeNull();
});
test("throws DatabaseError on prisma error", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: "P2002",
clientVersion: "0.0.1",
});
vi.mocked(prisma.apiKey.findUnique).mockRejectedValueOnce(errToThrow);
await expect(getApiKeyWithPermissions("apikey123")).rejects.toThrow(DatabaseError);
});
test("throws error if prisma throws an error", async () => {
const errToThrow = new Error("Mock error message");
vi.mocked(prisma.apiKey.findUnique).mockRejectedValueOnce(errToThrow);
await expect(getApiKeyWithPermissions("apikey123")).rejects.toThrow(errToThrow);
});
});
describe("deleteApiKey", () => {
@@ -131,6 +202,13 @@ describe("API Key Management", () => {
await expect(deleteApiKey(mockApiKey.id)).rejects.toThrow(DatabaseError);
});
test("throws error if prisma throws an error", async () => {
const errToThrow = new Error("Mock error message");
vi.mocked(prisma.apiKey.delete).mockRejectedValueOnce(errToThrow);
await expect(deleteApiKey(mockApiKey.id)).rejects.toThrow(errToThrow);
});
});
describe("createApiKey", () => {
@@ -191,6 +269,14 @@ describe("API Key Management", () => {
await expect(createApiKey("org123", "user123", mockApiKeyData)).rejects.toThrow(DatabaseError);
});
test("throws error if prisma throws an error", async () => {
const errToThrow = new Error("Mock error message");
vi.mocked(prisma.apiKey.create).mockRejectedValueOnce(errToThrow);
await expect(createApiKey("org123", "user123", mockApiKeyData)).rejects.toThrow(errToThrow);
});
});
describe("updateApiKey", () => {
@@ -215,5 +301,13 @@ describe("API Key Management", () => {
await expect(updateApiKey(mockApiKey.id, { label: "Updated API Key" })).rejects.toThrow(DatabaseError);
});
test("throws error if prisma throws an error", async () => {
const errToThrow = new Error("Mock error message");
vi.mocked(prisma.apiKey.update).mockRejectedValueOnce(errToThrow);
await expect(updateApiKey(mockApiKey.id, { label: "Updated API Key" })).rejects.toThrow(errToThrow);
});
});
});
@@ -0,0 +1,100 @@
import { describe, expect, test, vi } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { getOrganizationAccessKeyDisplayName, hasPermission } from "./utils";
describe("hasPermission", () => {
const envId = "env1";
test("returns true for manage permission (all methods)", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
{
environmentId: envId,
environmentType: "production",
projectId: "project1",
projectName: "Project One",
permission: "manage",
},
];
expect(hasPermission(permissions, envId, "GET")).toBe(true);
expect(hasPermission(permissions, envId, "POST")).toBe(true);
expect(hasPermission(permissions, envId, "PUT")).toBe(true);
expect(hasPermission(permissions, envId, "PATCH")).toBe(true);
expect(hasPermission(permissions, envId, "DELETE")).toBe(true);
});
test("returns true for write permission (read/write), false for delete", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
{
environmentId: envId,
environmentType: "production",
projectId: "project1",
projectName: "Project One",
permission: "write",
},
];
expect(hasPermission(permissions, envId, "GET")).toBe(true);
expect(hasPermission(permissions, envId, "POST")).toBe(true);
expect(hasPermission(permissions, envId, "PUT")).toBe(true);
expect(hasPermission(permissions, envId, "PATCH")).toBe(true);
expect(hasPermission(permissions, envId, "DELETE")).toBe(false);
});
test("returns true for read permission (GET), false for others", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
{
environmentId: envId,
environmentType: "production",
projectId: "project1",
projectName: "Project One",
permission: "read",
},
];
expect(hasPermission(permissions, envId, "GET")).toBe(true);
expect(hasPermission(permissions, envId, "POST")).toBe(false);
expect(hasPermission(permissions, envId, "PUT")).toBe(false);
expect(hasPermission(permissions, envId, "PATCH")).toBe(false);
expect(hasPermission(permissions, envId, "DELETE")).toBe(false);
});
test("returns false if no permissions or environment entry", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
{
environmentId: "other",
environmentType: "production",
projectId: "project1",
projectName: "Project One",
permission: "manage",
},
];
expect(hasPermission(undefined as any, envId, "GET")).toBe(false);
expect(hasPermission([], envId, "GET")).toBe(false);
expect(hasPermission(permissions, envId, "GET")).toBe(false);
});
test("returns false for unknown permission", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
{
environmentId: "other",
environmentType: "production",
projectId: "project1",
projectName: "Project One",
permission: "unknown" as any,
},
];
expect(hasPermission(permissions, "other", "GET")).toBe(false);
});
});
describe("getOrganizationAccessKeyDisplayName", () => {
test("returns tolgee string for accessControl", () => {
const t = vi.fn((k) => k);
expect(getOrganizationAccessKeyDisplayName("accessControl", t)).toBe(
"environments.project.api_keys.access_control"
);
expect(t).toHaveBeenCalledWith("environments.project.api_keys.access_control");
});
test("returns tolgee string for other keys", () => {
const t = vi.fn((k) => k);
expect(getOrganizationAccessKeyDisplayName("otherKey", t)).toBe("otherKey");
expect(t).toHaveBeenCalledWith("otherKey");
});
});
@@ -0,0 +1,43 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import Loading from "./loading";
vi.mock(
"@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar",
() => ({
OrganizationSettingsNavbar: () => <div data-testid="org-navbar">OrgNavbar</div>,
})
);
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div data-testid="content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }) => (
<div data-testid="page-header">
<span>{pageTitle}</span>
{children}
</div>
),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (k) => k }),
}));
describe("Loading (API Keys)", () => {
afterEach(() => {
cleanup();
});
test("renders loading skeletons and tolgee strings", () => {
render(<Loading isFormbricksCloud={true} />);
expect(screen.getByTestId("content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("org-navbar")).toBeInTheDocument();
expect(screen.getByText("environments.settings.general.organization_settings")).toBeInTheDocument();
expect(screen.getAllByText("common.loading").length).toBeGreaterThan(0);
expect(screen.getByText("environments.project.api_keys.api_key")).toBeInTheDocument();
expect(screen.getByText("common.label")).toBeInTheDocument();
expect(screen.getByText("common.created_at")).toBeInTheDocument();
});
});
@@ -0,0 +1,104 @@
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getProjectsByOrganizationId } from "@/modules/organization/settings/api-keys/lib/projects";
import { TOrganizationProject } from "@/modules/organization/settings/api-keys/types/api-keys";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { APIKeysPage } from "./page";
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div data-testid="content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }) => (
<div data-testid="page-header">
<span>{pageTitle}</span>
{children}
</div>
),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar",
() => ({
OrganizationSettingsNavbar: () => <div data-testid="org-navbar">OrgNavbar</div>,
})
);
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ title, description, children }) => (
<div data-testid="settings-card">
<span>{title}</span>
<span>{description}</span>
{children}
</div>
),
}));
vi.mock("@/modules/organization/settings/api-keys/lib/projects", () => ({
getProjectsByOrganizationId: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("./components/api-key-list", () => ({
ApiKeyList: ({ organizationId, locale, isReadOnly, projects }) => (
<div data-testid="api-key-list">
{organizationId}-{locale}-{isReadOnly ? "readonly" : "editable"}-{projects.length}
</div>
),
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
}));
// Mock the server-side translation function
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
const mockParams = { environmentId: "env-1" };
const mockLocale = "en-US";
const mockOrg = { id: "org-1" };
const mockMembership = { role: "owner" };
const mockProjects: TOrganizationProject[] = [
{ id: "p1", environments: [], name: "project1" },
{ id: "p2", environments: [], name: "project2" },
];
describe("APIKeysPage", () => {
afterEach(() => {
cleanup();
});
test("renders all main components and passes props", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
currentUserMembership: mockMembership,
organization: mockOrg,
isOwner: true,
} as any);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
vi.mocked(getProjectsByOrganizationId).mockResolvedValue(mockProjects);
const props = { params: Promise.resolve(mockParams) };
render(await APIKeysPage(props));
expect(screen.getByTestId("content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("org-navbar")).toBeInTheDocument();
expect(screen.getByTestId("settings-card")).toBeInTheDocument();
expect(screen.getByTestId("api-key-list")).toHaveTextContent("org-1-en-US-editable-2");
expect(screen.getByText("environments.settings.general.organization_settings")).toBeInTheDocument();
expect(screen.getByText("common.api_keys")).toBeInTheDocument();
expect(screen.getByText("environments.settings.api_keys.api_keys_description")).toBeInTheDocument();
});
test("throws error if not owner", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
currentUserMembership: { role: "member" },
organization: mockOrg,
} as any);
const props = { params: Promise.resolve(mockParams) };
await expect(APIKeysPage(props)).rejects.toThrow("common.not_authorized");
});
});
@@ -0,0 +1,118 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganization } from "@formbricks/types/organizations";
import { EditMemberships } from "./edit-memberships";
vi.mock("@/modules/organization/settings/teams/components/edit-memberships/members-info", () => ({
MembersInfo: (props: any) => <div data-testid="members-info" data-props={JSON.stringify(props)} />,
}));
vi.mock("@/modules/organization/settings/teams/lib/invite", () => ({
getInvitesByOrganizationId: vi.fn(async () => [
{
id: "invite-1",
email: "invite@example.com",
name: "Invitee",
role: "member",
expiresAt: new Date(),
createdAt: new Date(),
},
]),
}));
vi.mock("@/modules/organization/settings/teams/lib/membership", () => ({
getMembershipByOrganizationId: vi.fn(async () => [
{
userId: "user-1",
name: "User One",
email: "user1@example.com",
role: "owner",
accepted: true,
isActive: true,
},
]),
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: 0,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
const mockOrg: TOrganization = {
id: "org-1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: "free",
period: "monthly",
periodStart: new Date(),
stripeCustomerId: null,
limits: { monthly: { responses: 100, miu: 100 }, projects: 1 },
},
isAIEnabled: false,
};
describe("EditMemberships", () => {
afterEach(() => {
cleanup();
});
test("renders all table headers and MembersInfo when role is present", async () => {
const ui = await EditMemberships({
organization: mockOrg,
currentUserId: "user-1",
role: "owner",
canDoRoleManagement: true,
isUserManagementDisabledFromUi: false,
});
render(ui);
expect(screen.getByText("common.full_name")).toBeInTheDocument();
expect(screen.getByText("common.email")).toBeInTheDocument();
expect(screen.getByText("common.role")).toBeInTheDocument();
expect(screen.getByText("common.status")).toBeInTheDocument();
expect(screen.getByText("common.actions")).toBeInTheDocument();
expect(screen.getByTestId("members-info")).toBeInTheDocument();
const props = JSON.parse(screen.getByTestId("members-info").getAttribute("data-props")!);
expect(props.organization.id).toBe("org-1");
expect(props.currentUserId).toBe("user-1");
expect(props.currentUserRole).toBe("owner");
expect(props.canDoRoleManagement).toBe(true);
expect(props.isUserManagementDisabledFromUi).toBe(false);
expect(Array.isArray(props.invites)).toBe(true);
expect(Array.isArray(props.members)).toBe(true);
});
test("does not render role/actions columns if canDoRoleManagement or isUserManagementDisabledFromUi is false", async () => {
const ui = await EditMemberships({
organization: mockOrg,
currentUserId: "user-1",
role: "member",
canDoRoleManagement: false,
isUserManagementDisabledFromUi: true,
});
render(ui);
expect(screen.getByText("common.full_name")).toBeInTheDocument();
expect(screen.getByText("common.email")).toBeInTheDocument();
expect(screen.queryByText("common.role")).not.toBeInTheDocument();
expect(screen.getByText("common.status")).toBeInTheDocument();
expect(screen.queryByText("common.actions")).not.toBeInTheDocument();
expect(screen.getByTestId("members-info")).toBeInTheDocument();
});
test("does not render MembersInfo if role is falsy", async () => {
const ui = await EditMemberships({
organization: mockOrg,
currentUserId: "user-1",
role: undefined as any,
canDoRoleManagement: true,
isUserManagementDisabledFromUi: false,
});
render(ui);
expect(screen.queryByTestId("members-info")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,13 @@
import { describe, expect, test, vi } from "vitest";
import { EditMemberships } from "./edit-memberships";
import { EditMemberships as ExportedEditMemberships } from "./index";
vi.mock("./edit-memberships", () => ({
EditMemberships: vi.fn(),
}));
describe("EditMemberships Re-export", () => {
test("should re-export EditMemberships", () => {
expect(ExportedEditMemberships).toBe(EditMemberships);
});
});
@@ -0,0 +1,203 @@
import { getAccessFlags } from "@/lib/membership/utils";
import { isInviteExpired } from "@/modules/organization/settings/teams/lib/utils";
import { TInvite } from "@/modules/organization/settings/teams/types/invites";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TMember } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { MembersInfo } from "./members-info";
vi.mock("@/modules/ee/role-management/components/edit-membership-role", () => ({
EditMembershipRole: (props: any) => (
<div data-testid="edit-membership-role" data-props={JSON.stringify(props)} />
),
}));
vi.mock("@/modules/organization/settings/teams/components/edit-memberships/member-actions", () => ({
MemberActions: (props: any) => <div data-testid="member-actions" data-props={JSON.stringify(props)} />,
}));
vi.mock("@/modules/ui/components/badge", () => ({
Badge: (props: any) => <div data-testid={props["data-testid"] ?? "badge"}>{props.text}</div>,
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: (props: any) => <div data-testid="tooltip">{props.children}</div>,
}));
vi.mock("@/modules/organization/settings/teams/lib/utils", () => ({
isInviteExpired: vi.fn(() => false),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(() => ({ isOwner: false, isManager: false })),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
}));
const org: TOrganization = {
id: "org-1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: "free",
period: "monthly",
periodStart: new Date(),
stripeCustomerId: null,
limits: { monthly: { responses: 100, miu: 100 }, projects: 1 },
},
isAIEnabled: false,
};
const member: TMember = {
userId: "user-1",
name: "User One",
email: "user1@example.com",
role: "owner",
accepted: true,
isActive: true,
};
const inactiveMember: TMember = {
...member,
isActive: false,
role: "member",
userId: "user-2",
email: "user2@example.com",
};
const invite: TInvite = {
id: "invite-1",
email: "invite@example.com",
name: "Invitee",
role: "member",
expiresAt: new Date(),
createdAt: new Date(),
};
describe("MembersInfo", () => {
afterEach(() => {
cleanup();
});
test("renders member info and EditMembershipRole when canDoRoleManagement", () => {
render(
<MembersInfo
organization={org}
members={[member]}
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
);
expect(screen.getByText("User One")).toBeInTheDocument();
expect(screen.getByText("user1@example.com")).toBeInTheDocument();
expect(screen.getByTestId("edit-membership-role")).toBeInTheDocument();
expect(screen.getByTestId("badge")).toHaveTextContent("Active");
expect(screen.getByTestId("member-actions")).toBeInTheDocument();
});
test("renders badge as Inactive for inactive member", () => {
render(
<MembersInfo
organization={org}
members={[inactiveMember]}
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
);
expect(screen.getByTestId("badge")).toHaveTextContent("Inactive");
});
test("renders invite as Pending with tooltip if not expired", () => {
render(
<MembersInfo
organization={org}
members={[]}
invites={[invite]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
);
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
expect(screen.getByTestId("badge")).toHaveTextContent("Pending");
});
test("renders invite as Expired if isInviteExpired returns true", () => {
vi.mocked(isInviteExpired).mockReturnValueOnce(true);
render(
<MembersInfo
organization={org}
members={[]}
invites={[invite]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
);
expect(screen.getByTestId("expired-badge")).toHaveTextContent("Expired");
});
test("does not render EditMembershipRole if canDoRoleManagement is false", () => {
render(
<MembersInfo
organization={org}
members={[member]}
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={false}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
);
expect(screen.queryByTestId("edit-membership-role")).not.toBeInTheDocument();
});
test("does not render MemberActions if isUserManagementDisabledFromUi is true", () => {
render(
<MembersInfo
organization={org}
members={[member]}
invites={[]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={true}
/>
);
expect(screen.queryByTestId("member-actions")).not.toBeInTheDocument();
});
test("showDeleteButton returns correct values for different roles and invite/member types", () => {
vi.mocked(getAccessFlags).mockReturnValueOnce({
isOwner: true,
isManager: false,
isBilling: false,
isMember: false,
});
render(
<MembersInfo
organization={org}
members={[]}
invites={[invite]}
currentUserRole="owner"
currentUserId="user-1"
canDoRoleManagement={true}
isFormbricksCloud={true}
isUserManagementDisabledFromUi={false}
/>
);
expect(screen.getByTestId("member-actions")).toBeInTheDocument();
});
});
@@ -4,7 +4,7 @@ import { getAccessFlags } from "@/lib/membership/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { EditMembershipRole } from "@/modules/ee/role-management/components/edit-membership-role";
import { MemberActions } from "@/modules/organization/settings/teams/components/edit-memberships/member-actions";
import { isInviteExpired } from "@/modules/organization/settings/teams/lib/utilts";
import { isInviteExpired } from "@/modules/organization/settings/teams/lib/utils";
import { TInvite } from "@/modules/organization/settings/teams/types/invites";
import { Badge } from "@/modules/ui/components/badge";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
@@ -1,5 +1,6 @@
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { inviteUserAction, leaveOrganizationAction } from "@/modules/organization/settings/teams/actions";
import { InviteMemberModal } from "@/modules/organization/settings/teams/components/invite-member/invite-member-modal";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { AppRouterInstance } from "next/dist/shared/lib/app-router-context.shared-runtime";
import { useRouter } from "next/navigation";
@@ -42,10 +43,11 @@ vi.mock("@/modules/organization/settings/teams/components/invite-member/invite-m
// Mock the CustomDialog
vi.mock("@/modules/ui/components/custom-dialog", () => ({
CustomDialog: vi.fn(({ open, setOpen, onOk }) => {
CustomDialog: vi.fn(({ children, open, setOpen, onOk }) => {
if (!open) return null;
return (
<div data-testid="leave-org-modal">
{children}
<button data-testid="leave-org-confirm-btn" onClick={onOk}>
Confirm
</button>
@@ -107,6 +109,7 @@ describe("OrganizationActions Component", () => {
isFormbricksCloud: false,
environmentId: "env-123",
isMultiOrgEnabled: true,
isUserManagementDisabledFromUi: false,
};
beforeEach(() => {
@@ -239,4 +242,66 @@ describe("OrganizationActions Component", () => {
render(<OrganizationActions {...defaultProps} isMultiOrgEnabled={false} />);
expect(screen.queryByText("environments.settings.general.leave_organization")).not.toBeInTheDocument();
});
test("invite member modal closes on close button click", () => {
render(<OrganizationActions {...defaultProps} membershipRole="owner" />);
fireEvent.click(screen.getByText("environments.settings.teams.invite_member"));
expect(screen.getByTestId("invite-member-modal")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("invite-close-btn"));
expect(screen.queryByTestId("invite-member-modal")).not.toBeInTheDocument();
});
test("leave organization modal closes on cancel", () => {
render(<OrganizationActions {...defaultProps} />);
fireEvent.click(screen.getByText("environments.settings.general.leave_organization"));
expect(screen.getByTestId("leave-org-modal")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("leave-org-cancel-btn"));
expect(screen.queryByTestId("leave-org-modal")).not.toBeInTheDocument();
});
test("leave organization button is disabled and warning shown when isLeaveOrganizationDisabled is true", () => {
render(<OrganizationActions {...defaultProps} isLeaveOrganizationDisabled={true} />);
fireEvent.click(screen.getByText("environments.settings.general.leave_organization"));
expect(screen.getByTestId("leave-org-modal")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.general.cannot_leave_only_organization")
).toBeInTheDocument();
});
test("invite button is hidden when isUserManagementDisabledFromUi is true", () => {
render(
<OrganizationActions {...defaultProps} membershipRole="owner" isUserManagementDisabledFromUi={true} />
);
expect(screen.queryByText("environments.settings.teams.invite_member")).not.toBeInTheDocument();
});
test("invite button is hidden when membershipRole is undefined", () => {
render(<OrganizationActions {...defaultProps} membershipRole={undefined} />);
expect(screen.queryByText("environments.settings.teams.invite_member")).not.toBeInTheDocument();
});
test("invite member modal receives correct props", () => {
render(<OrganizationActions {...defaultProps} membershipRole="owner" />);
fireEvent.click(screen.getByText("environments.settings.teams.invite_member"));
const modal = screen.getByTestId("invite-member-modal");
expect(modal).toBeInTheDocument();
const calls = vi.mocked(InviteMemberModal).mock.calls;
expect(
calls.some((call) =>
expect
.objectContaining({
environmentId: "env-123",
canDoRoleManagement: true,
isFormbricksCloud: false,
teams: expect.arrayContaining(defaultProps.teams),
membershipRole: "owner",
open: true,
setOpen: expect.any(Function),
onSubmit: expect.any(Function),
})
.asymmetricMatch(call[0])
)
).toBe(true);
});
});
@@ -0,0 +1,117 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { IndividualInviteTab } from "./individual-invite-tab";
const t = (k: string) => k;
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t }) }));
vi.mock("@/modules/ee/role-management/components/add-member-role", () => ({
AddMemberRole: () => <div data-testid="add-member-role">AddMemberRole</div>,
}));
vi.mock("@/modules/ui/components/multi-select", () => ({
MultiSelect: ({ value, options, onChange, disabled }: any) => (
<select
data-testid="multi-select"
value={value}
disabled={disabled}
onChange={(e) => onChange([e.target.value])}>
{options.map((opt: any) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
),
}));
const defaultProps = {
setOpen: vi.fn(),
onSubmit: vi.fn(),
teams: [
{ id: "team-1", name: "Team 1" },
{ id: "team-2", name: "Team 2" },
],
canDoRoleManagement: true,
isFormbricksCloud: true,
environmentId: "env-1",
membershipRole: "owner" as TOrganizationRole,
};
describe("IndividualInviteTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders form fields and buttons", () => {
render(<IndividualInviteTab {...defaultProps} />);
expect(screen.getByLabelText("common.full_name")).toBeInTheDocument();
expect(screen.getByLabelText("common.email")).toBeInTheDocument();
expect(screen.getByTestId("add-member-role")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
expect(screen.getByText("common.invite")).toBeInTheDocument();
});
test("submits valid form and calls onSubmit", async () => {
render(<IndividualInviteTab {...defaultProps} />);
await userEvent.type(screen.getByLabelText("common.full_name"), "Test User");
await userEvent.type(screen.getByLabelText("common.email"), "test@example.com");
fireEvent.submit(screen.getByRole("button", { name: "common.invite" }).closest("form")!);
await waitFor(() =>
expect(defaultProps.onSubmit).toHaveBeenCalledWith([
expect.objectContaining({ name: "Test User", email: "test@example.com", role: "member" }),
])
);
expect(defaultProps.setOpen).toHaveBeenCalledWith(false);
});
test("shows error for empty name", async () => {
render(<IndividualInviteTab {...defaultProps} />);
await userEvent.type(screen.getByLabelText("common.email"), "test@example.com");
fireEvent.submit(screen.getByRole("button", { name: "common.invite" }).closest("form")!);
expect(await screen.findByText("Name should be at least 1 character long")).toBeInTheDocument();
});
test("shows error for invalid email", async () => {
render(<IndividualInviteTab {...defaultProps} />);
await userEvent.type(screen.getByLabelText("common.full_name"), "Test User");
await userEvent.type(screen.getByLabelText("common.email"), "not-an-email");
fireEvent.submit(screen.getByRole("button", { name: "common.invite" }).closest("form")!);
expect(await screen.findByText(/Invalid email/)).toBeInTheDocument();
});
test("shows member role info alert when role is member", async () => {
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={true} />);
await userEvent.type(screen.getByLabelText("common.full_name"), "Test User");
await userEvent.type(screen.getByLabelText("common.email"), "test@example.com");
// Simulate selecting member role
// Not needed as default is member if canDoRoleManagement is true
expect(screen.getByText("environments.settings.teams.member_role_info_message")).toBeInTheDocument();
});
test("shows team select when canDoRoleManagement is true", () => {
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={true} />);
expect(screen.getByTestId("multi-select")).toBeInTheDocument();
});
test("shows upgrade alert when canDoRoleManagement is false", () => {
render(<IndividualInviteTab {...defaultProps} canDoRoleManagement={false} />);
expect(screen.getByText("environments.settings.teams.upgrade_plan_notice_message")).toBeInTheDocument();
expect(screen.getByText("common.start_free_trial")).toBeInTheDocument();
});
test("shows team select placeholder and message when no teams", () => {
render(<IndividualInviteTab {...defaultProps} teams={[]} />);
expect(screen.getByText("environments.settings.teams.create_first_team_message")).toBeInTheDocument();
});
test("cancel button closes modal", async () => {
render(<IndividualInviteTab {...defaultProps} />);
userEvent.click(screen.getByText("common.cancel"));
await waitFor(() => expect(defaultProps.setOpen).toHaveBeenCalledWith(false));
});
});
@@ -0,0 +1,60 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { InviteMemberModal } from "./invite-member-modal";
const t = (k: string) => k;
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t }) }));
vi.mock("./bulk-invite-tab", () => ({
BulkInviteTab: () => <div data-testid="bulk-invite-tab">BulkInviteTab</div>,
}));
vi.mock("./individual-invite-tab", () => ({
IndividualInviteTab: () => <div data-testid="individual-invite-tab">IndividualInviteTab</div>,
}));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: any) => (open ? <div data-testid="modal">{children}</div> : null),
}));
vi.mock("@/modules/ui/components/tab-toggle", () => ({
TabToggle: ({ options, onChange, defaultSelected }: any) => (
<select data-testid="tab-toggle" value={defaultSelected} onChange={(e) => onChange(e.target.value)}>
{options.map((opt: any) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
),
}));
const defaultProps = {
open: true,
setOpen: vi.fn(),
onSubmit: vi.fn(),
teams: [],
canDoRoleManagement: true,
isFormbricksCloud: true,
environmentId: "env-1",
membershipRole: "owner" as TOrganizationRole,
};
describe("InviteMemberModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders modal and individual tab by default", () => {
render(<InviteMemberModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("individual-invite-tab")).toBeInTheDocument();
expect(screen.getByTestId("tab-toggle")).toBeInTheDocument();
});
test("renders correct texts", () => {
render(<InviteMemberModal {...defaultProps} />);
expect(screen.getByText("environments.settings.teams.invite_member")).toBeInTheDocument();
expect(screen.getByText("environments.settings.teams.invite_member_description")).toBeInTheDocument();
});
});
@@ -0,0 +1,45 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { ShareInviteModal } from "./share-invite-modal";
const t = (k: string) => k;
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t }) }));
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: any) => (open ? <div data-testid="modal">{children}</div> : null),
}));
const defaultProps = {
inviteToken: "test-token",
open: true,
setOpen: vi.fn(),
};
describe("ShareInviteModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders modal and invite link", () => {
render(<ShareInviteModal {...defaultProps} />);
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("environments.settings.general.organization_invite_link_ready")
).toBeInTheDocument();
expect(
screen.getByText(
"environments.settings.general.share_this_link_to_let_your_organization_member_join_your_organization"
)
).toBeInTheDocument();
expect(screen.getByText("common.copy_link")).toBeInTheDocument();
});
test("calls setOpen when modal is closed", () => {
render(<ShareInviteModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,118 @@
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { getMembershipsByUserId } from "@/modules/organization/settings/teams/lib/membership";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { MembersLoading, MembersView } from "./members-view";
vi.mock("@/app/(app)/environments/[environmentId]/settings/components/SettingsCard", () => ({
SettingsCard: ({ title, description, children }: any) => (
<div data-testid="SettingsCard">
<div>{title}</div>
<div>{description}</div>
{children}
</div>
),
}));
vi.mock("@/lib/constants", () => ({
INVITE_DISABLED: false,
IS_FORMBRICKS_CLOUD: true,
}));
vi.mock("@/modules/organization/settings/teams/components/edit-memberships/organization-actions", () => ({
OrganizationActions: (props: any) => <div data-testid="OrganizationActions">{JSON.stringify(props)}</div>,
}));
vi.mock("@/modules/organization/settings/teams/components/edit-memberships", () => ({
EditMemberships: (props: any) => <div data-testid="EditMemberships">{JSON.stringify(props)}</div>,
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("@/modules/organization/settings/teams/lib/membership", () => ({
getMembershipsByUserId: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: vi.fn(),
}));
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
getTeamsByOrganizationId: vi.fn(),
}));
describe("MembersView", () => {
afterEach(() => {
cleanup();
});
const baseProps = {
membershipRole: "owner",
organization: { id: "org-1", name: "Test Org" },
currentUserId: "user-1",
environmentId: "env-1",
canDoRoleManagement: true,
isUserManagementDisabledFromUi: false,
} as any;
const mockMembership = {
organizationId: "org-1",
userId: "user-1",
accepted: true,
role: "owner" as TOrganizationRole,
};
test("renders SettingsCard and children with correct props", async () => {
vi.mocked(getMembershipsByUserId).mockResolvedValue([mockMembership]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([{ id: "t1", name: "Team 1" }]);
const ui = await MembersView(baseProps);
render(ui);
expect(screen.getByTestId("SettingsCard")).toBeInTheDocument();
expect(screen.getByText("environments.settings.general.manage_members")).toBeInTheDocument();
expect(screen.getByText("environments.settings.general.manage_members_description")).toBeInTheDocument();
expect(screen.getByTestId("OrganizationActions")).toBeInTheDocument();
expect(screen.getByTestId("EditMemberships")).toBeInTheDocument();
});
test("disables leave organization if only one membership", async () => {
vi.mocked(getMembershipsByUserId).mockResolvedValue([]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([{ id: "t1", name: "Team 1" }]);
const ui = await MembersView(baseProps);
render(ui);
expect(screen.getByTestId("OrganizationActions").textContent).toContain(
'"isLeaveOrganizationDisabled":true'
);
});
test("does not render OrganizationActions or EditMemberships if no membershipRole", async () => {
vi.mocked(getMembershipsByUserId).mockResolvedValue([mockMembership]);
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([{ id: "t1", name: "Team 1" }]);
const ui = await MembersView({ ...baseProps, membershipRole: undefined });
render(ui);
expect(screen.queryByTestId("OrganizationActions")).toBeNull();
expect(screen.queryByTestId("EditMemberships")).toBeNull();
});
});
describe("MembersLoading", () => {
afterEach(() => {
cleanup();
});
test("renders two skeleton loaders", () => {
const { container } = render(<MembersLoading />);
const skeletons = container.querySelectorAll(".animate-pulse");
expect(skeletons.length).toBe(2);
expect(skeletons[0]).toHaveClass("h-8", "w-80", "rounded-full", "bg-slate-200");
});
});
@@ -20,7 +20,7 @@ interface MembersViewProps {
isUserManagementDisabledFromUi: boolean;
}
const MembersLoading = () => (
export const MembersLoading = () => (
<div className="px-2">
{Array.from(Array(2)).map((_, index) => (
<div key={index} className="mt-4">
@@ -49,9 +49,6 @@ export const MembersView = async ({
if (canDoRoleManagement) {
teams = (await getTeamsByOrganizationId(organization.id)) ?? [];
if (!teams) {
throw new Error(t("common.teams_not_found"));
}
}
return (
@@ -0,0 +1,230 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { Invite, Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { TInvitee } from "../types/invites";
import { deleteInvite, getInvite, getInvitesByOrganizationId, inviteUser, resendInvite } from "./invite";
vi.mock("@formbricks/database", () => ({
prisma: {
invite: {
findUnique: vi.fn(),
update: vi.fn(),
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
team: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/invite", () => ({
inviteCache: { revalidate: vi.fn(), tag: { byOrganizationId: (id) => id, byId: (id) => id } },
}));
vi.mock("@/lib/membership/service", () => ({ getMembershipByUserIdOrganizationId: vi.fn() }));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
const mockInvite: Invite = {
id: "invite-1",
email: "test@example.com",
name: "Test User",
organizationId: "org-1",
creatorId: "user-1",
acceptorId: null,
role: "member",
expiresAt: new Date(),
createdAt: new Date(),
deprecatedRole: null,
teamIds: [],
};
describe("resendInvite", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns email and name if invite exists", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue({ ...mockInvite, creator: {} });
vi.mocked(prisma.invite.update).mockResolvedValue({ ...mockInvite, organizationId: "org-1" });
const result = await resendInvite("invite-1");
expect(result).toEqual({ email: mockInvite.email, name: mockInvite.name });
});
test("throws ResourceNotFoundError if invite not found", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue(null);
await expect(resendInvite("invite-1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.findUnique).mockRejectedValue(prismaError);
await expect(resendInvite("invite-1")).rejects.toThrow(DatabaseError);
});
test("throws error if prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.findUnique).mockRejectedValue(error);
await expect(resendInvite("invite-1")).rejects.toThrow("db");
});
});
describe("getInvitesByOrganizationId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns invites", async () => {
vi.mocked(prisma.invite.findMany).mockResolvedValue([mockInvite]);
const result = await getInvitesByOrganizationId("org-1", 1);
expect(result[0].id).toBe("invite-1");
});
test("throws DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.invite.findMany).mockRejectedValue(prismaError);
await expect(getInvitesByOrganizationId("org-1", 1)).rejects.toThrow(DatabaseError);
});
test("throws error if prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.findMany).mockRejectedValue(error);
await expect(getInvitesByOrganizationId("org-1", 1)).rejects.toThrow("db");
});
});
describe("inviteUser", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const invitee: TInvitee = { name: "Test", email: "test@example.com", role: "member", teamIds: [] };
test("creates invite if valid", async () => {
vi.mocked(prisma.invite.findFirst).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.team.findMany).mockResolvedValue([]);
vi.mocked(prisma.invite.create).mockResolvedValue(mockInvite);
const result = await inviteUser({ invitee, organizationId: "org-1", currentUserId: "user-1" });
expect(result).toBe("invite-1");
});
test("throws InvalidInputError if invite exists", async () => {
vi.mocked(prisma.invite.findFirst).mockResolvedValue(mockInvite);
await expect(inviteUser({ invitee, organizationId: "org-1", currentUserId: "user-1" })).rejects.toThrow(
InvalidInputError
);
});
test("throws InvalidInputError if user is already a member", async () => {
vi.mocked(prisma.invite.findFirst).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue({ id: "user-2" });
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
accepted: true,
organizationId: "org1",
role: "member",
userId: "user1",
});
await expect(inviteUser({ invitee, organizationId: "org-1", currentUserId: "user-1" })).rejects.toThrow(
InvalidInputError
);
});
test("throws ValidationError if teamIds not unique", async () => {
await expect(
inviteUser({
invitee: { ...invitee, teamIds: ["a", "a"] },
organizationId: "org-1",
currentUserId: "user-1",
})
).rejects.toThrow(ValidationError);
});
test("throws ValidationError if teamIds invalid", async () => {
vi.mocked(prisma.invite.findFirst).mockResolvedValue(null);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.team.findMany).mockResolvedValue([]);
await expect(
inviteUser({
invitee: { ...invitee, teamIds: ["a"] },
organizationId: "org-1",
currentUserId: "user-1",
})
).rejects.toThrow(ValidationError);
});
test("throws DatabaseError on prisma error", async () => {
const error = new Prisma.PrismaClientKnownRequestError("db", { code: "P2002", clientVersion: "1.0.0" });
vi.mocked(prisma.invite.findFirst).mockRejectedValue(error);
await expect(inviteUser({ invitee, organizationId: "org-1", currentUserId: "user-1" })).rejects.toThrow(
DatabaseError
);
});
test("throws error if prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.findFirst).mockRejectedValue(error);
await expect(inviteUser({ invitee, organizationId: "org-1", currentUserId: "user-1" })).rejects.toThrow(
"db"
);
});
});
describe("deleteInvite", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns true if deleted", async () => {
vi.mocked(prisma.invite.delete).mockResolvedValue({ id: "invite-1", organizationId: "org-1" } as any);
const result = await deleteInvite("invite-1");
expect(result).toBe(true);
});
test("throws ResourceNotFoundError if not found", async () => {
vi.mocked(prisma.invite.delete).mockResolvedValue(null as any);
await expect(deleteInvite("invite-1")).rejects.toThrow(ResourceNotFoundError);
});
test("throws DatabaseError on prisma error", async () => {
const error = new Prisma.PrismaClientKnownRequestError("db", { code: "P2002", clientVersion: "1.0.0" });
vi.mocked(prisma.invite.delete).mockRejectedValue(error);
await expect(deleteInvite("invite-1")).rejects.toThrow(DatabaseError);
});
test("throws error if prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.delete).mockRejectedValue(error);
await expect(deleteInvite("invite-1")).rejects.toThrow("db");
});
});
describe("getInvite", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns invite with creator if found", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue({
email: "test@example.com",
creator: { name: "Test" },
});
const result = await getInvite("invite-1");
expect(result).toEqual({ email: "test@example.com", creator: { name: "Test" } });
});
test("returns null if not found", async () => {
vi.mocked(prisma.invite.findUnique).mockResolvedValue(null);
const result = await getInvite("invite-1");
expect(result).toBeNull();
});
test("throws DatabaseError on prisma error", async () => {
const error = new Prisma.PrismaClientKnownRequestError("db", { code: "P2002", clientVersion: "1.0.0" });
vi.mocked(prisma.invite.findUnique).mockRejectedValue(error);
await expect(getInvite("invite-1")).rejects.toThrow(DatabaseError);
});
test("throws error if prisma error", async () => {
const error = new Error("db");
vi.mocked(prisma.invite.findUnique).mockRejectedValue(error);
await expect(getInvite("invite-1")).rejects.toThrow("db");
});
});
@@ -0,0 +1,173 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
import {
deleteMembership,
getMembersByOrganizationId,
getMembershipByOrganizationId,
getMembershipsByUserId,
getOrganizationOwnerCount,
} from "./membership";
vi.mock("@formbricks/database", () => ({
prisma: {
membership: {
findMany: vi.fn(),
count: vi.fn(),
delete: vi.fn(),
},
teamUser: {
findMany: vi.fn(),
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/cache", () => ({ cache: (fn) => fn }));
vi.mock("@/lib/cache/membership", () => ({
membershipCache: { revalidate: vi.fn(), tag: { byOrganizationId: (id) => id, byUserId: (id) => id } },
}));
vi.mock("@/lib/cache/organization", () => ({ organizationCache: { revalidate: vi.fn() } }));
vi.mock("@/lib/cache/team", () => ({ teamCache: { revalidate: vi.fn() } }));
vi.mock("@/lib/constants", () => ({ ITEMS_PER_PAGE: 2 }));
vi.mock("@/lib/utils/validate", () => ({ validateInputs: vi.fn() }));
vi.mock("react", () => ({ cache: (fn) => fn }));
vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn() } }));
const organizationId = "org-1";
const userId = "user-1";
const teamId = "team-1";
const mockMember = {
user: { name: "Test", email: "test@example.com", isActive: true },
userId,
accepted: true,
role: "member",
};
const mockMembership = { userId, organizationId, role: "member", accepted: true };
const mockTeamMembership = { userId, role: "contributor", teamId };
describe("getMembershipByOrganizationId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns members", async () => {
vi.mocked(prisma.membership.findMany).mockResolvedValue([mockMember]);
const result = await getMembershipByOrganizationId(organizationId, 1);
expect(result[0].userId).toBe(userId);
expect(result[0].name).toBe("Test");
expect(result[0].email).toBe("test@example.com");
expect(result[0].isActive).toBe(true);
});
test("throws DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.membership.findMany).mockRejectedValue(prismaError);
await expect(getMembershipByOrganizationId(organizationId, 1)).rejects.toThrow(DatabaseError);
});
test("throws UnknownError on unknown error", async () => {
vi.mocked(prisma.membership.findMany).mockRejectedValue({});
await expect(getMembershipByOrganizationId(organizationId, 1)).rejects.toThrow(UnknownError);
});
});
describe("getOrganizationOwnerCount", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns owner count", async () => {
vi.mocked(prisma.membership.count).mockResolvedValue(2);
const result = await getOrganizationOwnerCount(organizationId);
expect(result).toBe(2);
});
test("throws DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.membership.count).mockRejectedValue(prismaError);
await expect(getOrganizationOwnerCount(organizationId)).rejects.toThrow(DatabaseError);
});
test("throws original error on unknown error", async () => {
vi.mocked(prisma.membership.count).mockRejectedValue({});
await expect(getOrganizationOwnerCount(organizationId)).rejects.toThrowError();
});
});
describe("deleteMembership", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("deletes membership and returns deleted team memberships", async () => {
vi.mocked(prisma.teamUser.findMany).mockResolvedValue([mockTeamMembership]);
vi.mocked(prisma.$transaction).mockResolvedValue([{}, {}]);
const result = await deleteMembership(userId, organizationId);
expect(result[0].teamId).toBe(teamId);
});
test("throws DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.teamUser.findMany).mockRejectedValue(prismaError);
await expect(deleteMembership(userId, organizationId)).rejects.toThrow(DatabaseError);
});
test("throws original error on unknown error", async () => {
vi.mocked(prisma.teamUser.findMany).mockRejectedValue({});
await expect(deleteMembership(userId, organizationId)).rejects.toThrowError();
});
});
describe("getMembershipsByUserId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns memberships", async () => {
vi.mocked(prisma.membership.findMany).mockResolvedValue([mockMembership]);
const result = await getMembershipsByUserId(userId, 1);
expect(result[0].userId).toBe(userId);
expect(result[0].organizationId).toBe(organizationId);
});
test("throws DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.membership.findMany).mockRejectedValue(prismaError);
await expect(getMembershipsByUserId(userId, 1)).rejects.toThrow(DatabaseError);
});
test("throws UnknownError on unknown error", async () => {
vi.mocked(prisma.membership.findMany).mockRejectedValue(new Error("unknown"));
await expect(getMembershipsByUserId(userId, 1)).rejects.toThrow(Error);
});
});
describe("getMembersByOrganizationId", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns members", async () => {
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{ user: { name: "Test" }, role: "member", userId },
]);
const result = await getMembersByOrganizationId(organizationId);
expect(result[0].id).toBe(userId);
expect(result[0].name).toBe("Test");
expect(result[0].role).toBe("member");
});
test("throws DatabaseError on prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("db", {
code: "P2002",
clientVersion: "1.0.0",
});
vi.mocked(prisma.membership.findMany).mockRejectedValue(prismaError);
await expect(getMembersByOrganizationId(organizationId)).rejects.toThrow(DatabaseError);
});
test("throws original on unknown error", async () => {
vi.mocked(prisma.membership.findMany).mockRejectedValue(new Error("unknown"));
await expect(getMembersByOrganizationId(organizationId)).rejects.toThrow(Error);
});
});
@@ -0,0 +1,29 @@
import { TInvite } from "@/modules/organization/settings/teams/types/invites";
import { describe, expect, test } from "vitest";
import { isInviteExpired } from "./utils";
describe("isInviteExpired", () => {
test("returns true if invite is expired", () => {
const invite: TInvite = {
id: "1",
email: "test@example.com",
name: "Test",
role: "member",
expiresAt: new Date(Date.now() - 1000 * 60 * 60),
createdAt: new Date(),
};
expect(isInviteExpired(invite)).toBe(true);
});
test("returns false if invite is not expired", () => {
const invite: TInvite = {
id: "1",
email: "test@example.com",
name: "Test",
role: "member",
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
createdAt: new Date(),
};
expect(isInviteExpired(invite)).toBe(false);
});
});
@@ -0,0 +1,93 @@
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TeamsPage } from "./page";
vi.mock(
"@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar",
() => ({
OrganizationSettingsNavbar: (props) => <div data-testid="org-navbar">OrgNavbar-{props.activeId}</div>,
})
);
vi.mock("@/lib/constants", () => ({
DISABLE_USER_MANAGEMENT: 0,
IS_FORMBRICKS_CLOUD: 1,
ENCRYPTION_KEY: "test-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-key",
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getRoleManagementPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/team-list/components/teams-view", () => ({
TeamsView: (props) => <div data-testid="teams-view">TeamsView-{props.organizationId}</div>,
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/organization/settings/teams/components/members-view", () => ({
MembersView: (props) => <div data-testid="members-view">MembersView-{props.membershipRole}</div>,
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: ({ children }) => <div data-testid="content-wrapper">{children}</div>,
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: ({ children, pageTitle }) => (
<div data-testid="page-header">
<span>{pageTitle}</span>
{children}
</div>
),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
const mockParams = { environmentId: "env-1" };
const mockOrg = { id: "org-1", billing: { plan: "free" } };
const mockMembership = { role: "owner" };
const mockSession = { user: { id: "user-1" } };
describe("TeamsPage", () => {
afterEach(() => {
cleanup();
});
test("renders all main components and passes props", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: mockSession,
currentUserMembership: mockMembership,
organization: mockOrg,
} as any);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
const props = { params: Promise.resolve(mockParams) };
render(await TeamsPage(props));
expect(screen.getByTestId("content-wrapper")).toBeInTheDocument();
expect(screen.getByTestId("page-header")).toBeInTheDocument();
expect(screen.getByTestId("org-navbar")).toHaveTextContent("OrgNavbar-teams");
expect(screen.getByTestId("members-view")).toHaveTextContent("MembersView-owner");
expect(screen.getByTestId("teams-view")).toHaveTextContent("TeamsView-org-1");
expect(screen.getByText("environments.settings.general.organization_settings")).toBeInTheDocument();
});
test("passes correct props to role management util", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
session: mockSession,
currentUserMembership: mockMembership,
organization: mockOrg,
} as any);
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
const props = { params: Promise.resolve(mockParams) };
render(await TeamsPage(props));
expect(getRoleManagementPermission).toHaveBeenCalledWith("free");
});
});