mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-27 23:49:51 -05:00
459 lines
19 KiB
TypeScript
459 lines
19 KiB
TypeScript
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
import { prisma } from "@formbricks/database";
|
|
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
|
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
|
|
import {
|
|
getContactAttributes,
|
|
hasEmailAttribute,
|
|
hasUserIdAttribute,
|
|
} from "@/modules/ee/contacts/lib/contact-attributes";
|
|
import { updateAttributes } from "./attributes";
|
|
|
|
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", async () => {
|
|
const actual = await vi.importActual("@/modules/ee/contacts/lib/contact-attributes");
|
|
return {
|
|
...actual,
|
|
getContactAttributes: vi.fn(),
|
|
hasEmailAttribute: vi.fn(),
|
|
hasUserIdAttribute: vi.fn(),
|
|
};
|
|
});
|
|
vi.mock("@formbricks/database", () => ({
|
|
prisma: {
|
|
$transaction: vi.fn(),
|
|
contactAttribute: { upsert: vi.fn(), findMany: vi.fn(), deleteMany: 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,
|
|
},
|
|
{
|
|
id: "key-3",
|
|
key: "customAttr",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
isUnique: false,
|
|
name: "Custom Attribute",
|
|
description: null,
|
|
type: "custom",
|
|
environmentId,
|
|
},
|
|
];
|
|
|
|
describe("updateAttributes", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Set default mock return values - these will be overridden in individual tests
|
|
vi.mocked(getContactAttributes).mockResolvedValue({});
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
});
|
|
|
|
test("updates existing attributes", async () => {
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
const attributes = { name: "John", email: "john@example.com" };
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
expect(prisma.$transaction).toHaveBeenCalled();
|
|
expect(result.success).toBe(true);
|
|
expect(result.messages).toBeUndefined();
|
|
});
|
|
|
|
test("skips updating email if it already exists", async () => {
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
const attributes = { name: "John", email: "john@example.com" };
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
expect(prisma.$transaction).toHaveBeenCalled();
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.messages).toContainEqual({ code: "email_already_exists", params: {} });
|
|
});
|
|
|
|
test("skips updating userId if it already exists", async () => {
|
|
const attributeKeysWithUserId: TContactAttributeKey[] = [
|
|
...attributeKeys,
|
|
{
|
|
id: "key-4",
|
|
key: "userId",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
isUnique: true,
|
|
name: "User ID",
|
|
description: null,
|
|
type: "default",
|
|
environmentId,
|
|
},
|
|
];
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", userId: "old-user-id" });
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(true);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
const attributes = { name: "John", userId: "duplicate-user-id" };
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
expect(prisma.$transaction).toHaveBeenCalled();
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.messages).toContainEqual({ code: "userid_already_exists", params: {} });
|
|
expect(result.ignoreUserIdAttribute).toBe(true);
|
|
});
|
|
|
|
test("skips updating both email and userId if both already exist", async () => {
|
|
const attributeKeysWithUserId: TContactAttributeKey[] = [
|
|
...attributeKeys,
|
|
{
|
|
id: "key-4",
|
|
key: "userId",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
isUnique: true,
|
|
name: "User ID",
|
|
description: null,
|
|
type: "default",
|
|
environmentId,
|
|
},
|
|
];
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({
|
|
name: "Jane",
|
|
email: "old@example.com",
|
|
userId: "old-user-id",
|
|
});
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(true);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(true);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
const attributes = { name: "John", email: "duplicate@example.com", userId: "duplicate-user-id" };
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
expect(prisma.$transaction).toHaveBeenCalled();
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.messages).toContainEqual({ code: "email_already_exists", params: {} });
|
|
expect(result.messages).toContainEqual({ code: "userid_already_exists", params: {} });
|
|
expect(result.ignoreEmailAttribute).toBe(true);
|
|
expect(result.ignoreUserIdAttribute).toBe(true);
|
|
});
|
|
|
|
test("creates new attributes if under limit", async () => {
|
|
// Use name and email keys (2 existing keys), MAX is mocked to 2
|
|
// We update existing attributes, no new ones created
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[0], attributeKeys[1]]); // name, email
|
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
const attributes = { name: "John", email: "john@example.com" };
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
expect(prisma.$transaction).toHaveBeenCalled();
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.messages).toBeUndefined();
|
|
});
|
|
|
|
test("does not create new attributes if over the limit", async () => {
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
// Include email to satisfy the "at least one of email or userId" requirement
|
|
const attributes = { name: "John", email: "john@example.com", new_attr: "val" };
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
expect(result.success).toBe(true);
|
|
expect(result.messages?.[0]).toEqual(
|
|
expect.objectContaining({
|
|
code: "attribute_limit_exceeded",
|
|
params: expect.objectContaining({ count: "1" }),
|
|
})
|
|
);
|
|
});
|
|
|
|
test("returns success with only email attribute", async () => {
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue([attributeKeys[1]]); // email key
|
|
vi.mocked(getContactAttributes).mockResolvedValue({ email: "existing@example.com" });
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
const attributes = { email: "updated@example.com" };
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
expect(result.success).toBe(true);
|
|
expect(result.messages).toBeUndefined();
|
|
});
|
|
|
|
test("deletes non-default attributes when deleteRemovedAttributes is true", async () => {
|
|
// Reset mocks explicitly for this test
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
|
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({
|
|
name: "Jane",
|
|
email: "jane@example.com",
|
|
customAttr: "oldValue",
|
|
});
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 1 });
|
|
const attributes = { name: "John", email: "john@example.com" };
|
|
// Pass deleteRemovedAttributes: true to enable deletion behavior
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes, true);
|
|
// Only customAttr (key-3) should be deleted, not name (key-1) or email (key-2)
|
|
expect(prisma.contactAttribute.deleteMany).toHaveBeenCalledWith({
|
|
where: {
|
|
contactId,
|
|
attributeKeyId: {
|
|
in: ["key-3"],
|
|
},
|
|
},
|
|
});
|
|
expect(result.success).toBe(true);
|
|
expect(result.messages).toBeUndefined();
|
|
});
|
|
|
|
test("does not delete attributes when deleteRemovedAttributes is false (default behavior)", async () => {
|
|
// Reset mocks explicitly for this test
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
|
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({
|
|
name: "Jane",
|
|
email: "jane@example.com",
|
|
customAttr: "oldValue",
|
|
});
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
const attributes = { name: "John", email: "john@example.com" };
|
|
// Default behavior (deleteRemovedAttributes: false) should NOT delete existing attributes
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
// deleteMany should NOT be called since we're merging, not replacing
|
|
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
|
|
expect(result.success).toBe(true);
|
|
expect(result.messages).toBeUndefined();
|
|
});
|
|
|
|
test("does not delete default attributes even when deleteRemovedAttributes is true", async () => {
|
|
// Reset mocks explicitly for this test
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockClear();
|
|
|
|
// Note: DEFAULT_ATTRIBUTES includes: email, userId, firstName, lastName (not "name")
|
|
const attributeKeysWithDefaults: TContactAttributeKey[] = [
|
|
{
|
|
id: "key-2",
|
|
key: "email",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
isUnique: false,
|
|
name: "Email",
|
|
description: null,
|
|
type: "default",
|
|
environmentId,
|
|
},
|
|
{
|
|
id: "key-4",
|
|
key: "userId",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
isUnique: false,
|
|
name: "User ID",
|
|
description: null,
|
|
type: "default",
|
|
environmentId,
|
|
},
|
|
{
|
|
id: "key-5",
|
|
key: "firstName",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
isUnique: false,
|
|
name: "First Name",
|
|
description: null,
|
|
type: "default",
|
|
environmentId,
|
|
},
|
|
{
|
|
id: "key-3",
|
|
key: "customAttr",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
isUnique: false,
|
|
name: "Custom Attribute",
|
|
description: null,
|
|
type: "custom",
|
|
environmentId,
|
|
},
|
|
];
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithDefaults);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({
|
|
email: "test@example.com",
|
|
userId: "user-123",
|
|
firstName: "John",
|
|
});
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
const attributes = { customAttr: "value" };
|
|
// Pass deleteRemovedAttributes: true to test that default attributes are still preserved
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes, true);
|
|
// Should not delete default attributes (email, userId, firstName) - deleteMany should not be called
|
|
// since all current attributes are default attributes
|
|
expect(prisma.contactAttribute.deleteMany).not.toHaveBeenCalled();
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
test("preserves existing email when empty string is submitted", async () => {
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "existing@example.com" });
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
|
|
// Attempt to clear email by submitting empty string
|
|
const attributes = { name: "John", email: "" };
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
|
|
// Verify that the transaction was called with the preserved email
|
|
expect(prisma.$transaction).toHaveBeenCalled();
|
|
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
|
|
// The email should be preserved (existing@example.com), not cleared
|
|
expect(transactionCall).toHaveLength(2); // name and email
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
test("allows clearing userId when empty string is submitted", async () => {
|
|
const attributeKeysWithUserId: TContactAttributeKey[] = [
|
|
...attributeKeys,
|
|
{
|
|
id: "key-4",
|
|
key: "userId",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
isUnique: true,
|
|
name: "User ID",
|
|
description: null,
|
|
type: "default",
|
|
environmentId,
|
|
},
|
|
];
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithUserId);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", userId: "existing-user-id" });
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
|
|
// Clear userId by submitting empty string - this should be allowed
|
|
const attributes = { name: "John", userId: "" };
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
|
|
// Verify that the transaction was called
|
|
expect(prisma.$transaction).toHaveBeenCalled();
|
|
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
|
|
// Only name and userId (empty) should be in the transaction
|
|
expect(transactionCall).toHaveLength(2); // name and userId (with empty value)
|
|
expect(result.success).toBe(true);
|
|
});
|
|
|
|
test("preserves existing values when both email and userId would be cleared", async () => {
|
|
const attributeKeysWithBoth: TContactAttributeKey[] = [
|
|
...attributeKeys,
|
|
{
|
|
id: "key-4",
|
|
key: "userId",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
isUnique: true,
|
|
name: "User ID",
|
|
description: null,
|
|
type: "default",
|
|
environmentId,
|
|
},
|
|
];
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeysWithBoth);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({
|
|
name: "Jane",
|
|
email: "existing@example.com",
|
|
userId: "existing-user-id",
|
|
});
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
|
|
// Attempt to clear both email and userId
|
|
const attributes = { name: "John", email: "", userId: "" };
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.messages).toContainEqual({ code: "email_or_userid_required", params: {} });
|
|
});
|
|
|
|
test("coerces boolean attribute values to strings", async () => {
|
|
vi.mocked(getContactAttributeKeys).mockResolvedValue(attributeKeys);
|
|
vi.mocked(getContactAttributes).mockResolvedValue({ name: "Jane", email: "jane@example.com" });
|
|
vi.mocked(hasEmailAttribute).mockResolvedValue(false);
|
|
vi.mocked(hasUserIdAttribute).mockResolvedValue(false);
|
|
vi.mocked(prisma.$transaction).mockResolvedValue(undefined);
|
|
vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 0 });
|
|
|
|
const attributes = { name: true, email: "john@example.com" };
|
|
const result = await updateAttributes(contactId, userId, environmentId, attributes);
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(prisma.$transaction).toHaveBeenCalled();
|
|
const transactionCall = vi.mocked(prisma.$transaction).mock.calls[0][0];
|
|
// Both name (coerced from boolean) and email should be upserted
|
|
expect(transactionCall).toHaveLength(2);
|
|
});
|
|
});
|