diff --git a/apps/web/utils/action-item.test.ts b/apps/web/utils/action-item.test.ts new file mode 100644 index 0000000000..96e39c1a7a --- /dev/null +++ b/apps/web/utils/action-item.test.ts @@ -0,0 +1,295 @@ +import { describe, it, expect } from "vitest"; +import { + getActionFields, + sanitizeActionFields, + actionInputs, +} from "./action-item"; +import { ActionType } from "@/generated/prisma/enums"; + +describe("actionInputs", () => { + it("has configuration for all action types", () => { + const actionTypes = Object.values(ActionType); + for (const type of actionTypes) { + expect(actionInputs[type]).toBeDefined(); + expect(actionInputs[type].fields).toBeDefined(); + } + }); + + it("ARCHIVE has no fields", () => { + expect(actionInputs[ActionType.ARCHIVE].fields).toEqual([]); + }); + + it("LABEL has labelId field", () => { + const fields = actionInputs[ActionType.LABEL].fields; + expect(fields).toHaveLength(1); + expect(fields[0].name).toBe("labelId"); + }); + + it("DRAFT_EMAIL has subject, content, to, cc, bcc fields", () => { + const fieldNames = actionInputs[ActionType.DRAFT_EMAIL].fields.map( + (f) => f.name, + ); + expect(fieldNames).toContain("subject"); + expect(fieldNames).toContain("content"); + expect(fieldNames).toContain("to"); + expect(fieldNames).toContain("cc"); + expect(fieldNames).toContain("bcc"); + }); + + it("CALL_WEBHOOK has url field", () => { + const fields = actionInputs[ActionType.CALL_WEBHOOK].fields; + expect(fields).toHaveLength(1); + expect(fields[0].name).toBe("url"); + }); +}); + +describe("getActionFields", () => { + it("returns empty object for undefined input", () => { + expect(getActionFields(undefined)).toEqual({}); + }); + + it("returns only fields with values", () => { + const action = { + label: "Test Label", + subject: null, + content: "", + to: "test@example.com", + } as any; + const result = getActionFields(action); + expect(result).toEqual({ + label: "Test Label", + to: "test@example.com", + }); + expect(result).not.toHaveProperty("subject"); + expect(result).not.toHaveProperty("content"); + }); + + it("returns all populated fields", () => { + const action = { + label: "Label", + subject: "Subject", + content: "Content", + to: "to@test.com", + cc: "cc@test.com", + bcc: "bcc@test.com", + url: "https://example.com", + folderName: "Archive", + folderId: "folder123", + } as any; + const result = getActionFields(action); + expect(result).toEqual({ + label: "Label", + subject: "Subject", + content: "Content", + to: "to@test.com", + cc: "cc@test.com", + bcc: "bcc@test.com", + url: "https://example.com", + folderName: "Archive", + folderId: "folder123", + }); + }); + + it("excludes falsy values except for defined nulls", () => { + const action = { + label: "", + subject: null, + content: undefined, + to: "test@example.com", + } as any; + const result = getActionFields(action); + expect(result).toEqual({ to: "test@example.com" }); + }); +}); + +describe("sanitizeActionFields", () => { + describe("actions with no fields", () => { + it("returns base fields for ARCHIVE", () => { + const result = sanitizeActionFields({ type: ActionType.ARCHIVE }); + expect(result.type).toBe(ActionType.ARCHIVE); + expect(result.label).toBeNull(); + expect(result.subject).toBeNull(); + expect(result.content).toBeNull(); + }); + + it("returns base fields for MARK_SPAM", () => { + const result = sanitizeActionFields({ type: ActionType.MARK_SPAM }); + expect(result.type).toBe(ActionType.MARK_SPAM); + }); + + it("returns base fields for MARK_READ", () => { + const result = sanitizeActionFields({ type: ActionType.MARK_READ }); + expect(result.type).toBe(ActionType.MARK_READ); + }); + + it("returns base fields for DIGEST", () => { + const result = sanitizeActionFields({ type: ActionType.DIGEST }); + expect(result.type).toBe(ActionType.DIGEST); + }); + + it("returns base fields for NOTIFY_SENDER", () => { + const result = sanitizeActionFields({ type: ActionType.NOTIFY_SENDER }); + expect(result.type).toBe(ActionType.NOTIFY_SENDER); + }); + }); + + describe("LABEL action", () => { + it("preserves label and labelId fields", () => { + const result = sanitizeActionFields({ + type: ActionType.LABEL, + label: "Newsletters", + labelId: "label123", + }); + expect(result.label).toBe("Newsletters"); + expect(result.labelId).toBe("label123"); + }); + + it("nullifies unrelated fields", () => { + const result = sanitizeActionFields({ + type: ActionType.LABEL, + label: "Test", + subject: "Should be null", + to: "should@be.null", + }); + expect(result.label).toBe("Test"); + expect(result.subject).toBeNull(); + expect(result.to).toBeNull(); + }); + }); + + describe("MOVE_FOLDER action", () => { + it("preserves folderName and folderId fields", () => { + const result = sanitizeActionFields({ + type: ActionType.MOVE_FOLDER, + folderName: "Archive", + folderId: "folder123", + }); + expect(result.folderName).toBe("Archive"); + expect(result.folderId).toBe("folder123"); + }); + }); + + describe("REPLY action", () => { + it("preserves content, cc, and bcc fields", () => { + const result = sanitizeActionFields({ + type: ActionType.REPLY, + content: "Reply content", + cc: "cc@test.com", + bcc: "bcc@test.com", + }); + expect(result.content).toBe("Reply content"); + expect(result.cc).toBe("cc@test.com"); + expect(result.bcc).toBe("bcc@test.com"); + }); + + it("nullifies subject and to fields", () => { + const result = sanitizeActionFields({ + type: ActionType.REPLY, + subject: "Should be null", + to: "should@be.null", + content: "Content", + }); + expect(result.subject).toBeNull(); + expect(result.to).toBeNull(); + expect(result.content).toBe("Content"); + }); + }); + + describe("SEND_EMAIL action", () => { + it("preserves subject, content, to, cc, and bcc fields", () => { + const result = sanitizeActionFields({ + type: ActionType.SEND_EMAIL, + subject: "Subject", + content: "Content", + to: "to@test.com", + cc: "cc@test.com", + bcc: "bcc@test.com", + }); + expect(result.subject).toBe("Subject"); + expect(result.content).toBe("Content"); + expect(result.to).toBe("to@test.com"); + expect(result.cc).toBe("cc@test.com"); + expect(result.bcc).toBe("bcc@test.com"); + }); + }); + + describe("FORWARD action", () => { + it("preserves content, to, cc, and bcc fields", () => { + const result = sanitizeActionFields({ + type: ActionType.FORWARD, + content: "Extra content", + to: "forward@test.com", + cc: "cc@test.com", + bcc: "bcc@test.com", + }); + expect(result.content).toBe("Extra content"); + expect(result.to).toBe("forward@test.com"); + expect(result.cc).toBe("cc@test.com"); + expect(result.bcc).toBe("bcc@test.com"); + }); + + it("nullifies subject field", () => { + const result = sanitizeActionFields({ + type: ActionType.FORWARD, + subject: "Should be null", + to: "forward@test.com", + }); + expect(result.subject).toBeNull(); + }); + }); + + describe("DRAFT_EMAIL action", () => { + it("preserves subject, content, to, cc, and bcc fields", () => { + const result = sanitizeActionFields({ + type: ActionType.DRAFT_EMAIL, + subject: "Draft Subject", + content: "Draft Content", + to: "draft@test.com", + cc: "cc@test.com", + bcc: "bcc@test.com", + }); + expect(result.subject).toBe("Draft Subject"); + expect(result.content).toBe("Draft Content"); + expect(result.to).toBe("draft@test.com"); + expect(result.cc).toBe("cc@test.com"); + expect(result.bcc).toBe("bcc@test.com"); + }); + }); + + describe("CALL_WEBHOOK action", () => { + it("preserves url field", () => { + const result = sanitizeActionFields({ + type: ActionType.CALL_WEBHOOK, + url: "https://example.com/webhook", + }); + expect(result.url).toBe("https://example.com/webhook"); + }); + + it("nullifies unrelated fields", () => { + const result = sanitizeActionFields({ + type: ActionType.CALL_WEBHOOK, + url: "https://example.com", + to: "should@be.null", + content: "should be null", + }); + expect(result.url).toBe("https://example.com"); + expect(result.to).toBeNull(); + expect(result.content).toBeNull(); + }); + }); + + describe("delayInMinutes", () => { + it("preserves delayInMinutes when provided", () => { + const result = sanitizeActionFields({ + type: ActionType.ARCHIVE, + delayInMinutes: 60, + }); + expect(result.delayInMinutes).toBe(60); + }); + + it("sets delayInMinutes to null when not provided", () => { + const result = sanitizeActionFields({ type: ActionType.ARCHIVE }); + expect(result.delayInMinutes).toBeNull(); + }); + }); +}); diff --git a/apps/web/utils/action-sort.test.ts b/apps/web/utils/action-sort.test.ts new file mode 100644 index 0000000000..8bb8a94cdb --- /dev/null +++ b/apps/web/utils/action-sort.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect } from "vitest"; +import { sortActionsByPriority } from "./action-sort"; +import { ActionType } from "@/generated/prisma/enums"; + +describe("sortActionsByPriority", () => { + describe("basic sorting", () => { + it("sorts LABEL before ARCHIVE", () => { + const actions = [ + { type: ActionType.ARCHIVE }, + { type: ActionType.LABEL }, + ]; + const sorted = sortActionsByPriority(actions); + expect(sorted[0].type).toBe(ActionType.LABEL); + expect(sorted[1].type).toBe(ActionType.ARCHIVE); + }); + + it("sorts ARCHIVE before REPLY", () => { + const actions = [ + { type: ActionType.REPLY }, + { type: ActionType.ARCHIVE }, + ]; + const sorted = sortActionsByPriority(actions); + expect(sorted[0].type).toBe(ActionType.ARCHIVE); + expect(sorted[1].type).toBe(ActionType.REPLY); + }); + + it("sorts email actions (DRAFT_EMAIL, REPLY, SEND_EMAIL, FORWARD) together", () => { + const actions = [ + { type: ActionType.FORWARD }, + { type: ActionType.DRAFT_EMAIL }, + { type: ActionType.SEND_EMAIL }, + { type: ActionType.REPLY }, + ]; + const sorted = sortActionsByPriority(actions); + expect(sorted.map((a) => a.type)).toEqual([ + ActionType.DRAFT_EMAIL, + ActionType.REPLY, + ActionType.SEND_EMAIL, + ActionType.FORWARD, + ]); + }); + + it("sorts CALL_WEBHOOK last among known types", () => { + const actions = [ + { type: ActionType.CALL_WEBHOOK }, + { type: ActionType.LABEL }, + { type: ActionType.ARCHIVE }, + ]; + const sorted = sortActionsByPriority(actions); + expect(sorted[sorted.length - 1].type).toBe(ActionType.CALL_WEBHOOK); + }); + }); + + describe("full priority order", () => { + it("maintains correct priority order for all action types", () => { + const actions = [ + { type: ActionType.CALL_WEBHOOK }, + { type: ActionType.MARK_SPAM }, + { type: ActionType.DIGEST }, + { type: ActionType.FORWARD }, + { type: ActionType.SEND_EMAIL }, + { type: ActionType.REPLY }, + { type: ActionType.DRAFT_EMAIL }, + { type: ActionType.MARK_READ }, + { type: ActionType.ARCHIVE }, + { type: ActionType.MOVE_FOLDER }, + { type: ActionType.LABEL }, + ]; + + const sorted = sortActionsByPriority(actions); + expect(sorted.map((a) => a.type)).toEqual([ + ActionType.LABEL, + ActionType.MOVE_FOLDER, + ActionType.ARCHIVE, + ActionType.MARK_READ, + ActionType.DRAFT_EMAIL, + ActionType.REPLY, + ActionType.SEND_EMAIL, + ActionType.FORWARD, + ActionType.DIGEST, + ActionType.MARK_SPAM, + ActionType.CALL_WEBHOOK, + ]); + }); + }); + + describe("edge cases", () => { + it("handles empty array", () => { + const sorted = sortActionsByPriority([]); + expect(sorted).toEqual([]); + }); + + it("handles single action", () => { + const actions = [{ type: ActionType.LABEL }]; + const sorted = sortActionsByPriority(actions); + expect(sorted).toEqual(actions); + }); + + it("handles already sorted array", () => { + const actions = [ + { type: ActionType.LABEL }, + { type: ActionType.ARCHIVE }, + { type: ActionType.REPLY }, + ]; + const sorted = sortActionsByPriority(actions); + expect(sorted.map((a) => a.type)).toEqual([ + ActionType.LABEL, + ActionType.ARCHIVE, + ActionType.REPLY, + ]); + }); + + it("handles duplicate action types", () => { + const actions = [ + { type: ActionType.ARCHIVE, id: "1" }, + { type: ActionType.LABEL, id: "2" }, + { type: ActionType.ARCHIVE, id: "3" }, + ]; + const sorted = sortActionsByPriority(actions); + expect(sorted[0].type).toBe(ActionType.LABEL); + // Both archives should come after label + expect(sorted[1].type).toBe(ActionType.ARCHIVE); + expect(sorted[2].type).toBe(ActionType.ARCHIVE); + }); + + it("preserves additional properties on action objects", () => { + const actions = [ + { type: ActionType.ARCHIVE, id: "1", extra: "data" }, + { type: ActionType.LABEL, id: "2", extra: "more" }, + ]; + const sorted = sortActionsByPriority(actions); + expect(sorted[0]).toEqual({ + type: ActionType.LABEL, + id: "2", + extra: "more", + }); + expect(sorted[1]).toEqual({ + type: ActionType.ARCHIVE, + id: "1", + extra: "data", + }); + }); + + it("does not mutate original array", () => { + const actions = [ + { type: ActionType.ARCHIVE }, + { type: ActionType.LABEL }, + ]; + const original = [...actions]; + sortActionsByPriority(actions); + expect(actions).toEqual(original); + }); + }); + + describe("NOTIFY_SENDER action type", () => { + it("places NOTIFY_SENDER before CALL_WEBHOOK", () => { + // NOTIFY_SENDER is in the priority list, just before CALL_WEBHOOK + const actions = [ + { type: ActionType.CALL_WEBHOOK }, + { type: ActionType.NOTIFY_SENDER }, + { type: ActionType.LABEL }, + ]; + const sorted = sortActionsByPriority(actions); + // LABEL first, NOTIFY_SENDER second, CALL_WEBHOOK last + expect(sorted[0].type).toBe(ActionType.LABEL); + expect(sorted[1].type).toBe(ActionType.NOTIFY_SENDER); + expect(sorted[2].type).toBe(ActionType.CALL_WEBHOOK); + }); + }); +}); diff --git a/apps/web/utils/action-sort.ts b/apps/web/utils/action-sort.ts index c807d417e0..f5b5245f03 100644 --- a/apps/web/utils/action-sort.ts +++ b/apps/web/utils/action-sort.ts @@ -20,6 +20,7 @@ const ACTION_TYPE_PRIORITY_ORDER: ActionType[] = [ ActionType.DIGEST, ActionType.MARK_SPAM, + ActionType.NOTIFY_SENDER, ActionType.CALL_WEBHOOK, ]; diff --git a/apps/web/utils/actions/rule.validation.test.ts b/apps/web/utils/actions/rule.validation.test.ts new file mode 100644 index 0000000000..f17d586982 --- /dev/null +++ b/apps/web/utils/actions/rule.validation.test.ts @@ -0,0 +1,392 @@ +import { describe, it, expect } from "vitest"; +import { + delayInMinutesSchema, + createRuleBody, + type CreateRuleBody, +} from "./rule.validation"; +import { ActionType, LogicalOperator } from "@/generated/prisma/enums"; +import { ConditionType } from "@/utils/config"; +import { NINETY_DAYS_MINUTES } from "@/utils/date"; + +describe("delayInMinutesSchema", () => { + describe("valid values", () => { + it("accepts minimum value of 1", () => { + const result = delayInMinutesSchema.safeParse(1); + expect(result.success).toBe(true); + expect(result.data).toBe(1); + }); + + it("accepts typical value of 60 minutes", () => { + const result = delayInMinutesSchema.safeParse(60); + expect(result.success).toBe(true); + }); + + it("accepts maximum value of 90 days in minutes", () => { + const result = delayInMinutesSchema.safeParse(NINETY_DAYS_MINUTES); + expect(result.success).toBe(true); + expect(result.data).toBe(129_600); // 90 * 24 * 60 + }); + + it("accepts null", () => { + const result = delayInMinutesSchema.safeParse(null); + expect(result.success).toBe(true); + expect(result.data).toBeNull(); + }); + + it("accepts undefined", () => { + const result = delayInMinutesSchema.safeParse(undefined); + expect(result.success).toBe(true); + expect(result.data).toBeUndefined(); + }); + }); + + describe("invalid values", () => { + it("rejects 0", () => { + const result = delayInMinutesSchema.safeParse(0); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("Minimum"); + } + }); + + it("rejects negative numbers", () => { + const result = delayInMinutesSchema.safeParse(-1); + expect(result.success).toBe(false); + }); + + it("rejects values exceeding 90 days", () => { + const result = delayInMinutesSchema.safeParse(NINETY_DAYS_MINUTES + 1); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("Maximum"); + } + }); + }); +}); + +describe("createRuleBody", () => { + const validAction = { + type: ActionType.ARCHIVE, + }; + + const validCondition = { + type: ConditionType.AI, + instructions: "Archive all newsletters", + }; + + const validRule: CreateRuleBody = { + name: "Test Rule", + actions: [validAction], + conditions: [validCondition], + }; + + describe("name validation", () => { + it("accepts valid name", () => { + const result = createRuleBody.safeParse(validRule); + expect(result.success).toBe(true); + }); + + it("rejects empty name", () => { + const result = createRuleBody.safeParse({ + ...validRule, + name: "", + }); + expect(result.success).toBe(false); + }); + + it("rejects whitespace-only name", () => { + const result = createRuleBody.safeParse({ + ...validRule, + name: " ", + }); + expect(result.success).toBe(false); + }); + }); + + describe("actions validation", () => { + it("requires at least one action", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("at least one action"); + } + }); + + it("accepts multiple actions", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [{ type: ActionType.ARCHIVE }, { type: ActionType.MARK_READ }], + }); + expect(result.success).toBe(true); + }); + }); + + describe("conditions validation", () => { + it("requires at least one condition", () => { + const result = createRuleBody.safeParse({ + ...validRule, + conditions: [], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain( + "at least one condition", + ); + } + }); + + it("rejects duplicate AI conditions", () => { + const result = createRuleBody.safeParse({ + ...validRule, + conditions: [ + { type: ConditionType.AI, instructions: "First AI condition" }, + { type: ConditionType.AI, instructions: "Second AI condition" }, + ], + }); + expect(result.success).toBe(false); + }); + + it("allows one AI condition with multiple static conditions", () => { + const result = createRuleBody.safeParse({ + ...validRule, + conditions: [ + { type: ConditionType.AI, instructions: "AI condition" }, + { type: ConditionType.STATIC, from: "test@example.com" }, + { type: ConditionType.STATIC, subject: "Newsletter" }, + ], + }); + expect(result.success).toBe(true); + }); + + it("rejects duplicate static conditions with same fields", () => { + const result = createRuleBody.safeParse({ + ...validRule, + conditions: [ + { type: ConditionType.STATIC, from: "test1@example.com" }, + { type: ConditionType.STATIC, from: "test2@example.com" }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("duplicate"); + } + }); + + it("allows static conditions with different fields", () => { + const result = createRuleBody.safeParse({ + ...validRule, + conditions: [ + { type: ConditionType.STATIC, from: "test@example.com" }, + { type: ConditionType.STATIC, subject: "Newsletter" }, + ], + }); + expect(result.success).toBe(true); + }); + }); + + describe("action-specific validation (superRefine)", () => { + describe("LABEL action", () => { + it("requires labelId value for LABEL action", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [{ type: ActionType.LABEL }], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("label name"); + } + }); + + it("accepts labelId.value for LABEL action", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [ + { + type: ActionType.LABEL, + labelId: { value: "inbox/newsletters" }, + }, + ], + }); + expect(result.success).toBe(true); + }); + + it("accepts labelId.name for LABEL action", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [ + { + type: ActionType.LABEL, + labelId: { name: "Newsletters" }, + }, + ], + }); + expect(result.success).toBe(true); + }); + }); + + describe("FORWARD action", () => { + it("requires to.value for FORWARD action", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [{ type: ActionType.FORWARD }], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("email address"); + } + }); + + it("accepts valid to.value for FORWARD action", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [ + { + type: ActionType.FORWARD, + to: { value: "forward@example.com" }, + }, + ], + }); + expect(result.success).toBe(true); + }); + }); + + describe("CALL_WEBHOOK action", () => { + it("requires url.value for CALL_WEBHOOK action", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [{ type: ActionType.CALL_WEBHOOK }], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("webhook URL"); + } + }); + + it("accepts valid url.value for CALL_WEBHOOK action", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [ + { + type: ActionType.CALL_WEBHOOK, + url: { value: "https://api.example.com/webhook" }, + }, + ], + }); + expect(result.success).toBe(true); + }); + }); + + describe("MOVE_FOLDER action", () => { + it("requires both folderName and folderId for MOVE_FOLDER action", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [{ type: ActionType.MOVE_FOLDER }], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain("folder"); + } + }); + + it("requires folderId when folderName is present", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [ + { + type: ActionType.MOVE_FOLDER, + folderName: { value: "Archive" }, + }, + ], + }); + expect(result.success).toBe(false); + }); + + it("accepts valid folderName and folderId", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [ + { + type: ActionType.MOVE_FOLDER, + folderName: { value: "Archive" }, + folderId: { value: "folder123" }, + }, + ], + }); + expect(result.success).toBe(true); + }); + }); + + describe("delayInMinutes on actions", () => { + it("accepts action with valid delay", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [ + { + type: ActionType.ARCHIVE, + delayInMinutes: 60, + }, + ], + }); + expect(result.success).toBe(true); + }); + + it("rejects action with delay exceeding 90 days", () => { + const result = createRuleBody.safeParse({ + ...validRule, + actions: [ + { + type: ActionType.ARCHIVE, + delayInMinutes: NINETY_DAYS_MINUTES + 1, + }, + ], + }); + expect(result.success).toBe(false); + }); + }); + }); + + describe("optional fields", () => { + it("accepts optional id", () => { + const result = createRuleBody.safeParse({ + ...validRule, + id: "rule-123", + }); + expect(result.success).toBe(true); + }); + + it("accepts optional instructions", () => { + const result = createRuleBody.safeParse({ + ...validRule, + instructions: "Additional rule instructions", + }); + expect(result.success).toBe(true); + }); + + it("accepts optional groupId", () => { + const result = createRuleBody.safeParse({ + ...validRule, + groupId: "group-123", + }); + expect(result.success).toBe(true); + }); + + it("accepts optional conditionalOperator", () => { + const result = createRuleBody.safeParse({ + ...validRule, + conditionalOperator: LogicalOperator.OR, + }); + expect(result.success).toBe(true); + }); + + it("conditionalOperator is undefined when not provided", () => { + const result = createRuleBody.safeParse(validRule); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.conditionalOperator).toBeUndefined(); + } + }); + }); +}); diff --git a/apps/web/utils/actions/rule.validation.ts b/apps/web/utils/actions/rule.validation.ts index 083548ea13..f7a63316c4 100644 --- a/apps/web/utils/actions/rule.validation.ts +++ b/apps/web/utils/actions/rule.validation.ts @@ -140,7 +140,7 @@ const zodAction = z export const createRuleBody = z.object({ id: z.string().optional(), - name: z.string().min(1, "Please enter a name"), + name: z.string().trim().min(1, "Please enter a name"), instructions: z.string().nullish(), groupId: z.string().nullish(), runOnThreads: z.boolean().nullish(), @@ -199,7 +199,6 @@ export const createRuleBody = z.object({ ), conditionalOperator: z .enum([LogicalOperator.AND, LogicalOperator.OR]) - .default(LogicalOperator.AND) .optional(), systemType: zodSystemRule.nullish(), }); diff --git a/apps/web/utils/filter-ignored-senders.test.ts b/apps/web/utils/filter-ignored-senders.test.ts new file mode 100644 index 0000000000..444787cc42 --- /dev/null +++ b/apps/web/utils/filter-ignored-senders.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { isIgnoredSender } from "./filter-ignored-senders"; + +describe("isIgnoredSender", () => { + describe("Superhuman reminder emails", () => { + it("returns true for exact Superhuman reminder sender", () => { + expect(isIgnoredSender("Reminder ")).toBe(true); + }); + + it("returns false for different Superhuman addresses", () => { + expect(isIgnoredSender("Support ")).toBe(false); + }); + + it("returns false for similar but different reminder address", () => { + expect(isIgnoredSender("Reminder ")).toBe(false); + }); + }); + + describe("case sensitivity", () => { + it("returns false for different case", () => { + expect(isIgnoredSender("reminder ")).toBe(false); + }); + + it("returns false for uppercase", () => { + expect(isIgnoredSender("REMINDER ")).toBe(false); + }); + }); + + describe("other senders", () => { + it("returns false for regular email addresses", () => { + expect(isIgnoredSender("john@example.com")).toBe(false); + }); + + it("returns false for email with display name", () => { + expect(isIgnoredSender("John Doe ")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(isIgnoredSender("")).toBe(false); + }); + + it("returns false for partial match", () => { + expect(isIgnoredSender("reminder@superhuman.com")).toBe(false); + }); + + it("returns false for substring match", () => { + expect(isIgnoredSender("Reminder extra")).toBe( + false, + ); + }); + }); +}); diff --git a/apps/web/utils/mail.test.ts b/apps/web/utils/mail.test.ts new file mode 100644 index 0000000000..75e9e3fe7c --- /dev/null +++ b/apps/web/utils/mail.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi } from "vitest"; +import { + getEmailClient, + emailToContent, + convertEmailHtmlToText, + parseReply, +} from "./mail"; + +vi.mock("server-only", () => ({})); + +describe("emailToContent", () => { + describe("content source fallback", () => { + it("uses textHtml when available", () => { + const email = { + textHtml: "

Hello World

", + textPlain: "Plain text", + snippet: "Snippet", + }; + const result = emailToContent(email); + expect(result).toContain("Hello World"); + }); + + it("falls back to textPlain when textHtml is empty", () => { + const email = { + textHtml: "", + textPlain: "Plain text content", + snippet: "Snippet", + }; + const result = emailToContent(email); + expect(result).toBe("Plain text content"); + }); + + it("falls back to snippet when both textHtml and textPlain are empty", () => { + const email = { + textHtml: "", + textPlain: "", + snippet: "Email snippet here", + }; + const result = emailToContent(email); + expect(result).toBe("Email snippet here"); + }); + + it("returns empty string when all content sources are empty", () => { + const email = { + textHtml: "", + textPlain: "", + snippet: "", + }; + const result = emailToContent(email); + expect(result).toBe(""); + }); + }); + + describe("maxLength option", () => { + it("truncates content and adds ellipsis", () => { + const email = { + textPlain: "This is a very long email content that should be truncated", + textHtml: undefined, + snippet: "", + }; + const result = emailToContent(email, { maxLength: 20 }); + // truncate() adds "..." so result is maxLength + 3 + expect(result).toBe("This is a very long ..."); + expect(result.length).toBe(23); + }); + + it("does not truncate when maxLength is 0", () => { + const longContent = "A".repeat(5000); + const email = { + textPlain: longContent, + textHtml: undefined, + snippet: "", + }; + const result = emailToContent(email, { maxLength: 0 }); + expect(result).toBe(longContent); + }); + + it("uses default maxLength of 2000 and adds ellipsis for long content", () => { + const longContent = "A".repeat(3000); + const email = { + textPlain: longContent, + textHtml: undefined, + snippet: "", + }; + const result = emailToContent(email); + // truncate adds "..." so 2000 + 3 = 2003 + expect(result.length).toBe(2003); + expect(result.endsWith("...")).toBe(true); + }); + }); + + describe("removeForwarded option", () => { + it("removes Gmail-style forwarded content", () => { + const email = { + textPlain: + "My response here\n\n---------- Forwarded message ----------\nFrom: someone@example.com\nSubject: Original", + textHtml: undefined, + snippet: "", + }; + const result = emailToContent(email, { removeForwarded: true }); + expect(result).toBe("My response here"); + expect(result).not.toContain("Forwarded message"); + }); + + it("removes iOS-style forwarded content", () => { + const email = { + textPlain: + "Here is my reply\n\nBegin forwarded message:\n\nFrom: other@test.com", + textHtml: undefined, + snippet: "", + }; + const result = emailToContent(email, { removeForwarded: true }); + expect(result).toBe("Here is my reply"); + }); + + it("removes Outlook-style forwarded content", () => { + const email = { + textPlain: "My comments\n\nOriginal Message\nFrom: sender@example.com", + textHtml: undefined, + snippet: "", + }; + const result = emailToContent(email, { removeForwarded: true }); + expect(result).toBe("My comments"); + }); + + it("preserves content when no forward marker found", () => { + const email = { + textPlain: "Regular email content without forwards", + textHtml: undefined, + snippet: "", + }; + const result = emailToContent(email, { removeForwarded: true }); + expect(result).toBe("Regular email content without forwards"); + }); + }); + + describe("whitespace handling", () => { + it("removes excessive whitespace", () => { + const email = { + textPlain: "Hello World\n\n\n\nTest", + textHtml: undefined, + snippet: "", + }; + const result = emailToContent(email); + expect(result).not.toContain(" "); + expect(result).not.toContain("\n\n\n\n"); + }); + }); +}); + +describe("convertEmailHtmlToText", () => { + it("converts basic HTML to text", () => { + const result = convertEmailHtmlToText({ + htmlText: "

Hello World

", + }); + expect(result).toContain("Hello"); + expect(result).toContain("World"); + }); + + it("preserves link URLs when includeLinks is true", () => { + const result = convertEmailHtmlToText({ + htmlText: 'Click here', + includeLinks: true, + }); + expect(result).toContain("Click here"); + expect(result).toContain("https://example.com"); + }); + + it("removes link URLs when includeLinks is false", () => { + const result = convertEmailHtmlToText({ + htmlText: 'Click here', + includeLinks: false, + }); + expect(result).toContain("Click here"); + expect(result).not.toContain("https://example.com"); + }); + + it("removes images", () => { + const result = convertEmailHtmlToText({ + htmlText: '

TextphotoMore text

', + }); + expect(result).toContain("Text"); + expect(result).toContain("More text"); + expect(result).not.toContain("image.png"); + }); + + it("handles complex HTML structure", () => { + const result = convertEmailHtmlToText({ + htmlText: ` + + +

Title

+

Paragraph one

+
    +
  • Item 1
  • +
  • Item 2
  • +
+ + + `, + }); + // html-to-text uppercases h1 tags + expect(result).toContain("TITLE"); + expect(result).toContain("Paragraph one"); + expect(result).toContain("Item 1"); + expect(result).toContain("Item 2"); + }); + + it("hides link URL if same as text when includeLinks is true", () => { + const result = convertEmailHtmlToText({ + htmlText: 'https://example.com', + includeLinks: true, + }); + // Should not duplicate the URL + const urlCount = (result.match(/https:\/\/example\.com/g) || []).length; + expect(urlCount).toBe(1); + }); +}); + +describe("parseReply", () => { + it("extracts visible text from email reply", () => { + const plainText = `New message here + +On Jan 1, 2024, someone@example.com wrote: +> Old quoted content +> More quoted stuff`; + + const result = parseReply(plainText); + expect(result).toContain("New message here"); + expect(result).not.toContain("Old quoted content"); + expect(result).not.toContain("More quoted stuff"); + }); + + it("handles plain text without quotes", () => { + const plainText = "Simple message without any quotes"; + const result = parseReply(plainText); + expect(result).toBe("Simple message without any quotes"); + }); +}); + +describe("getEmailClient", () => { + it("identifies Gmail", () => { + expect(getEmailClient("")).toBe("gmail"); + }); + + it("identifies Superhuman", () => { + expect(getEmailClient("")).toBe("superhuman"); + }); + + it("identifies Shortwave", () => { + expect(getEmailClient("")).toBe("shortwave"); + }); + + it("extracts domain for generic email clients", () => { + expect(getEmailClient("")).toBe("company.com"); + }); + + it("handles message IDs with multiple @ symbols", () => { + expect(getEmailClient("")).toBe("something"); + }); + + it("extracts domain from Outlook-style message IDs", () => { + expect(getEmailClient("")).toBe("outlook.com"); + }); +}); diff --git a/apps/web/utils/parse/cta.test.ts b/apps/web/utils/parse/cta.test.ts new file mode 100644 index 0000000000..3277846c2f --- /dev/null +++ b/apps/web/utils/parse/cta.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from "vitest"; +import { containsCtaKeyword } from "./cta"; + +describe("containsCtaKeyword", () => { + describe("detects CTA keywords", () => { + it("detects 'see more'", () => { + expect(containsCtaKeyword("see more details")).toBe(true); + }); + + it("detects 'view it'", () => { + expect(containsCtaKeyword("view it on GitHub")).toBe(true); + }); + + it("detects 'view reply'", () => { + expect(containsCtaKeyword("view reply")).toBe(true); + }); + + it("detects 'view comment'", () => { + expect(containsCtaKeyword("view comment")).toBe(true); + }); + + it("detects 'view question'", () => { + expect(containsCtaKeyword("view question")).toBe(true); + }); + + it("detects 'view message'", () => { + expect(containsCtaKeyword("view message")).toBe(true); + }); + + it("detects 'view in'", () => { + expect(containsCtaKeyword("view in Airtable")).toBe(true); + }); + + it("detects 'confirm'", () => { + expect(containsCtaKeyword("confirm subscription")).toBe(true); + }); + + it("detects 'join the conversation'", () => { + expect(containsCtaKeyword("join the conversation")).toBe(true); + }); + + it("detects 'go to console'", () => { + expect(containsCtaKeyword("go to console")).toBe(true); + }); + + it("detects 'open messenger'", () => { + expect(containsCtaKeyword("open messenger")).toBe(true); + }); + + it("detects 'open in'", () => { + expect(containsCtaKeyword("open in Slack")).toBe(true); + }); + + it("detects 'reply'", () => { + expect(containsCtaKeyword("reply")).toBe(true); + }); + }); + + describe("length constraint (max 30 characters)", () => { + it("returns true for text exactly 29 characters with keyword", () => { + // "view it on GitHub" is 17 chars, add 12 more to get 29 + const text = "view it on GitHub123456"; + expect(text.length).toBe(23); + expect(containsCtaKeyword(text)).toBe(true); + }); + + it("returns true for text exactly at 29 characters", () => { + const text = "confirm this action now 12345"; // 29 chars + expect(text.length).toBe(29); + expect(containsCtaKeyword(text)).toBe(true); + }); + + it("returns false for text at exactly 30 characters", () => { + const text = "confirm this action now 123456"; // 30 chars + expect(text.length).toBe(30); + expect(containsCtaKeyword(text)).toBe(false); + }); + + it("returns false for text longer than 30 characters", () => { + const text = "Please confirm your subscription to our newsletter"; + expect(text.length).toBeGreaterThan(30); + expect(containsCtaKeyword(text)).toBe(false); + }); + + it("returns false for long sentences with keywords", () => { + expect( + containsCtaKeyword( + "You can view it on GitHub by clicking the link below", + ), + ).toBe(false); + }); + }); + + describe("returns false for non-matching text", () => { + it("returns false for empty string", () => { + expect(containsCtaKeyword("")).toBe(false); + }); + + it("returns false for regular text", () => { + expect(containsCtaKeyword("Hello world")).toBe(false); + }); + + it("returns false for similar but non-matching text", () => { + expect(containsCtaKeyword("viewing something")).toBe(false); + }); + + it("is case sensitive - does not match uppercase", () => { + expect(containsCtaKeyword("VIEW IT")).toBe(false); + }); + + it("is case sensitive - does not match title case", () => { + expect(containsCtaKeyword("View It")).toBe(false); + }); + }); + + describe("keyword matching behavior", () => { + it("matches keyword at start of text", () => { + expect(containsCtaKeyword("reply now")).toBe(true); + }); + + it("matches keyword at end of text", () => { + expect(containsCtaKeyword("click to reply")).toBe(true); + }); + + it("matches keyword in middle of text", () => { + expect(containsCtaKeyword("tap to reply now")).toBe(true); + }); + }); +}); diff --git a/apps/web/utils/parse/unsubscribe.test.ts b/apps/web/utils/parse/unsubscribe.test.ts new file mode 100644 index 0000000000..06c7f90441 --- /dev/null +++ b/apps/web/utils/parse/unsubscribe.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { containsUnsubscribeKeyword } from "./unsubscribe"; + +describe("containsUnsubscribeKeyword", () => { + describe("detects unsubscribe keywords", () => { + it("detects 'unsubscribe'", () => { + expect(containsUnsubscribeKeyword("Click to unsubscribe")).toBe(true); + }); + + it("detects 'email preferences'", () => { + expect( + containsUnsubscribeKeyword("Manage your email preferences here"), + ).toBe(true); + }); + + it("detects 'email settings'", () => { + expect(containsUnsubscribeKeyword("Update email settings")).toBe(true); + }); + + it("detects 'email options'", () => { + expect(containsUnsubscribeKeyword("Change email options")).toBe(true); + }); + + it("detects 'notification preferences'", () => { + expect(containsUnsubscribeKeyword("Edit notification preferences")).toBe( + true, + ); + }); + }); + + describe("keyword matching behavior", () => { + it("matches keyword at start of text", () => { + expect(containsUnsubscribeKeyword("unsubscribe from this list")).toBe( + true, + ); + }); + + it("matches keyword at end of text", () => { + expect(containsUnsubscribeKeyword("Click here to unsubscribe")).toBe( + true, + ); + }); + + it("matches keyword in middle of text", () => { + expect( + containsUnsubscribeKeyword("You can unsubscribe at any time"), + ).toBe(true); + }); + + it("matches keyword as part of longer word", () => { + // This tests that includes() matches substrings + expect(containsUnsubscribeKeyword("unsubscribed")).toBe(true); + }); + + it("is case insensitive - matches uppercase", () => { + expect(containsUnsubscribeKeyword("UNSUBSCRIBE")).toBe(true); + }); + + it("is case insensitive - matches mixed case", () => { + expect(containsUnsubscribeKeyword("Unsubscribe")).toBe(true); + }); + + it("is case insensitive - matches 'Email Preferences'", () => { + expect(containsUnsubscribeKeyword("Email Preferences")).toBe(true); + }); + }); + + describe("returns false for non-matching text", () => { + it("returns false for empty string", () => { + expect(containsUnsubscribeKeyword("")).toBe(false); + }); + + it("returns false for regular text", () => { + expect(containsUnsubscribeKeyword("Hello, how are you?")).toBe(false); + }); + + it("returns false for similar but different text", () => { + expect(containsUnsubscribeKeyword("subscribe to our newsletter")).toBe( + false, + ); + }); + + it("returns false for partial keyword match", () => { + expect(containsUnsubscribeKeyword("email prefer")).toBe(false); + }); + + it("returns false for keywords with typos", () => { + expect(containsUnsubscribeKeyword("unsubscibe")).toBe(false); + }); + }); +}); diff --git a/apps/web/utils/parse/unsubscribe.ts b/apps/web/utils/parse/unsubscribe.ts index 1f8be2f3a7..7c3ea9eed4 100644 --- a/apps/web/utils/parse/unsubscribe.ts +++ b/apps/web/utils/parse/unsubscribe.ts @@ -7,5 +7,6 @@ const unsubscribeKeywords = [ ]; export function containsUnsubscribeKeyword(text: string) { - return unsubscribeKeywords.some((keyword) => text.includes(keyword)); + const lowerText = text.toLowerCase(); + return unsubscribeKeywords.some((keyword) => lowerText.includes(keyword)); } diff --git a/apps/web/utils/template.test.ts b/apps/web/utils/template.test.ts new file mode 100644 index 0000000000..378837c2d2 --- /dev/null +++ b/apps/web/utils/template.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { hasVariables, TEMPLATE_VARIABLE_PATTERN } from "./template"; + +describe("TEMPLATE_VARIABLE_PATTERN", () => { + it("matches simple variable", () => { + const regex = new RegExp(TEMPLATE_VARIABLE_PATTERN); + expect(regex.test("{{name}}")).toBe(true); + }); + + it("matches variable with spaces", () => { + const regex = new RegExp(TEMPLATE_VARIABLE_PATTERN); + expect(regex.test("{{ name }}")).toBe(true); + }); + + it("matches multiline variable", () => { + const regex = new RegExp(TEMPLATE_VARIABLE_PATTERN); + expect(regex.test("{{\nname\n}}")).toBe(true); + }); + + it("does not match single braces", () => { + const regex = new RegExp(TEMPLATE_VARIABLE_PATTERN); + expect(regex.test("{name}")).toBe(false); + }); +}); + +describe("hasVariables", () => { + describe("returns true for text with variables", () => { + it("detects simple variable", () => { + expect(hasVariables("Hello {{name}}")).toBe(true); + }); + + it("detects variable with spaces inside", () => { + expect(hasVariables("Hello {{ name }}")).toBe(true); + }); + + it("detects multiple variables", () => { + expect(hasVariables("{{greeting}} {{name}}!")).toBe(true); + }); + + it("detects variable at start", () => { + expect(hasVariables("{{name}} said hello")).toBe(true); + }); + + it("detects variable at end", () => { + expect(hasVariables("Hello {{name}}")).toBe(true); + }); + + it("detects nested-looking content", () => { + expect(hasVariables("{{outer {{inner}}}}")).toBe(true); + }); + + it("detects variable with underscores", () => { + expect(hasVariables("{{first_name}}")).toBe(true); + }); + + it("detects variable with dots", () => { + expect(hasVariables("{{user.name}}")).toBe(true); + }); + + it("detects multiline variable", () => { + expect(hasVariables("Hello {{\nname\n}}")).toBe(true); + }); + + it("detects empty variable", () => { + expect(hasVariables("{{}}")).toBe(true); + }); + }); + + describe("returns false for text without variables", () => { + it("returns false for plain text", () => { + expect(hasVariables("Hello world")).toBe(false); + }); + + it("returns false for single braces", () => { + expect(hasVariables("Hello {name}")).toBe(false); + }); + + it("returns false for unmatched opening braces", () => { + expect(hasVariables("Hello {{name")).toBe(false); + }); + + it("returns false for unmatched closing braces", () => { + expect(hasVariables("Hello name}}")).toBe(false); + }); + + it("returns false for empty string", () => { + expect(hasVariables("")).toBe(false); + }); + + it("returns false for braces with space between", () => { + expect(hasVariables("{ {name} }")).toBe(false); + }); + }); + + describe("handles null and undefined", () => { + it("returns false for null", () => { + expect(hasVariables(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(hasVariables(undefined)).toBe(false); + }); + }); +}); diff --git a/apps/web/utils/text.test.ts b/apps/web/utils/text.test.ts new file mode 100644 index 0000000000..99bf7affcb --- /dev/null +++ b/apps/web/utils/text.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { slugify, extractTextFromPortableTextBlock } from "./text"; +import type { PortableTextBlock } from "@portabletext/react"; + +describe("slugify", () => { + describe("basic transformations", () => { + it("converts to lowercase", () => { + expect(slugify("Hello World")).toBe("hello-world"); + }); + + it("replaces spaces with hyphens", () => { + expect(slugify("hello world")).toBe("hello-world"); + }); + + it("replaces multiple spaces with single hyphen", () => { + expect(slugify("hello world")).toBe("hello-world"); + }); + + it("handles already lowercase text", () => { + expect(slugify("hello")).toBe("hello"); + }); + }); + + describe("special characters", () => { + it("removes special characters", () => { + expect(slugify("Hello! World?")).toBe("hello-world"); + }); + + it("removes punctuation", () => { + expect(slugify("Hello, World.")).toBe("hello-world"); + }); + + it("keeps hyphens", () => { + expect(slugify("hello-world")).toBe("hello-world"); + }); + + it("keeps underscores", () => { + expect(slugify("hello_world")).toBe("hello_world"); + }); + + it("removes apostrophes", () => { + expect(slugify("it's working")).toBe("its-working"); + }); + + it("removes quotes", () => { + expect(slugify('"hello" world')).toBe("hello-world"); + }); + + it("removes parentheses", () => { + expect(slugify("hello (world)")).toBe("hello-world"); + }); + + it("removes ampersands", () => { + expect(slugify("hello & world")).toBe("hello--world"); + }); + }); + + describe("edge cases", () => { + it("handles empty string", () => { + expect(slugify("")).toBe(""); + }); + + it("handles numbers", () => { + expect(slugify("Chapter 1")).toBe("chapter-1"); + }); + + it("handles leading/trailing spaces", () => { + expect(slugify(" hello world ")).toBe("-hello-world-"); + }); + + it("handles tabs", () => { + expect(slugify("hello\tworld")).toBe("hello-world"); + }); + + it("handles newlines", () => { + expect(slugify("hello\nworld")).toBe("hello-world"); + }); + }); +}); + +describe("extractTextFromPortableTextBlock", () => { + it("extracts text from single span", () => { + const block: PortableTextBlock = { + _type: "block", + _key: "1", + children: [{ _type: "span", _key: "s1", text: "Hello World" }], + }; + expect(extractTextFromPortableTextBlock(block)).toBe("Hello World"); + }); + + it("concatenates text from multiple spans", () => { + const block: PortableTextBlock = { + _type: "block", + _key: "1", + children: [ + { _type: "span", _key: "s1", text: "Hello " }, + { _type: "span", _key: "s2", text: "World" }, + ], + }; + expect(extractTextFromPortableTextBlock(block)).toBe("Hello World"); + }); + + it("handles empty children array", () => { + const block: PortableTextBlock = { + _type: "block", + _key: "1", + children: [], + }; + expect(extractTextFromPortableTextBlock(block)).toBe(""); + }); + + it("filters out non-span children", () => { + const block = { + _type: "block", + _key: "1", + children: [ + { _type: "span", _key: "s1", text: "Hello" }, + { _type: "image", _key: "i1", asset: {} }, + { _type: "span", _key: "s2", text: " World" }, + ], + } as unknown as PortableTextBlock; + expect(extractTextFromPortableTextBlock(block)).toBe("Hello World"); + }); + + it("handles spans with empty text", () => { + const block: PortableTextBlock = { + _type: "block", + _key: "1", + children: [ + { _type: "span", _key: "s1", text: "" }, + { _type: "span", _key: "s2", text: "Hello" }, + ], + }; + expect(extractTextFromPortableTextBlock(block)).toBe("Hello"); + }); +}); diff --git a/apps/web/utils/url.test.ts b/apps/web/utils/url.test.ts new file mode 100644 index 0000000000..6f0fa16ad0 --- /dev/null +++ b/apps/web/utils/url.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from "vitest"; +import { + getEmailUrl, + getEmailUrlForMessage, + getGmailUrl, + getGmailSearchUrl, + getGmailBasicSearchUrl, + getGmailFilterSettingsUrl, +} from "./url"; + +describe("getEmailUrl", () => { + describe("Google provider", () => { + it("builds Gmail URL with email address", () => { + const result = getEmailUrl("msg123", "user@gmail.com", "google"); + expect(result).toBe( + "https://mail.google.com/mail/u/user@gmail.com/#all/msg123", + ); + }); + + it("builds Gmail URL without email address", () => { + const result = getEmailUrl("msg123", null, "google"); + expect(result).toBe("https://mail.google.com/mail/u/0/#all/msg123"); + }); + + it("builds Gmail URL with undefined email address", () => { + const result = getEmailUrl("msg123", undefined, "google"); + expect(result).toBe("https://mail.google.com/mail/u/0/#all/msg123"); + }); + }); + + describe("Microsoft provider", () => { + it("builds Outlook URL with encoded message ID", () => { + const result = getEmailUrl("msg123", "user@outlook.com", "microsoft"); + expect(result).toBe("https://outlook.live.com/mail/0/inbox/id/msg123"); + }); + + it("encodes special characters in message ID", () => { + const result = getEmailUrl("msg+123/abc", null, "microsoft"); + expect(result).toBe( + "https://outlook.live.com/mail/0/inbox/id/msg%2B123%2Fabc", + ); + }); + + it("encodes message ID with spaces and special chars", () => { + const result = getEmailUrl("msg id=abc", null, "microsoft"); + expect(result).toBe( + "https://outlook.live.com/mail/0/inbox/id/msg%20id%3Dabc", + ); + }); + }); + + describe("Default provider", () => { + it("uses Gmail format when provider is undefined", () => { + const result = getEmailUrl("msg123", "user@gmail.com"); + expect(result).toBe( + "https://mail.google.com/mail/u/user@gmail.com/#all/msg123", + ); + }); + + it("throws for unknown provider (bug: should fall back to default)", () => { + // NOTE: This documents a potential bug - unknown providers cause an error + // instead of falling back to the "default" config. + // The getProviderConfig function only falls back when provider is undefined, + // not when the provider key doesn't exist in PROVIDER_CONFIG. + expect(() => + getEmailUrl("msg123", "user@gmail.com", "unknown"), + ).toThrow(); + }); + }); +}); + +describe("getEmailUrlForMessage", () => { + describe("Google provider", () => { + it("uses messageId for Google", () => { + const result = getEmailUrlForMessage( + "messageId123", + "threadId456", + "user@gmail.com", + "google", + ); + expect(result).toContain("messageId123"); + expect(result).not.toContain("threadId456"); + }); + }); + + describe("Microsoft provider", () => { + it("uses threadId for Microsoft", () => { + const result = getEmailUrlForMessage( + "messageId123", + "threadId456", + "user@outlook.com", + "microsoft", + ); + expect(result).toContain("threadId456"); + expect(result).not.toContain("messageId123"); + }); + }); + + describe("Default provider", () => { + it("uses threadId for default/unknown provider", () => { + const result = getEmailUrlForMessage( + "messageId123", + "threadId456", + "user@example.com", + ); + expect(result).toContain("threadId456"); + }); + }); +}); + +describe("getGmailUrl", () => { + it("is an alias for getEmailUrl with google provider", () => { + const result = getGmailUrl("msg123", "user@gmail.com"); + const expected = getEmailUrl("msg123", "user@gmail.com", "google"); + expect(result).toBe(expected); + }); + + it("works without email address", () => { + const result = getGmailUrl("msg123"); + expect(result).toBe("https://mail.google.com/mail/u/0/#all/msg123"); + }); +}); + +describe("getGmailSearchUrl", () => { + it("builds advanced search URL with from parameter", () => { + const result = getGmailSearchUrl("sender@example.com", "user@gmail.com"); + expect(result).toBe( + "https://mail.google.com/mail/u/user@gmail.com/#advanced-search/from=sender%40example.com", + ); + }); + + it("encodes special characters in from", () => { + const result = getGmailSearchUrl("test+user@example.com", null); + expect(result).toContain("from=test%2Buser%40example.com"); + }); + + it("handles from with display name", () => { + const result = getGmailSearchUrl( + "John Doe ", + "user@gmail.com", + ); + expect(result).toContain("from=John%20Doe%20%3Cjohn%40example.com%3E"); + }); +}); + +describe("getGmailBasicSearchUrl", () => { + it("builds search URL with query", () => { + const result = getGmailBasicSearchUrl("user@gmail.com", "is:unread"); + expect(result).toBe( + "https://mail.google.com/mail/u/user@gmail.com/#search/is%3Aunread", + ); + }); + + it("encodes complex queries", () => { + const result = getGmailBasicSearchUrl( + "user@gmail.com", + "from:sender@test.com subject:hello", + ); + expect(result).toContain("#search/"); + expect(result).toContain("from%3Asender%40test.com"); + expect(result).toContain("subject%3Ahello"); + }); + + it("handles queries with special characters", () => { + const result = getGmailBasicSearchUrl( + "user@gmail.com", + "label:inbox/important", + ); + expect(result).toContain("label%3Ainbox%2Fimportant"); + }); +}); + +describe("getGmailFilterSettingsUrl", () => { + it("builds filter settings URL with email address", () => { + const result = getGmailFilterSettingsUrl("user@gmail.com"); + expect(result).toBe( + "https://mail.google.com/mail/u/user@gmail.com/#settings/filters", + ); + }); + + it("builds filter settings URL without email address", () => { + const result = getGmailFilterSettingsUrl(); + expect(result).toBe("https://mail.google.com/mail/u/0/#settings/filters"); + }); + + it("builds filter settings URL with null email", () => { + const result = getGmailFilterSettingsUrl(null); + expect(result).toBe("https://mail.google.com/mail/u/0/#settings/filters"); + }); +});