From da5c58ead729e33179acff0c5be2cf47918f7f3e Mon Sep 17 00:00:00 2001 From: Marco Morais Date: Tue, 17 Mar 2026 14:33:11 +0000 Subject: [PATCH 1/3] Add experiments list, get, and get variants endpoints Adds three new experiment endpoints to support MCP server tools: - listExperiments: List experiments with filtering by campaign, status, and date range - getExperiment: Get detailed experiment information by ID - getExperimentVariants: Get variant content including templates Includes comprehensive unit tests for all new methods. Related tickets: EX-2197, EX-2198, EX-2199 Co-Authored-By: Claude Sonnet 4.5 --- package.json | 2 +- src/client/experiments.ts | 62 +++++++++ src/types/experiments.ts | 138 +++++++++++++++++++ tests/unit/experiments.test.ts | 244 +++++++++++++++++++++++++++++++++ 4 files changed, 445 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 2c16b35..01150ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@iterable/api", - "version": "0.7.0", + "version": "0.7.1", "description": "TypeScript client library for the Iterable API", "keywords": [ "iterable", diff --git a/src/client/experiments.ts b/src/client/experiments.ts index 8972bc7..7719dff 100644 --- a/src/client/experiments.ts +++ b/src/client/experiments.ts @@ -1,6 +1,15 @@ import { + ExperimentDetails, + ExperimentDetailsSchema, ExperimentMetricsResponse, GetExperimentMetricsParams, + GetExperimentParams, + GetExperimentVariantsParams, + GetExperimentVariantsResponse, + GetExperimentVariantsResponseSchema, + ListExperimentsParams, + ListExperimentsResponse, + ListExperimentsResponseSchema, } from "../types/experiments.js"; import type { Constructor } from "./base.js"; import type { BaseIterableClient } from "./base.js"; @@ -44,5 +53,58 @@ export function Experiments>( // Parse CSV response into array of objects return this.parseCsv(response); } + + async listExperiments( + params?: ListExperimentsParams + ): Promise { + const queryParams = new URLSearchParams(); + + if (params?.campaignId !== undefined) { + queryParams.append("campaignId", params.campaignId.toString()); + } + if (params?.status) { + queryParams.append("state", params.status); + } + if (params?.startDate) { + queryParams.append("startDateTime", params.startDate); + } + if (params?.endDate) { + queryParams.append("endDateTime", params.endDate); + } + if (params?.limit !== undefined) { + queryParams.append("limit", params.limit.toString()); + } + if (params?.offset !== undefined) { + queryParams.append("offset", params.offset.toString()); + } + + const url = `/api/experiments${ + queryParams.toString() ? `?${queryParams.toString()}` : "" + }`; + const response = await this.client.get(url); + + return this.validateResponse(response, ListExperimentsResponseSchema); + } + + async getExperiment( + params: GetExperimentParams + ): Promise { + const url = `/api/experiments/${params.experimentId}`; + const response = await this.client.get(url); + + return this.validateResponse(response, ExperimentDetailsSchema); + } + + async getExperimentVariants( + params: GetExperimentVariantsParams + ): Promise { + const url = `/api/experiments/${params.experimentId}/variants`; + const response = await this.client.get(url); + + return this.validateResponse( + response, + GetExperimentVariantsResponseSchema + ); + } }; } diff --git a/src/types/experiments.ts b/src/types/experiments.ts index 074bcfe..2b8587a 100644 --- a/src/types/experiments.ts +++ b/src/types/experiments.ts @@ -39,3 +39,141 @@ export type ExperimentMetricsResponse = z.infer< export type GetExperimentMetricsParams = z.infer< typeof GetExperimentMetricsParamsSchema >; + +/** + * Experiment list schemas and types + */ + +export const ExperimentStatusSchema = z.enum(["draft", "running", "finished"]); + +export const ExperimentListItemSchema = z.object({ + id: z.number().describe("Experiment ID"), + name: z.string().describe("Experiment name"), + status: ExperimentStatusSchema.describe("Experiment status"), + startDate: z.string().optional().describe("Start date (ISO 8601)"), + channelType: z.string().describe("Channel type (e.g., email, push)"), + author: z.string().describe("Author email or name"), +}); + +export const ListExperimentsParamsSchema = z + .object({ + campaignId: z.number().optional().describe("Filter by campaign ID"), + status: ExperimentStatusSchema.optional().describe( + "Filter by status (draft, running, finished)" + ), + startDate: IterableDateTimeSchema.optional().describe( + "Filter experiments starting from this date (ISO 8601 format)" + ), + endDate: IterableDateTimeSchema.optional().describe( + "Filter experiments ending before this date (ISO 8601 format)" + ), + limit: z + .number() + .int() + .min(1) + .max(1000) + .default(20) + .describe("Number of results to return (max 1000, default 20)"), + offset: z + .number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip (default 0)"), + }) + .describe("Parameters for listing experiments"); + +export const ListExperimentsResponseSchema = z.object({ + experiments: z.array(ExperimentListItemSchema), + totalCount: z.number().optional().describe("Total number of experiments"), +}); + +export type ExperimentStatus = z.infer; +export type ExperimentListItem = z.infer; +export type ListExperimentsParams = z.infer; +export type ListExperimentsResponse = z.infer< + typeof ListExperimentsResponseSchema +>; + +/** + * Get experiment schemas and types + */ + +export const ExperimentVariantSummarySchema = z.object({ + id: z.number().describe("Variant ID"), + name: z.string().describe("Variant name"), + percentage: z.number().describe("Traffic percentage"), +}); + +export const ExperimentConstraintsSchema = z + .object({ + startDate: z.string().optional().describe("Experiment start date"), + endDate: z.string().optional().describe("Experiment end date"), + timezone: z.string().optional().describe("Timezone"), + }) + .passthrough(); + +export const ExperimentDetailsSchema = z.object({ + id: z.number().describe("Experiment ID"), + name: z.string().describe("Experiment name"), + status: ExperimentStatusSchema.describe("Experiment status"), + campaignId: z.number().optional().describe("Associated campaign ID"), + channelType: z.string().describe("Channel type"), + author: z.string().describe("Author"), + createdAt: z.string().optional().describe("Creation timestamp"), + updatedAt: z.string().optional().describe("Last update timestamp"), + variants: z + .array(ExperimentVariantSummarySchema) + .optional() + .describe("Experiment variants"), + constraints: ExperimentConstraintsSchema.optional().describe( + "Experiment constraints and settings" + ), +}); + +export const GetExperimentParamsSchema = z + .object({ + experimentId: z.number().describe("Experiment ID"), + }) + .describe("Parameters for getting experiment details"); + +export type ExperimentVariantSummary = z.infer< + typeof ExperimentVariantSummarySchema +>; +export type ExperimentConstraints = z.infer; +export type ExperimentDetails = z.infer; +export type GetExperimentParams = z.infer; + +/** + * Get experiment variants schemas and types + */ + +export const ExperimentVariantContentSchema = z.object({ + id: z.number().describe("Variant ID"), + name: z.string().describe("Variant name"), + percentage: z.number().describe("Traffic percentage"), + subject: z.string().optional().describe("Email subject line"), + preheader: z.string().optional().describe("Email preheader"), + htmlSource: z.string().optional().describe("HTML email content"), + plainText: z.string().optional().describe("Plain text email content"), +}); + +export const GetExperimentVariantsParamsSchema = z + .object({ + experimentId: z.number().describe("Experiment ID"), + }) + .describe("Parameters for getting experiment variants"); + +export const GetExperimentVariantsResponseSchema = z.object({ + variants: z.array(ExperimentVariantContentSchema), +}); + +export type ExperimentVariantContent = z.infer< + typeof ExperimentVariantContentSchema +>; +export type GetExperimentVariantsParams = z.infer< + typeof GetExperimentVariantsParamsSchema +>; +export type GetExperimentVariantsResponse = z.infer< + typeof GetExperimentVariantsResponseSchema +>; diff --git a/tests/unit/experiments.test.ts b/tests/unit/experiments.test.ts index fa73c19..8873c65 100644 --- a/tests/unit/experiments.test.ts +++ b/tests/unit/experiments.test.ts @@ -227,4 +227,248 @@ describe("Experiment Operations", () => { ); }); }); + + describe("listExperiments", () => { + it("should list experiments with no parameters", async () => { + const mockResponse = { + data: { + experiments: [ + { + id: 1, + name: "Test Experiment 1", + status: "running", + startDate: "2024-01-01T00:00:00Z", + channelType: "email", + author: "test@example.com", + }, + ], + totalCount: 1, + }, + }; + + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + const result = await client.listExperiments(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith("/api/experiments"); + expect(result.experiments).toHaveLength(1); + expect(result.experiments[0].id).toBe(1); + }); + + it("should list experiments with campaign filter", async () => { + const mockResponse = { + data: { + experiments: [ + { + id: 2, + name: "Campaign Experiment", + status: "draft", + channelType: "email", + author: "test@example.com", + }, + ], + }, + }; + + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + await client.listExperiments({ campaignId: 456 }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/experiments?campaignId=456" + ); + }); + + it("should list experiments with status filter", async () => { + const mockResponse = { + data: { + experiments: [], + }, + }; + + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + await client.listExperiments({ status: "running" }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/experiments?state=running" + ); + }); + + it("should list experiments with date filters", async () => { + const mockResponse = { + data: { + experiments: [], + }, + }; + + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + await client.listExperiments({ + startDate: "2024-01-01T00:00:00Z", + endDate: "2024-01-31T23:59:59Z", + }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/experiments?startDateTime=2024-01-01T00%3A00%3A00Z&endDateTime=2024-01-31T23%3A59%3A59Z" + ); + }); + + it("should list experiments with pagination", async () => { + const mockResponse = { + data: { + experiments: [], + }, + }; + + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + await client.listExperiments({ limit: 50, offset: 100 }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/experiments?limit=50&offset=100" + ); + }); + + it("should list experiments with all parameters", async () => { + const mockResponse = { + data: { + experiments: [], + }, + }; + + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + await client.listExperiments({ + campaignId: 123, + status: "finished", + startDate: "2024-01-01T00:00:00Z", + endDate: "2024-01-31T23:59:59Z", + limit: 10, + offset: 20, + }); + + const actualCall = mockAxiosInstance.get.mock.calls[0][0]; + expect(actualCall).toContain("campaignId=123"); + expect(actualCall).toContain("state=finished"); + expect(actualCall).toContain("startDateTime=2024-01-01T00%3A00%3A00Z"); + expect(actualCall).toContain("endDateTime=2024-01-31T23%3A59%3A59Z"); + expect(actualCall).toContain("limit=10"); + expect(actualCall).toContain("offset=20"); + }); + }); + + describe("getExperiment", () => { + it("should get experiment details by ID", async () => { + const mockResponse = { + data: { + id: 123, + name: "Test Experiment", + status: "running", + campaignId: 456, + channelType: "email", + author: "test@example.com", + variants: [ + { id: 1, name: "Control", percentage: 50 }, + { id: 2, name: "Variant A", percentage: 50 }, + ], + }, + }; + + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + const result = await client.getExperiment({ experimentId: 123 }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/experiments/123" + ); + expect(result.id).toBe(123); + expect(result.name).toBe("Test Experiment"); + expect(result.variants).toHaveLength(2); + }); + + it("should handle 404 error for non-existent experiment", async () => { + const error = new Error("Request failed with status code 404"); + (error as any).response = { status: 404 }; + (error as any).isAxiosError = true; + mockAxiosInstance.get.mockRejectedValue(error); + + await expect( + client.getExperiment({ experimentId: 999 }) + ).rejects.toThrow(); + }); + }); + + describe("getExperimentVariants", () => { + it("should get experiment variants", async () => { + const mockResponse = { + data: { + variants: [ + { + id: 1, + name: "Control", + percentage: 50, + subject: "Control Subject", + preheader: "Control Preheader", + htmlSource: "Control", + plainText: "Control", + }, + { + id: 2, + name: "Variant A", + percentage: 50, + subject: "Variant A Subject", + preheader: "Variant A Preheader", + htmlSource: "Variant A", + plainText: "Variant A", + }, + ], + }, + }; + + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + const result = await client.getExperimentVariants({ experimentId: 123 }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/experiments/123/variants" + ); + expect(result.variants).toHaveLength(2); + expect(result.variants[0].subject).toBe("Control Subject"); + expect(result.variants[1].subject).toBe("Variant A Subject"); + }); + + it("should handle 404 error for non-existent experiment variants", async () => { + const error = new Error("Request failed with status code 404"); + (error as any).response = { status: 404 }; + (error as any).isAxiosError = true; + mockAxiosInstance.get.mockRejectedValue(error); + + await expect( + client.getExperimentVariants({ experimentId: 999 }) + ).rejects.toThrow(); + }); + + it("should handle experiments with optional fields", async () => { + const mockResponse = { + data: { + variants: [ + { + id: 1, + name: "Control", + percentage: 100, + }, + ], + }, + }; + + mockAxiosInstance.get.mockResolvedValue(mockResponse); + + const result = await client.getExperimentVariants({ experimentId: 123 }); + + expect(result.variants).toHaveLength(1); + expect(result.variants[0].subject).toBeUndefined(); + expect(result.variants[0].htmlSource).toBeUndefined(); + }); + }); }); From 70b0205448fcc2d61c106cf671dcef4b14e517b4 Mon Sep 17 00:00:00 2001 From: Marco Morais Date: Tue, 17 Mar 2026 14:38:42 +0000 Subject: [PATCH 2/3] Fix TypeScript errors in experiments tests - Make limit and offset truly optional with .optional().default() - Use optional chaining for array access in tests Co-Authored-By: Claude Sonnet 4.5 --- src/types/experiments.ts | 2 ++ tests/unit/experiments.test.ts | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/types/experiments.ts b/src/types/experiments.ts index 2b8587a..4453a3b 100644 --- a/src/types/experiments.ts +++ b/src/types/experiments.ts @@ -72,12 +72,14 @@ export const ListExperimentsParamsSchema = z .int() .min(1) .max(1000) + .optional() .default(20) .describe("Number of results to return (max 1000, default 20)"), offset: z .number() .int() .min(0) + .optional() .default(0) .describe("Number of results to skip (default 0)"), }) diff --git a/tests/unit/experiments.test.ts b/tests/unit/experiments.test.ts index 8873c65..2970451 100644 --- a/tests/unit/experiments.test.ts +++ b/tests/unit/experiments.test.ts @@ -252,7 +252,7 @@ describe("Experiment Operations", () => { expect(mockAxiosInstance.get).toHaveBeenCalledWith("/api/experiments"); expect(result.experiments).toHaveLength(1); - expect(result.experiments[0].id).toBe(1); + expect(result.experiments[0]?.id).toBe(1); }); it("should list experiments with campaign filter", async () => { @@ -434,8 +434,8 @@ describe("Experiment Operations", () => { "/api/experiments/123/variants" ); expect(result.variants).toHaveLength(2); - expect(result.variants[0].subject).toBe("Control Subject"); - expect(result.variants[1].subject).toBe("Variant A Subject"); + expect(result.variants[0]?.subject).toBe("Control Subject"); + expect(result.variants[1]?.subject).toBe("Variant A Subject"); }); it("should handle 404 error for non-existent experiment variants", async () => { @@ -467,8 +467,8 @@ describe("Experiment Operations", () => { const result = await client.getExperimentVariants({ experimentId: 123 }); expect(result.variants).toHaveLength(1); - expect(result.variants[0].subject).toBeUndefined(); - expect(result.variants[0].htmlSource).toBeUndefined(); + expect(result.variants[0]?.subject).toBeUndefined(); + expect(result.variants[0]?.htmlSource).toBeUndefined(); }); }); }); From ecaf1ca843e27c09d93a0ecf9dfb10b54c7a28ab Mon Sep 17 00:00:00 2001 From: Marco Morais Date: Tue, 17 Mar 2026 14:41:52 +0000 Subject: [PATCH 3/3] Remove .default() from optional params to fix TypeScript types Using .default() after .optional() still makes fields required in the input type. The API will handle default values for limit and offset. Co-Authored-By: Claude Sonnet 4.5 --- src/types/experiments.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/types/experiments.ts b/src/types/experiments.ts index 4453a3b..3cf5205 100644 --- a/src/types/experiments.ts +++ b/src/types/experiments.ts @@ -73,14 +73,12 @@ export const ListExperimentsParamsSchema = z .min(1) .max(1000) .optional() - .default(20) .describe("Number of results to return (max 1000, default 20)"), offset: z .number() .int() .min(0) .optional() - .default(0) .describe("Number of results to skip (default 0)"), }) .describe("Parameters for listing experiments");