diff --git a/src/client/export.ts b/src/client/export.ts index ed99154..36e113a 100644 --- a/src/client/export.ts +++ b/src/client/export.ts @@ -1,16 +1,18 @@ import { CancelExportJobParams, CancelExportJobResponse, + CancelExportJobResponseSchema, GetExportFilesParams, GetExportFilesResponse, + GetExportFilesResponseSchema, GetExportJobsParams, GetExportJobsResponse, + GetExportJobsResponseSchema, StartExportJobParams, StartExportJobResponse, StartExportJobResponseSchema, } from "../types/export.js"; -import type { Constructor } from "./base.js"; -import type { BaseIterableClient } from "./base.js"; +import type { BaseIterableClient, Constructor } from "./base.js"; /** * Export operations mixin @@ -26,7 +28,7 @@ export function Export>(Base: T) { const response = await this.client.get( `/api/export/jobs?${urlParams.toString()}` ); - return response.data; + return this.validateResponse(response, GetExportJobsResponseSchema); } async getExportFiles( @@ -38,7 +40,7 @@ export function Export>(Base: T) { const response = await this.client.get( `/api/export/${params.jobId}/files?${urlParams.toString()}` ); - return response.data; + return this.validateResponse(response, GetExportFilesResponseSchema); } async startExportJob( @@ -52,7 +54,7 @@ export function Export>(Base: T) { params: CancelExportJobParams ): Promise { const response = await this.client.delete(`/api/export/${params.jobId}`); - return response.data; + return this.validateResponse(response, CancelExportJobResponseSchema); } }; } diff --git a/src/client/journeys.ts b/src/client/journeys.ts index 8634fde..27cc073 100644 --- a/src/client/journeys.ts +++ b/src/client/journeys.ts @@ -6,10 +6,10 @@ import { import { GetJourneysParams, GetJourneysResponse, + GetJourneysResponseSchema, TriggerJourneyParams, } from "../types/journeys.js"; -import type { Constructor } from "./base.js"; -import type { BaseIterableClient } from "./base.js"; +import type { BaseIterableClient, Constructor } from "./base.js"; /** * Journeys operations mixin @@ -48,7 +48,7 @@ export function Journeys>(Base: T) { const url = `/api/journeys?${queryParams.toString()}`; const response = await this.client.get(url); - return response.data; + return this.validateResponse(response, GetJourneysResponseSchema); } }; } diff --git a/src/client/messaging.ts b/src/client/messaging.ts index 6beeeb1..1b1a7e4 100644 --- a/src/client/messaging.ts +++ b/src/client/messaging.ts @@ -1,5 +1,7 @@ -import { IterableSuccessResponse } from "../types/common.js"; -import { IterableSuccessResponseSchema } from "../types/common.js"; +import { + IterableSuccessResponse, + IterableSuccessResponseSchema, +} from "../types/common.js"; import { CancelEmailParams, CancelInAppParams, @@ -10,6 +12,7 @@ import { ChannelsResponse, ChannelsResponseSchema, EmbeddedMessagesResponse, + EmbeddedMessagesResponseSchema, GetEmbeddedMessagesParams, GetInAppMessagesParams, GetInAppMessagesResponse, @@ -160,7 +163,7 @@ export function Messaging>(Base: T) { const response = await this.client.get( `/api/embedded-messaging/messages?${queryParams.toString()}` ); - return response.data; + return this.validateResponse(response, EmbeddedMessagesResponseSchema); } // get available message channels diff --git a/src/client/snippets.ts b/src/client/snippets.ts index e17cc2a..22c56c4 100644 --- a/src/client/snippets.ts +++ b/src/client/snippets.ts @@ -44,7 +44,7 @@ export function Snippets>(Base: T) { opts?: { signal?: AbortSignal } ): Promise { const response = await this.client.get( - `/api/snippets/${params.identifier}`, + `/api/snippets/${encodeURIComponent(String(params.identifier))}`, opts?.signal ? { signal: opts.signal } : {} ); return this.validateResponse(response, GetSnippetResponseSchema); @@ -56,7 +56,7 @@ export function Snippets>(Base: T) { ): Promise { const { identifier, ...body } = params; const response = await this.client.put( - `/api/snippets/${identifier}`, + `/api/snippets/${encodeURIComponent(String(identifier))}`, body, opts ); @@ -68,7 +68,7 @@ export function Snippets>(Base: T) { opts?: { signal?: AbortSignal } ): Promise { const response = await this.client.delete( - `/api/snippets/${params.identifier}`, + `/api/snippets/${encodeURIComponent(String(params.identifier))}`, opts?.signal ? { signal: opts.signal } : {} ); return this.validateResponse(response, DeleteSnippetResponseSchema); diff --git a/src/client/subscriptions.ts b/src/client/subscriptions.ts index 096ca58..37ce13a 100644 --- a/src/client/subscriptions.ts +++ b/src/client/subscriptions.ts @@ -1,4 +1,7 @@ -import { IterableSuccessResponse } from "../types/common.js"; +import { + IterableSuccessResponse, + IterableSuccessResponseSchema, +} from "../types/common.js"; import { BulkUpdateSubscriptionsParams, SubscribeUserByEmailParams, @@ -6,8 +9,7 @@ import { UnsubscribeUserByEmailParams, UnsubscribeUserByUserIdParams, } from "../types/subscriptions.js"; -import type { Constructor } from "./base.js"; -import type { BaseIterableClient } from "./base.js"; +import type { BaseIterableClient, Constructor } from "./base.js"; export function Subscriptions>( Base: T @@ -30,7 +32,7 @@ export function Subscriptions>( `/api/subscriptions/${encodeURIComponent(subscriptionGroup)}/${subscriptionGroupId}?action=${action}`, requestBody ); - return response.data; + return this.validateResponse(response, IterableSuccessResponseSchema); } async subscribeUserByEmail( @@ -41,7 +43,7 @@ export function Subscriptions>( const response = await this.client.patch( `/api/subscriptions/${encodeURIComponent(subscriptionGroup)}/${subscriptionGroupId}/user/${encodeURIComponent(userEmail)}` ); - return response.data; + return this.validateResponse(response, IterableSuccessResponseSchema); } async subscribeUserByUserId( @@ -52,7 +54,7 @@ export function Subscriptions>( const response = await this.client.patch( `/api/subscriptions/${encodeURIComponent(subscriptionGroup)}/${subscriptionGroupId}/byUserId/${encodeURIComponent(userId)}` ); - return response.data; + return this.validateResponse(response, IterableSuccessResponseSchema); } async unsubscribeUserByEmail( @@ -63,7 +65,7 @@ export function Subscriptions>( const response = await this.client.delete( `/api/subscriptions/${encodeURIComponent(subscriptionGroup)}/${subscriptionGroupId}/user/${encodeURIComponent(userEmail)}` ); - return response.data; + return this.validateResponse(response, IterableSuccessResponseSchema); } async unsubscribeUserByUserId( @@ -74,7 +76,7 @@ export function Subscriptions>( const response = await this.client.delete( `/api/subscriptions/${encodeURIComponent(subscriptionGroup)}/${subscriptionGroupId}/byUserId/${encodeURIComponent(userId)}` ); - return response.data; + return this.validateResponse(response, IterableSuccessResponseSchema); } }; } diff --git a/src/client/templates.ts b/src/client/templates.ts index 8b9b621..9b26452 100644 --- a/src/client/templates.ts +++ b/src/client/templates.ts @@ -79,16 +79,6 @@ export function Templates>(Base: T) { return this.validateResponse(response, IterableSuccessResponseSchema); } - // Template deletion - works for all template types - async deleteTemplates( - templateIds: number[] - ): Promise { - const response = await this.client.post(`/api/templates/bulkDelete`, { - ids: templateIds, - }); - return this.validateResponse(response, BulkDeleteTemplatesResponseSchema); - } - async #sendTemplateProof( pathSegment: string, request: SendTemplateProofParams @@ -155,12 +145,22 @@ export function Templates>(Base: T) { async bulkDeleteTemplates( params: BulkDeleteTemplatesParams - ): Promise { + ): Promise { const response = await this.client.post( `/api/templates/bulkDelete`, params ); - return this.validateResponse(response, IterableSuccessResponseSchema); + return this.validateResponse(response, BulkDeleteTemplatesResponseSchema); + } + + /** + * Delete one or more templates by ID + * @deprecated Use {@link bulkDeleteTemplates} instead + */ + async deleteTemplates( + templateIds: number[] + ): Promise { + return this.bulkDeleteTemplates({ ids: templateIds }); } // Email Template Management diff --git a/src/client/users.ts b/src/client/users.ts index 069983b..1bdde74 100644 --- a/src/client/users.ts +++ b/src/client/users.ts @@ -1,20 +1,25 @@ -import { IterableSuccessResponse } from "../types/common.js"; -import { IterableSuccessResponseSchema } from "../types/common.js"; -import { UserBulkUpdateListResponse } from "../types/lists.js"; -import { UserBulkUpdateListResponseSchema } from "../types/lists.js"; +import { + IterableSuccessResponse, + IterableSuccessResponseSchema, +} from "../types/common.js"; +import { + UserBulkUpdateListResponse, + UserBulkUpdateListResponseSchema, +} from "../types/lists.js"; import { BulkUpdateUsersParams, GetSentMessagesParams, GetSentMessagesResponse, + GetSentMessagesResponseSchema, GetUserFieldsResponse, GetUserFieldsResponseSchema, UpdateEmailParams, UpdateUserParams, UpdateUserSubscriptionsParams, UserResponse, + UserResponseSchema, } from "../types/users.js"; -import type { Constructor } from "./base.js"; -import type { BaseIterableClient } from "./base.js"; +import type { BaseIterableClient, Constructor } from "./base.js"; /** * User management operations mixin @@ -32,7 +37,7 @@ export function Users>(Base: T) { `/api/users/${encodeURIComponent(email)}`, opts?.signal ? { signal: opts.signal } : {} ); - return response.data; + return this.validateResponse(response, UserResponseSchema); } /** @@ -42,10 +47,11 @@ export function Users>(Base: T) { userId: string, opts?: { signal?: AbortSignal } ): Promise { - const response = await this.client.get(`/api/users/byUserId/${userId}`, { - ...(opts?.signal ? { signal: opts.signal } : {}), - }); - return response.data; + const response = await this.client.get( + `/api/users/byUserId/${encodeURIComponent(userId)}`, + opts?.signal ? { signal: opts.signal } : {} + ); + return this.validateResponse(response, UserResponseSchema); } /** @@ -67,7 +73,7 @@ export function Users>(Base: T) { const response = await this.client.delete( `/api/users/${encodeURIComponent(email)}` ); - return response.data; + return this.validateResponse(response, IterableSuccessResponseSchema); } /** @@ -79,7 +85,7 @@ export function Users>(Base: T) { const response = await this.client.delete( `/api/users/byUserId/${encodeURIComponent(userId)}` ); - return response.data; + return this.validateResponse(response, IterableSuccessResponseSchema); } /** @@ -159,7 +165,7 @@ export function Users>(Base: T) { const response = await this.client.get( `/api/users/getSentMessages?${queryParams.toString()}` ); - return response.data; + return this.validateResponse(response, GetSentMessagesResponseSchema); } /** diff --git a/src/types/export.ts b/src/types/export.ts index 0e28732..8c3c1c3 100644 --- a/src/types/export.ts +++ b/src/types/export.ts @@ -121,21 +121,27 @@ export type GetExportJobsParams = z.infer; export type GetExportFilesParams = z.infer; export type StartExportJobParams = z.infer; export type CancelExportJobParams = z.infer; -export interface ExportJob { - id: number; - dataTypeName: string; - jobState: - | "enqueued" - | "queued" - | "running" - | "completed" - | "failed" - | "cancelled" - | "cancelling"; - scheduledStartTime?: string; - endTime?: string; - bytesExported?: number; -} + +// Export job state enum - API uses PlayLowercaseJsonEnum so values are lowercase +export const ExportJobStateSchema = z.enum([ + "enqueued", + "running", + "completed", + "failed", + "cancelling", +]); + +// Export job schema - matches JobModel from API docs +export const ExportJobSchema = z.object({ + id: z.number(), + dataTypeName: z.string(), + jobState: ExportJobStateSchema, + scheduledStartTime: z.string().optional(), + endTime: z.string().optional(), + bytesExported: z.number().optional(), +}); + +export type ExportJob = z.infer; export const StartExportJobResponseSchema = z.object({ jobId: z.number(), @@ -146,32 +152,50 @@ export type StartExportJobResponse = z.infer< typeof StartExportJobResponseSchema >; -export interface GetExportJobsResponse { - jobs: ExportJob[]; -} +// Response schema for getExportJobs +export const GetExportJobsResponseSchema = z.object({ + jobs: z.array(ExportJobSchema), +}); + +export type GetExportJobsResponse = z.infer; /** * Individual export file with download URL * Each file is up to 10MB in size */ -export interface ExportFileAndUrl { - file: string; - url: string; -} +export const ExportFileAndUrlSchema = z.object({ + file: z.string(), + url: z.string(), +}); + +export type ExportFileAndUrl = z.infer; /** * Response from getExportFiles containing job status and file download URLs * Files are added to the list as the export job runs */ -export interface GetExportFilesResponse { - exportTruncated: boolean; - files: ExportFileAndUrl[]; - jobId: number; - jobState: "Enqueued" | "Running" | "Completed" | "Failed"; -} - -export interface CancelExportJobResponse { - // The cancel endpoint returns a simple success response - // Based on API docs: "successful operation" with no specific schema - [key: string]: unknown; -} +export const GetExportFilesResponseSchema = z.object({ + exportTruncated: z.boolean(), + files: z.array(ExportFileAndUrlSchema), + jobId: z.number(), + jobState: ExportJobStateSchema, +}); + +export type GetExportFilesResponse = z.infer< + typeof GetExportFilesResponseSchema +>; + +/** + * Response from cancelExportJob + * The cancel endpoint returns a simple success response + */ +export const CancelExportJobResponseSchema = z + .object({ + msg: z.string().optional(), + code: z.string().optional(), + }) + .passthrough(); // Allow additional fields + +export type CancelExportJobResponse = z.infer< + typeof CancelExportJobResponseSchema +>; diff --git a/src/types/messaging.ts b/src/types/messaging.ts index 4e0085d..c25adec 100644 --- a/src/types/messaging.ts +++ b/src/types/messaging.ts @@ -352,6 +352,121 @@ export const SendInAppParamsSchema = z ); // Embedded messaging schemas +export const ActionSchema = z.object({ + type: z + .string() + .describe( + "For URL actions, this field is 'openUrl'. For custom actions, it's the full URL of the custom action." + ), + data: z + .string() + .describe( + "For URL actions, this field is a full URL. For custom actions, it's an empty string." + ), +}); + +export const ButtonSchema = z.object({ + id: z.string().optional().describe("ID of the button."), + title: z.string().optional().describe("Text to display on the button."), + action: ActionSchema.optional().describe( + "Action to invoke when a user taps the button." + ), +}); + +export const TextSchema = z.object({ + id: z + .string() + .optional() + .describe("Deprecated field. Do not use. Use label as a key."), + label: z + .string() + .optional() + .describe( + "Identifier for the text field, specified by the user who created its associated placement. This field is a key, not content. Do not display it." + ), + text: z.string().optional().describe("Text to display."), +}); + +export const EmbeddedMessagingElementsSchema = z.object({ + title: z.string().optional().describe("Title text of the embedded message."), + body: z.string().optional().describe("Body text of the embedded message."), + mediaUrl: z + .string() + .optional() + .describe("Image URL associated with the embedded message."), + mediaUrlCaption: z + .string() + .optional() + .describe("Alt text for the image specified by mediaUrl."), + text: z + .array(TextSchema) + .optional() + .describe( + "Text fields (other than title and body) to display with the embedded message." + ), + buttons: z + .array(ButtonSchema) + .optional() + .describe("Buttons to display with the embedded message."), + defaultAction: ActionSchema.optional().describe( + "Action to invoke when a user taps or clicks on the embedded message (but not on a button or link)." + ), +}); + +export const EmbeddedMessagingMetadataSchema = z.object({ + messageId: z + .string() + .optional() + .describe( + "ID associated with the specific send of a specific campaign to a specific user." + ), + campaignId: z + .number() + .optional() + .describe( + "ID of the Iterable campaign associated with the embedded message." + ), + placementId: z + .number() + .optional() + .describe("ID of the placement to which the embedded message belongs."), + isProof: z + .boolean() + .optional() + .describe("Whether or not the campaign is a test message (proof)."), + priorityOrder: z + .number() + .optional() + .describe( + "Numeric priority, as compared to other embedded campaigns in the same placement. Lower numbers mean higher priority. Highest priority is 1." + ), +}); + +export const EmbeddedMessageSchema = z.object({ + metadata: EmbeddedMessagingMetadataSchema.optional().describe( + "Identifying information about the embedded message." + ), + elements: EmbeddedMessagingElementsSchema.optional().describe( + "Content to display in the message, and actions to invoke on click or tap." + ), + payload: z + .record(z.string(), z.unknown()) + .optional() + .describe( + "Custom JSON data. Use to customize the message display or trigger custom behavior." + ), +}); + +export const EmbeddedPlacementSchema = z.object({ + placementId: z.number().optional().describe("ID of a placement."), + embeddedMessages: z + .array(EmbeddedMessageSchema) + .optional() + .describe( + "Array of embedded messages associated with placementId. The user specified in the request is eligible for all messages in this array." + ), +}); + export const GetEmbeddedMessagesParamsSchema = z .object({ email: z.email().optional().describe("User email address"), @@ -389,7 +504,7 @@ export const ApiInAppMessagesResponseSchema = z.object({ }); export const EmbeddedMessagesResponseSchema = z.object({ - placements: z.record(z.string(), z.any()), // Grouped by placementId with complex structure + placements: z.array(EmbeddedPlacementSchema), // API returns array of placement objects }); // Type exports @@ -425,3 +540,14 @@ export type ApiInAppMessagesResponse = z.infer< export type EmbeddedMessagesResponse = z.infer< typeof EmbeddedMessagesResponseSchema >; +export type Action = z.infer; +export type Button = z.infer; +export type Text = z.infer; +export type EmbeddedMessagingElements = z.infer< + typeof EmbeddedMessagingElementsSchema +>; +export type EmbeddedMessagingMetadata = z.infer< + typeof EmbeddedMessagingMetadataSchema +>; +export type EmbeddedMessage = z.infer; +export type EmbeddedPlacement = z.infer; diff --git a/src/types/users.ts b/src/types/users.ts index 597335c..59eea6f 100644 --- a/src/types/users.ts +++ b/src/types/users.ts @@ -19,12 +19,14 @@ export type UserProfile = z.infer; export type UpdateUserParams = z.infer; export const UserResponseSchema = z.object({ - user: z.object({ - email: z.string(), - userId: z.string().optional(), - dataFields: z.record(z.string(), z.any()).optional(), - profileUpdatedAt: z.string().optional(), - }), + user: z + .object({ + email: z.string(), + userId: z.string().optional(), + dataFields: z.record(z.string(), z.any()).optional(), + profileUpdatedAt: z.string().optional(), + }) + .optional(), // user field is undefined when user not found }); export type UserResponse = z.infer; diff --git a/tests/integration/general.test.ts b/tests/integration/general.test.ts index ae6f6ce..66b0d3c 100644 --- a/tests/integration/general.test.ts +++ b/tests/integration/general.test.ts @@ -113,12 +113,12 @@ describe("General Integration Tests", () => { operationCount: 2, }); - expect(userResponse.user.email).toBe(testUserEmail); - expect(userResponse.user.dataFields?.consistencyTest).toBe( + expect(userResponse.user?.email).toBe(testUserEmail); + expect(userResponse.user?.dataFields?.consistencyTest).toBe( testData.dataFields.consistencyTest ); - expect(userResponse.user.dataFields?.operationCount).toBe(2); - expect(userResponse.user.dataFields?.lastUpdate).toBeDefined(); + expect(userResponse.user?.dataFields?.operationCount).toBe(2); + expect(userResponse.user?.dataFields?.lastUpdate).toBeDefined(); }); }); }); diff --git a/tests/integration/users.test.ts b/tests/integration/users.test.ts index e51e365..237ccf9 100644 --- a/tests/integration/users.test.ts +++ b/tests/integration/users.test.ts @@ -48,8 +48,8 @@ describe("User Management Integration Tests", () => { }); // ✅ VERIFY: User data is correct (already retrieved by waitForUserUpdate) - expect(userResponse.user.email).toBe(testUserEmail); - expect(userResponse.user.userId).toBe(testUserId); + expect(userResponse.user?.email).toBe(testUserEmail); + expect(userResponse.user?.userId).toBe(testUserId); }); it("should get user by email using getUserByEmail", async () => { @@ -70,8 +70,8 @@ describe("User Management Integration Tests", () => { const userResponse = await withTimeout( client.getUserByEmail(testUserEmail) ); - expect(userResponse.user.email).toBe(testUserEmail); - expect(userResponse.user.dataFields?.testField).toBe("email-test"); + expect(userResponse.user?.email).toBe(testUserEmail); + expect(userResponse.user?.dataFields?.testField).toBe("email-test"); }); it("should get user by userId using getUserByUserId", async () => { @@ -90,9 +90,9 @@ describe("User Management Integration Tests", () => { // ✅ VERIFY: User can be retrieved by userId const userResponse = await withTimeout(client.getUserByUserId(testUserId)); - expect(userResponse.user.userId).toBe(testUserId); - expect(userResponse.user.email).toBe(testUserEmail); - expect(userResponse.user.dataFields?.testField).toBe("userId-test"); + expect(userResponse.user?.userId).toBe(testUserId); + expect(userResponse.user?.email).toBe(testUserEmail); + expect(userResponse.user?.dataFields?.testField).toBe("userId-test"); }); it("should update user data fields", async () => { @@ -269,7 +269,7 @@ describe("User Management Integration Tests", () => { await retryWithBackoff( async () => { const userCheck = await client.getUserByUserId(deleteTestUserId); - if (!userCheck.user.userId) { + if (!userCheck.user?.userId) { throw new Error("userId not set on user profile yet"); } expect(userCheck.user.userId).toBe(deleteTestUserId); @@ -330,7 +330,7 @@ describe("User Management Integration Tests", () => { await new Promise((resolve) => setTimeout(resolve, 2000)); const userResponse = await withTimeout(client.getUserByEmail(newEmail)); - expect(userResponse.user.email).toBe(newEmail); + expect(userResponse.user?.email).toBe(newEmail); } finally { // Cleanup both possible emails await cleanupTestUser(client, oldEmail); diff --git a/tests/unit/export.test.ts b/tests/unit/export.test.ts index 2bf5525..bee76b6 100644 --- a/tests/unit/export.test.ts +++ b/tests/unit/export.test.ts @@ -24,20 +24,73 @@ describe("Export Operations", () => { jest.clearAllMocks(); }); + describe("getExportJobs", () => { + it("should get export jobs list", async () => { + const mockResponse = { + data: { + jobs: [ + { + id: 123, + dataTypeName: "user", + jobState: "completed", + bytesExported: 1024, + }, + { + id: 124, + dataTypeName: "emailSend", + jobState: "running", + }, + ], + }, + }; + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + const result = await client.getExportJobs(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith("/api/export/jobs?"); + expect(result).toEqual(mockResponse.data); + }); + + it("should get export jobs filtered by state", async () => { + const mockResponse = { + data: { + jobs: [ + { + id: 125, + dataTypeName: "purchase", + jobState: "running", + }, + ], + }, + }; + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + const result = await client.getExportJobs({ jobState: "running" }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/export/jobs?jobState=running" + ); + expect(result).toEqual(mockResponse.data); + }); + }); + describe("getExportFiles", () => { it("should get export files for a job", async () => { const mockResponse = { data: { + exportTruncated: false, files: [ { - fileName: "export_123_part_1.csv", - downloadUrl: "https://example.com/file1.csv", + file: "export_123_part_1.csv", + url: "https://example.com/file1.csv", }, { - fileName: "export_123_part_2.csv", - downloadUrl: "https://example.com/file2.csv", + file: "export_123_part_2.csv", + url: "https://example.com/file2.csv", }, ], + jobId: 123, + jobState: "completed", }, }; mockAxiosInstance.get.mockResolvedValue(mockResponse); @@ -53,12 +106,15 @@ describe("Export Operations", () => { it("should get export files with pagination", async () => { const mockResponse = { data: { + exportTruncated: false, files: [ { - fileName: "export_456_part_3.csv", - downloadUrl: "https://example.com/file3.csv", + file: "export_456_part_3.csv", + url: "https://example.com/file3.csv", }, ], + jobId: 456, + jobState: "running", }, }; mockAxiosInstance.get.mockResolvedValue(mockResponse); diff --git a/tests/unit/messaging.test.ts b/tests/unit/messaging.test.ts index 310c2c5..209b10a 100644 --- a/tests/unit/messaging.test.ts +++ b/tests/unit/messaging.test.ts @@ -143,11 +143,24 @@ describe("Messaging Operations", () => { it("should get embedded messages with all parameters", async () => { const mockResponse = { data: { - placements: { - placement1: { - messages: [{ messageId: "embedded123" }], + placements: [ + { + placementId: 123, + embeddedMessages: [ + { + metadata: { + messageId: "embedded123", + campaignId: 456, + placementId: 123, + }, + elements: { + title: "Test Message", + body: "Test Body", + }, + }, + ], }, - }, + ], }, }; mockAxiosInstance.get.mockResolvedValue(mockResponse); @@ -170,7 +183,7 @@ describe("Messaging Operations", () => { it("should get embedded messages with minimal parameters", async () => { const mockResponse = { data: { - placements: {}, + placements: [], }, }; mockAxiosInstance.get.mockResolvedValue(mockResponse); diff --git a/tests/unit/snippets.test.ts b/tests/unit/snippets.test.ts index cdf536e..d58025e 100644 --- a/tests/unit/snippets.test.ts +++ b/tests/unit/snippets.test.ts @@ -140,6 +140,19 @@ describe("Snippets Management", () => { ); }); + it("should URL-encode special characters in identifier", async () => { + const mockSnippet = createMockSnippet(); + const mockResponse = { data: { snippet: mockSnippet } }; + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + await client.getSnippet({ identifier: "test snippet/with special&chars" }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/snippets/test%20snippet%2Fwith%20special%26chars", + { signal: undefined } + ); + }); + it("should return snippet details", async () => { const mockSnippet = createMockSnippet(); const mockResponse = { data: { snippet: mockSnippet } }; diff --git a/tests/unit/subscriptions.test.ts b/tests/unit/subscriptions.test.ts index 8ac3d97..1d5f560 100644 --- a/tests/unit/subscriptions.test.ts +++ b/tests/unit/subscriptions.test.ts @@ -31,8 +31,10 @@ describe("Subscription Management", () => { }); describe("bulkUpdateSubscriptions", () => { + const successResponse = { data: { msg: "Success", code: "Success" } }; + it("should use PUT method with correct URL and query parameter", async () => { - mockAxiosInstance.put.mockResolvedValue({ data: {} }); + mockAxiosInstance.put.mockResolvedValue(successResponse); await client.bulkUpdateSubscriptions({ subscriptionGroup: "emailList", @@ -48,7 +50,7 @@ describe("Subscription Management", () => { }); it("should properly encode subscription group names with special characters", async () => { - mockAxiosInstance.put.mockResolvedValue({ data: {} }); + mockAxiosInstance.put.mockResolvedValue(successResponse); await client.bulkUpdateSubscriptions({ subscriptionGroup: "emailList", @@ -64,7 +66,7 @@ describe("Subscription Management", () => { }); it("should include both users arrays in request body", async () => { - mockAxiosInstance.put.mockResolvedValue({ data: {} }); + mockAxiosInstance.put.mockResolvedValue(successResponse); await client.bulkUpdateSubscriptions({ subscriptionGroup: "emailList", @@ -84,7 +86,7 @@ describe("Subscription Management", () => { }); it("should include undefined fields in request body", async () => { - mockAxiosInstance.put.mockResolvedValue({ data: {} }); + mockAxiosInstance.put.mockResolvedValue(successResponse); await client.bulkUpdateSubscriptions({ subscriptionGroup: "emailList", @@ -103,7 +105,7 @@ describe("Subscription Management", () => { }); it("should accept all valid subscription group types", async () => { - mockAxiosInstance.put.mockResolvedValue({ data: {} }); + mockAxiosInstance.put.mockResolvedValue(successResponse); const validGroups: Array<"emailList" | "messageType" | "messageChannel"> = ["emailList", "messageType", "messageChannel"]; @@ -156,8 +158,10 @@ describe("Subscription Management", () => { }); describe("subscribeUserByEmail", () => { + const successResponse = { data: { msg: "Success", code: "Success" } }; + it("should use PATCH method with correct URL", async () => { - mockAxiosInstance.patch.mockResolvedValue({ data: {} }); + mockAxiosInstance.patch.mockResolvedValue(successResponse); await client.subscribeUserByEmail({ subscriptionGroup: "emailList", @@ -171,7 +175,7 @@ describe("Subscription Management", () => { }); it("should properly encode email addresses with special characters", async () => { - mockAxiosInstance.patch.mockResolvedValue({ data: {} }); + mockAxiosInstance.patch.mockResolvedValue(successResponse); await client.subscribeUserByEmail({ subscriptionGroup: "emailList", @@ -217,8 +221,10 @@ describe("Subscription Management", () => { }); describe("subscribeUserByUserId", () => { + const successResponse = { data: { msg: "Success", code: "Success" } }; + it("should use PATCH method with correct URL", async () => { - mockAxiosInstance.patch.mockResolvedValue({ data: {} }); + mockAxiosInstance.patch.mockResolvedValue(successResponse); await client.subscribeUserByUserId({ subscriptionGroup: "messageType", @@ -232,7 +238,7 @@ describe("Subscription Management", () => { }); it("should properly encode user IDs with special characters", async () => { - mockAxiosInstance.patch.mockResolvedValue({ data: {} }); + mockAxiosInstance.patch.mockResolvedValue(successResponse); await client.subscribeUserByUserId({ subscriptionGroup: "messageType", @@ -247,8 +253,10 @@ describe("Subscription Management", () => { }); describe("unsubscribeUserByEmail", () => { + const successResponse = { data: { msg: "Success", code: "Success" } }; + it("should use DELETE method with correct URL", async () => { - mockAxiosInstance.delete.mockResolvedValue({ data: {} }); + mockAxiosInstance.delete.mockResolvedValue(successResponse); await client.unsubscribeUserByEmail({ subscriptionGroup: "emailList", @@ -262,7 +270,7 @@ describe("Subscription Management", () => { }); it("should properly encode email addresses with special characters", async () => { - mockAxiosInstance.delete.mockResolvedValue({ data: {} }); + mockAxiosInstance.delete.mockResolvedValue(successResponse); await client.unsubscribeUserByEmail({ subscriptionGroup: "emailList", @@ -277,8 +285,10 @@ describe("Subscription Management", () => { }); describe("unsubscribeUserByUserId", () => { + const successResponse = { data: { msg: "Success", code: "Success" } }; + it("should use DELETE method with correct URL", async () => { - mockAxiosInstance.delete.mockResolvedValue({ data: {} }); + mockAxiosInstance.delete.mockResolvedValue(successResponse); await client.unsubscribeUserByUserId({ subscriptionGroup: "messageType", @@ -292,7 +302,7 @@ describe("Subscription Management", () => { }); it("should properly encode user IDs with special characters", async () => { - mockAxiosInstance.delete.mockResolvedValue({ data: {} }); + mockAxiosInstance.delete.mockResolvedValue(successResponse); await client.unsubscribeUserByUserId({ subscriptionGroup: "messageType", @@ -332,29 +342,23 @@ describe("Subscription Management", () => { } }); - it("should reject non-success response codes", async () => { + it("should reject non-success response codes via validation", async () => { const invalidResponse = { code: "BadRequest", msg: "This would normally be thrown as an exception by the base client", }; mockAxiosInstance.put.mockResolvedValue({ data: invalidResponse }); - const result = await client.bulkUpdateSubscriptions({ - subscriptionGroup: "emailList", - subscriptionGroupId: 123, - action: "subscribe", - users: ["test@example.com"], - }); - - // This should fail validation because only "Success" code is allowed in actual responses - // (HTTP errors are thrown as exceptions by the base client) - const validation = IterableSuccessResponseSchema.safeParse(result); - expect(validation.success).toBe(false); - if (!validation.success) { - expect(validation.error.issues.length).toBeGreaterThan(0); - expect(validation.error?.issues[0]?.code).toBe("invalid_value"); - expect(validation.error?.issues[0]?.path).toEqual(["code"]); - } + // Response validation now happens inside the method, so it throws + // when the response doesn't match the expected schema + await expect( + client.bulkUpdateSubscriptions({ + subscriptionGroup: "emailList", + subscriptionGroupId: 123, + action: "subscribe", + users: ["test@example.com"], + }) + ).rejects.toThrow("Response validation failed"); }); it("should handle HTTP errors with proper Iterable API error parsing", async () => { diff --git a/tests/unit/templates.test.ts b/tests/unit/templates.test.ts index 0da520e..c8a2a3d 100644 --- a/tests/unit/templates.test.ts +++ b/tests/unit/templates.test.ts @@ -18,11 +18,7 @@ import { UpdateEmailTemplateParamsSchema, UpsertEmailTemplateParamsSchema, } from "../../src/types/templates.js"; -import { - createMockClient, - createMockIterableResponse, - createMockTemplate, -} from "../utils/test-helpers"; +import { createMockClient, createMockTemplate } from "../utils/test-helpers"; describe("Template Management", () => { let client: IterableClient; @@ -99,39 +95,39 @@ describe("Template Management", () => { }); }); - describe("deleteTemplates", () => { - it("should delete templates", async () => { - const templateIds = [12345, 67890]; + describe("bulkDeleteTemplates", () => { + it("should bulk delete multiple templates", async () => { + const templateIds = [67890, 67891, 67892]; const mockResponse = { data: { success: templateIds, failed: [], - failureReason: "", }, }; mockAxiosInstance.post.mockResolvedValue(mockResponse); - const result = await client.deleteTemplates(templateIds); + const result = await client.bulkDeleteTemplates({ ids: templateIds }); expect(mockAxiosInstance.post).toHaveBeenCalledWith( "/api/templates/bulkDelete", { ids: templateIds } ); - expect(result).toEqual({ - success: templateIds, - failed: [], - failureReason: "", - }); + expect(result).toEqual(mockResponse.data); }); }); - describe("bulkDeleteTemplates", () => { - it("should bulk delete multiple templates", async () => { - const templateIds = [67890, 67891, 67892]; - const mockResponse = { data: createMockIterableResponse() }; + describe("deleteTemplates (deprecated)", () => { + it("should delete templates using bulkDeleteTemplates under the hood", async () => { + const templateIds = [12345, 12346]; + const mockResponse = { + data: { + success: templateIds, + failed: [], + }, + }; mockAxiosInstance.post.mockResolvedValue(mockResponse); - const result = await client.bulkDeleteTemplates({ ids: templateIds }); + const result = await client.deleteTemplates(templateIds); expect(mockAxiosInstance.post).toHaveBeenCalledWith( "/api/templates/bulkDelete", @@ -313,7 +309,8 @@ describe("Template Management", () => { describe("Template proof schema validation", () => { it("should validate proof request with email", () => { - const result = SendTemplateProofParamsSchema.safeParse(mockProofRequest); + const result = + SendTemplateProofParamsSchema.safeParse(mockProofRequest); expect(result.success).toBe(true); }); @@ -323,7 +320,8 @@ describe("Template Management", () => { recipientUserId: "user123", dataFields: { firstName: "Test" }, }; - const result = SendTemplateProofParamsSchema.safeParse(requestWithUserId); + const result = + SendTemplateProofParamsSchema.safeParse(requestWithUserId); expect(result.success).toBe(true); }); diff --git a/tests/unit/users.test.ts b/tests/unit/users.test.ts index 5d75cbe..be8e358 100644 --- a/tests/unit/users.test.ts +++ b/tests/unit/users.test.ts @@ -57,8 +57,8 @@ describe("User Management", () => { const result = await client.getUserByEmail(TEST_USER_EMAIL); - expect(result.user.email).toBe(TEST_USER_EMAIL); - expect(result.user.userId).toBe("user123"); + expect(result.user?.email).toBe(TEST_USER_EMAIL); + expect(result.user?.userId).toBe("user123"); }); }); diff --git a/tests/utils/test-helpers.ts b/tests/utils/test-helpers.ts index 4eb9ad8..20797ed 100644 --- a/tests/utils/test-helpers.ts +++ b/tests/utils/test-helpers.ts @@ -287,7 +287,7 @@ export async function waitForUserUpdate( return retryWithBackoff( async () => { const userResponse = await client.getUserByEmail(email); - const actualDataFields = userResponse.user.dataFields || {}; + const actualDataFields = userResponse.user?.dataFields || {}; const missingFields = Object.entries(expectedDataFields).filter( ([key, expectedValue]) => actualDataFields[key] !== expectedValue );