diff --git a/src/types/templates.ts b/src/types/templates.ts index c76f5d6..0713b2a 100644 --- a/src/types/templates.ts +++ b/src/types/templates.ts @@ -42,92 +42,90 @@ const BaseTemplateSchema = z.object({ updatedAt: UnixTimestampSchema.optional().describe("Date last updated"), }); -export const EmailTemplateSchema = BaseTemplateSchema.extend({ +const campaignDataFieldsSchema = z + .record(z.string(), z.any()) + .optional() + .describe( + "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." + ); + +// Content fields for each template type, shared between response and param schemas +const EmailContentFields = { + subject: z.string().optional().describe("Subject"), + preheaderText: z.string().optional().describe("Preheader text"), + fromName: z.string().optional().describe("From name"), + fromEmail: z + .string() + .optional() + .describe("From email (must be an authorized sender)"), + replyToEmail: z.string().optional().describe("Reply to email"), + ccEmails: z.array(z.string()).optional().describe("CC emails"), bccEmails: z.array(z.string()).optional().describe("BCC emails"), + html: z.string().optional().describe("HTML contents"), + plainText: z.string().optional().describe("Plain text contents"), cacheDataFeed: z .boolean() .optional() .describe("Cache data feed lookups for 1 hour"), - campaignDataFields: z - .record(z.string(), z.any()) + dataFeedIds: z + .array(z.number()) .optional() - .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." - ), - ccEmails: z.array(z.string()).optional().describe("CC emails"), + .describe("Ids for data feeds used in template rendering"), dataFeedId: z .number() .optional() .describe( - "[Deprecated - use dataFeedIds instead] Id for data feed used in template rendering" + "[Deprecated - use dataFeedIds instead] ID for data feed used in template rendering" ), - dataFeedIds: z - .array(z.number()) - .optional() - .describe("Ids for data feeds used in template rendering"), - fromEmail: z - .string() + mergeDataFeedContext: z + .boolean() .optional() - .describe("From email (must be an authorized sender)"), - fromName: z.string().optional().describe("From name"), + .describe( + "Merge data feed contents into user context, so fields can be referenced by {{field}} instead of [[field]]" + ), googleAnalyticsCampaignName: z .string() .optional() .describe("Google analytics utm_campaign value"), - html: z.string().optional().describe("HTML contents"), linkParams: z .array(z.any()) .optional() .describe("Parameters to append to each URL in html contents"), - mergeDataFeedContext: z - .boolean() - .optional() - .describe( - "Merge data feed contents into user context, so fields be referenced by {{field}} instead of [[field]]" - ), - metadata: z.any().optional().describe("Metadata"), - plainText: z.string().optional().describe("Plain text contents"), - preheaderText: z.string().optional().describe("Preheader text"), - replyToEmail: z.string().optional().describe("Reply to email"), - subject: z.string().optional().describe("Subject"), -}); + campaignDataFields: campaignDataFieldsSchema, +}; -export const SMSTemplateSchema = BaseTemplateSchema.extend({ - campaignDataFields: z - .record(z.string(), z.any()) - .optional() - .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." - ), +const SMSContentFields = { + message: z.string().optional().describe("SMS message"), + imageUrl: z.string().optional().describe("Image URL"), googleAnalyticsCampaignName: z .string() .optional() .describe("Google analytics utm_campaign value"), - imageUrl: z.string().optional().describe("Image Url"), linkParams: z .array(z.any()) .optional() .describe("Parameters to append to each URL in contents"), - message: z.string().optional().describe("SMS message"), trackingDomain: z.string().optional().describe("Tracking Domain"), -}); + campaignDataFields: campaignDataFieldsSchema, +}; -export const PushTemplateSchema = BaseTemplateSchema.extend({ +const PushContentFields = { + message: z.string().optional().describe("Push message"), + title: z.string().optional().describe("Push message title"), badge: z.string().optional().describe("Badge to set for push notification"), buttons: z .array(z.any()) .optional() .describe("Array of buttons that appear to respond to the push. Max of 3"), + sound: z.string().optional().describe("Sound"), + payload: z + .record(z.string(), z.any()) + .optional() + .describe("Payload to send with push notification"), cacheDataFeed: z .boolean() .optional() .describe("Cache data feed lookups for 1 hour"), - campaignDataFields: z - .record(z.string(), z.any()) - .optional() - .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." - ), dataFeedIds: z .array(z.number()) .optional() @@ -139,7 +137,7 @@ export const PushTemplateSchema = BaseTemplateSchema.extend({ "Deep Link. A mapping that accepts two optional properties: 'ios' & 'android' and their respective deep link values" ), interruptionLevel: z - .string() + .enum(["passive", "active", "time-sensitive", "critical"]) .optional() .describe( "An interruption level helps iOS determine when to alert a user about the arrival of a push notification" @@ -154,11 +152,6 @@ export const PushTemplateSchema = BaseTemplateSchema.extend({ .describe( "Merge data feed contents into user context, so fields can be referenced by {{field}} instead of [[field]]" ), - message: z.string().optional().describe("Push message"), - payload: z - .record(z.string(), z.any()) - .optional() - .describe("Payload to send with push notification"), relevanceScore: z .number() .optional() @@ -171,23 +164,17 @@ export const PushTemplateSchema = BaseTemplateSchema.extend({ .describe( "Rich Media URL. A mapping that accepts two optional properties: 'ios' & 'android' and their respective rich media url values" ), - sound: z.string().optional().describe("Sound"), - title: z.string().optional().describe("Push message title"), wake: z .boolean() .optional() .describe( "Set the content-available flag on iOS notifications, which will wake the app in the background" ), -}); + campaignDataFields: campaignDataFieldsSchema, +}; -export const InAppTemplateSchema = BaseTemplateSchema.extend({ - campaignDataFields: z - .record(z.string(), z.any()) - .optional() - .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." - ), +const InAppContentFields = { + html: z.string().optional().describe("Html of the in-app notification"), expirationDateTime: z .string() .optional() @@ -200,7 +187,6 @@ export const InAppTemplateSchema = BaseTemplateSchema.extend({ .describe( "The in-app message's expiration time, relative to its send time. Should be an expression such as now+90d" ), - html: z.string().optional().describe("Html of the in-app notification"), inAppDisplaySettings: z .record(z.string(), z.any()) .optional() @@ -214,8 +200,21 @@ export const InAppTemplateSchema = BaseTemplateSchema.extend({ .record(z.string(), z.any()) .optional() .describe("Web In-app Display settings"), + campaignDataFields: campaignDataFieldsSchema, +}; + +export const EmailTemplateSchema = BaseTemplateSchema.extend({ + ...EmailContentFields, + metadata: z.any().optional().describe("Metadata"), }); +export const SMSTemplateSchema = BaseTemplateSchema.extend(SMSContentFields); + +export const PushTemplateSchema = BaseTemplateSchema.extend(PushContentFields); + +export const InAppTemplateSchema = + BaseTemplateSchema.extend(InAppContentFields); + export type EmailTemplate = z.infer; export type SMSTemplate = z.infer; export type PushTemplate = z.infer; @@ -348,6 +347,12 @@ export type GetTemplateByClientIdResponse = z.infer< const BaseTemplateParamsSchema = z.object({ name: z.string().optional().describe("Template name"), locale: z.string().optional().describe("Template locale"), + isDefaultLocale: z + .boolean() + .optional() + .describe( + "Sets the locale associated with the request content as the template's default" + ), messageTypeId: z.number().optional().describe("Message type ID"), creatorUserId: z.string().optional().describe("Creator user ID"), campaignId: z.number().optional().describe("Associated campaign ID"), @@ -363,58 +368,6 @@ const UpdateTemplateParamsSchema = BaseTemplateParamsSchema.extend({ templateId: z.number().describe("Template ID to update"), }); -// Content field objects for each template type -const EmailContentFields = { - subject: z.string().optional().describe("Email subject"), - fromName: z.string().optional().describe("From name"), - fromEmail: z.email().optional().describe("From email"), - html: z.string().optional().describe("HTML content"), - plainText: z.string().optional().describe("Plain text content"), - campaignDataFields: z - .record(z.string(), z.any()) - .optional() - .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." - ), -}; - -const SMSContentFields = { - message: z.string().optional().describe("SMS message content"), - campaignDataFields: z - .record(z.string(), z.any()) - .optional() - .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." - ), -}; - -const PushContentFields = { - message: z.string().optional().describe("Push notification message"), - title: z.string().optional().describe("Push notification title"), - badge: z.number().optional().describe("Badge count"), - sound: z.string().optional().describe("Sound file"), - payload: z.record(z.string(), z.any()).optional().describe("Custom payload"), - campaignDataFields: z - .record(z.string(), z.any()) - .optional() - .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." - ), -}; - -const InAppContentFields = { - html: z - .string() - .optional() - .describe("HTML content of the in-app notification"), - campaignDataFields: z - .record(z.string(), z.any()) - .optional() - .describe( - "Campaign-level data fields available as {{field}} merge parameters during message rendering. These fields are overridden by user and event data fields of the same name." - ), -}; - // Email template upsert (create or update by clientTemplateId) export const UpsertEmailTemplateParamsSchema = UpsertTemplateParamsSchema.extend(EmailContentFields); diff --git a/tests/integration/templates.test.ts b/tests/integration/templates.test.ts index 80c87ff..9593b75 100644 --- a/tests/integration/templates.test.ts +++ b/tests/integration/templates.test.ts @@ -33,6 +33,7 @@ describe("Template Management Integration Tests", () => { name: uniqueId("Test-Email-Template"), clientTemplateId: uniqueId("test-email-template"), subject: "Integration Test Email", + preheaderText: "Integration test preheader", fromName: "Alex Newman", fromEmail: "alex.newman@iterable.com", html: "

Test Email

Hello {{firstName}}!

Unsubscribe

", @@ -200,7 +201,14 @@ describe("Template Management Integration Tests", () => { templateId, name: `${originalData.name} (Updated)`, subject: `${originalData.subject} (Updated)`, + preheaderText: "Updated preheader", }), + verifyGet: (template: any, originalData: any) => { + expect(template.preheaderText).toBe(originalData.preheaderText); + }, + verifyUpdate: (template: any) => { + expect(template.preheaderText).toBe("Updated preheader"); + }, proofData: (templateId: number, recipientEmail: string) => ({ templateId, recipientEmail, @@ -260,7 +268,15 @@ describe("Template Management Integration Tests", () => { createData, updateData, proofData, + ...rest }) => { + const verifyGet = (rest as any).verifyGet as + | ((template: any, originalData: any) => void) + | undefined; + const verifyUpdate = (rest as any).verifyUpdate as + | ((template: any) => void) + | undefined; + describe(`${type} Templates`, () => { it(`should create, get, update, and delete ${type.toLowerCase()} template`, async () => { const templateData = createData(); @@ -274,6 +290,7 @@ describe("Template Management Integration Tests", () => { const getResponse = await waitForTemplate(templateId, getMethod); expect(getResponse.templateId).toBe(templateId); + verifyGet?.(getResponse, templateData); const updateParams = updateData(templateId, templateData); await withTimeout((client as any)[updateMethod](updateParams)); @@ -283,6 +300,7 @@ describe("Template Management Integration Tests", () => { getMethod ); expect(updatedTemplate.name).toBe(updateParams.name); + verifyUpdate?.(updatedTemplate); const deleteResponse = await withTimeout( client.deleteTemplates({ ids: [templateId] }) diff --git a/tests/unit/templates.test.ts b/tests/unit/templates.test.ts index cc3e163..fd7a8e5 100644 --- a/tests/unit/templates.test.ts +++ b/tests/unit/templates.test.ts @@ -16,7 +16,13 @@ import { SendTemplateProofParamsSchema, SMSTemplateSchema, UpdateEmailTemplateParamsSchema, + UpdateInAppTemplateParamsSchema, + UpdatePushTemplateParamsSchema, + UpdateSMSTemplateParamsSchema, UpsertEmailTemplateParamsSchema, + UpsertInAppTemplateParamsSchema, + UpsertPushTemplateParamsSchema, + UpsertSMSTemplateParamsSchema, } from "../../src/types/templates.js"; import { createMockClient, createMockTemplate } from "../utils/test-helpers"; @@ -184,19 +190,23 @@ describe("Template Management", () => { templateId: 12345, name: "Test Email Template", subject: "Test Subject", + preheaderText: "Preview text here", fromName: "Test Sender", fromEmail: "test@example.com", + replyToEmail: "reply@example.com", html: "Test", plainText: "Test", createdAt: 1640995200000, updatedAt: 1640995200000, cacheDataFeed: true, mergeDataFeedContext: false, + dataFeedId: 42, + dataFeedIds: [42, 43], }; - expect(() => - EmailTemplateSchema.parse(validEmailTemplate) - ).not.toThrow(); + const result = EmailTemplateSchema.parse(validEmailTemplate); + expect(result.preheaderText).toBe("Preview text here"); + expect(result.dataFeedId).toBe(42); // Test required fields expect(() => @@ -430,6 +440,171 @@ describe("Template Management", () => { }); }); + describe("Upsert/Update Param Schema Validation", () => { + describe("Email param schemas accept all content fields", () => { + const fullEmailParams = { + name: "Test Email", + subject: "Subject", + preheaderText: "Preview text", + fromName: "Sender", + fromEmail: "sender@example.com", + replyToEmail: "reply@example.com", + ccEmails: ["cc@example.com"], + bccEmails: ["bcc@example.com"], + html: "Test", + plainText: "Test", + cacheDataFeed: true, + dataFeedIds: [1, 2], + dataFeedId: 1, + mergeDataFeedContext: false, + googleAnalyticsCampaignName: "campaign-1", + linkParams: [{ key: "utm_source", value: "iterable" }], + campaignDataFields: { field1: "value1" }, + isDefaultLocale: true, + }; + + it("should accept all email fields on upsert", () => { + const result = UpsertEmailTemplateParamsSchema.safeParse({ + ...fullEmailParams, + clientTemplateId: "test-email", + }); + expect(result.success).toBe(true); + }); + + it("should accept all email fields on update", () => { + const result = UpdateEmailTemplateParamsSchema.safeParse({ + ...fullEmailParams, + templateId: 12345, + }); + expect(result.success).toBe(true); + }); + + it("should include preheaderText in parsed output", () => { + const result = UpsertEmailTemplateParamsSchema.safeParse({ + clientTemplateId: "test", + preheaderText: "Check out our latest deals!", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.preheaderText).toBe( + "Check out our latest deals!" + ); + } + }); + }); + + describe("SMS param schemas accept all content fields", () => { + const fullSMSParams = { + name: "Test SMS", + message: "Hello!", + imageUrl: "https://example.com/image.png", + googleAnalyticsCampaignName: "sms-campaign", + linkParams: [{ key: "utm_source", value: "iterable" }], + trackingDomain: "track.example.com", + campaignDataFields: { field1: "value1" }, + isDefaultLocale: false, + }; + + it("should accept all SMS fields on upsert", () => { + const result = UpsertSMSTemplateParamsSchema.safeParse({ + ...fullSMSParams, + clientTemplateId: "test-sms", + }); + expect(result.success).toBe(true); + }); + + it("should accept all SMS fields on update", () => { + const result = UpdateSMSTemplateParamsSchema.safeParse({ + ...fullSMSParams, + templateId: 12345, + }); + expect(result.success).toBe(true); + }); + }); + + describe("Push param schemas accept all content fields", () => { + const fullPushParams = { + name: "Test Push", + message: "Hello!", + title: "Title", + badge: "1", + buttons: [{ identifier: "btn1", action: "openApp" }], + sound: "default", + payload: { key: "value" }, + cacheDataFeed: false, + dataFeedIds: [1], + deeplink: { ios: "myapp://home", android: "myapp://home" }, + interruptionLevel: "time-sensitive" as const, + isSilentPush: false, + mergeDataFeedContext: true, + relevanceScore: 0.75, + richMedia: { ios: "https://example.com/img.png" }, + wake: true, + campaignDataFields: { field1: "value1" }, + isDefaultLocale: true, + }; + + it("should accept all push fields on upsert", () => { + const result = UpsertPushTemplateParamsSchema.safeParse({ + ...fullPushParams, + clientTemplateId: "test-push", + }); + expect(result.success).toBe(true); + }); + + it("should accept all push fields on update", () => { + const result = UpdatePushTemplateParamsSchema.safeParse({ + ...fullPushParams, + templateId: 12345, + }); + expect(result.success).toBe(true); + }); + + it("should reject invalid interruptionLevel values", () => { + const result = UpsertPushTemplateParamsSchema.safeParse({ + clientTemplateId: "test", + interruptionLevel: "invalid-value", + }); + expect(result.success).toBe(false); + }); + }); + + describe("InApp param schemas accept all content fields", () => { + const fullInAppParams = { + name: "Test InApp", + html: "
Hello!
", + expirationDateTime: "2026-12-31 23:59:59", + expirationDuration: "now+90d", + inAppDisplaySettings: { position: "center" }, + inboxMetadata: { + title: "Inbox Title", + subtitle: "Subtitle", + icon: "https://example.com/icon.png", + }, + payload: { key: "value" }, + webInAppDisplaySettings: { position: "top-right" }, + campaignDataFields: { field1: "value1" }, + isDefaultLocale: false, + }; + + it("should accept all in-app fields on upsert", () => { + const result = UpsertInAppTemplateParamsSchema.safeParse({ + ...fullInAppParams, + clientTemplateId: "test-inapp", + }); + expect(result.success).toBe(true); + }); + + it("should accept all in-app fields on update", () => { + const result = UpdateInAppTemplateParamsSchema.safeParse({ + ...fullInAppParams, + templateId: 12345, + }); + expect(result.success).toBe(true); + }); + }); + }); + describe("Template Preview", () => { describe("previewEmailTemplate", () => { it("should preview email template with user data", async () => {