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..3cf5205 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) + .optional() + .describe("Number of results to return (max 1000, default 20)"), + offset: z + .number() + .int() + .min(0) + .optional() + .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..2970451 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(); + }); + }); });