From 110c3cb295d3ea664f4716e3d5a2343ab9bf2607 Mon Sep 17 00:00:00 2001 From: Maria Melo Date: Thu, 24 Sep 2026 17:14:01 +0100 Subject: [PATCH] Add experiments API v2 client methods Expose create, lifecycle, totals, and trends calls and align experiment responses with the v2 payload so the MCP server can drive experiments. --- package.json | 2 +- src/client/experiments.ts | 147 +++++++++++- src/types/experiments.ts | 422 +++++++++++++++++++++++++++++---- tests/unit/experiments.test.ts | 332 +++++++++++++++++++++++--- 4 files changed, 818 insertions(+), 85 deletions(-) diff --git a/package.json b/package.json index d72f0c1..fd5afc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@iterable/api", - "version": "0.12.0", + "version": "0.13.0", "description": "TypeScript client library for the Iterable API", "keywords": [ "iterable", diff --git a/src/client/experiments.ts b/src/client/experiments.ts index 4cd7a53..fe06dc7 100644 --- a/src/client/experiments.ts +++ b/src/client/experiments.ts @@ -1,15 +1,25 @@ import { - ExperimentDetails, - ExperimentDetailsSchema, + CopyExperimentVariantParams, + CreateExperimentParams, + DeclareExperimentWinnerParams, + ExperimentIdParams, ExperimentMetricsResponse, + ExperimentResponse, + ExperimentResponseSchema, + ExperimentTotalsResponse, + ExperimentTotalsResponseSchema, + ExperimentTrendsResponse, + ExperimentTrendsResponseSchema, GetExperimentMetricsParams, GetExperimentParams, + GetExperimentTrendsParams, GetExperimentVariantsParams, GetExperimentVariantsResponse, GetExperimentVariantsResponseSchema, ListExperimentsParams, ListExperimentsResponse, ListExperimentsResponseSchema, + UpdateExperimentSettingsParams, } from "../types/experiments.js"; import type { Constructor } from "./base.js"; import type { BaseIterableClient } from "./base.js"; @@ -89,11 +99,11 @@ export function Experiments>( async getExperiment( params: GetExperimentParams - ): Promise { + ): Promise { const url = `/api/experiments/${params.experimentId}`; const response = await this.client.get(url); - return validateResponse(response, ExperimentDetailsSchema); + return validateResponse(response, ExperimentResponseSchema); } async getExperimentVariants( @@ -102,10 +112,133 @@ export function Experiments>( const url = `/api/experiments/${params.experimentId}/variants`; const response = await this.client.get(url); - return validateResponse( - response, - GetExperimentVariantsResponseSchema + return validateResponse(response, GetExperimentVariantsResponseSchema); + } + + async getExperimentTotals( + params: ExperimentIdParams + ): Promise { + const response = await this.client.get( + `/api/experiments/${params.experimentId}/totals` + ); + return validateResponse(response, ExperimentTotalsResponseSchema); + } + + async getExperimentTrends( + params: GetExperimentTrendsParams + ): Promise { + const queryParams = new URLSearchParams(); + if (params.startDateTime) { + queryParams.append("startDateTime", params.startDateTime); + } + if (params.endDateTime) { + queryParams.append("endDateTime", params.endDateTime); + } + const query = queryParams.toString(); + const response = await this.client.get( + `/api/experiments/${params.experimentId}/trends${query ? `?${query}` : ""}` + ); + return validateResponse(response, ExperimentTrendsResponseSchema); + } + + async createExperiment( + params: CreateExperimentParams + ): Promise { + const body: CreateExperimentParams = { + campaignId: params.campaignId, + experimentType: params.experimentType, + }; + if (params.name !== undefined) { + body.name = params.name; + } + + const response = await this.client.post("/api/experiments", body); + return validateResponse(response, ExperimentResponseSchema); + } + + async copyExperimentVariant( + params: CopyExperimentVariantParams + ): Promise { + const body: Omit = { + copyFromTemplateId: params.copyFromTemplateId, + }; + if (params.name !== undefined) { + body.name = params.name; + } + + const response = await this.client.post( + `/api/experiments/${params.experimentId}/variants`, + body + ); + return validateResponse(response, ExperimentResponseSchema); + } + + async updateExperimentSettings( + params: UpdateExperimentSettingsParams + ): Promise { + const body: Omit = {}; + if (params.conversionEventSettings !== undefined) { + body.conversionEventSettings = params.conversionEventSettings; + } + if (params.holdoutSettings !== undefined) { + body.holdoutSettings = params.holdoutSettings; + } + if (params.explorationBlastSettings !== undefined) { + body.explorationBlastSettings = params.explorationBlastSettings; + } + if (params.explorationTriggerSettings !== undefined) { + body.explorationTriggerSettings = params.explorationTriggerSettings; + } + if (params.evenlySplitVariations !== undefined) { + body.evenlySplitVariations = params.evenlySplitVariations; + } + + const response = await this.client.patch( + `/api/experiments/${params.experimentId}/settings`, + body + ); + return validateResponse(response, ExperimentResponseSchema); + } + + async startExperiment( + params: ExperimentIdParams + ): Promise { + const response = await this.client.post( + `/api/experiments/${params.experimentId}/start`, + {} + ); + return validateResponse(response, ExperimentResponseSchema); + } + + async cancelExperiment( + params: ExperimentIdParams + ): Promise { + const response = await this.client.post( + `/api/experiments/${params.experimentId}/cancel`, + {} + ); + return validateResponse(response, ExperimentResponseSchema); + } + + async declareExperimentWinner( + params: DeclareExperimentWinnerParams + ): Promise { + const { experimentId, variantId } = params; + const response = await this.client.post( + `/api/experiments/${experimentId}/winner`, + { variantId } + ); + return validateResponse(response, ExperimentResponseSchema); + } + + async deleteExperiment( + params: ExperimentIdParams + ): Promise { + const response = await this.client.post( + `/api/experiments/${params.experimentId}/delete`, + {} ); + return validateResponse(response, ExperimentResponseSchema); } }; } diff --git a/src/types/experiments.ts b/src/types/experiments.ts index 3cf5205..3698cf9 100644 --- a/src/types/experiments.ts +++ b/src/types/experiments.ts @@ -1,6 +1,10 @@ import { z } from "zod"; -import { IterableDateTimeSchema } from "./common.js"; +import { + FlexibleTimestampSchema, + IterableDateTimeSchema, + IterableISODateTimeSchema, +} from "./common.js"; /** * Experiment metrics schemas and types @@ -99,81 +103,399 @@ export type ListExperimentsResponse = z.infer< * Get experiment schemas and types */ -export const ExperimentVariantSummarySchema = z.object({ +export const GetExperimentParamsSchema = z + .object({ + experimentId: z.number().describe("Experiment ID"), + }) + .describe("Parameters for getting experiment details"); + +export type GetExperimentParams = z.infer; + +export const GetExperimentVariantsParamsSchema = z + .object({ + experimentId: z.number().describe("Experiment ID"), + }) + .describe("Parameters for getting experiment variants"); + +export type GetExperimentVariantsParams = z.infer< + typeof GetExperimentVariantsParamsSchema +>; + +/** + * GET /api/experiments/{id} and the experiment write routes. + * name and campaignId mirror meta. variant.percentage mirrors currentPercentage. + */ + +export const ExperimentApiStatusSchema = z.enum([ + "draft", + "ready", + "running", + "finished", + "winner_found", +]); + +export const ExperimentChannelTypeSchema = z.enum([ + "email", + "push", + "sms", + "in_app", + "web_push", +]); + +export const AllocationModeSchema = z.enum([ + "even_split", + "winner_takes_all", + "multi_armed_bandit", +]); + +export const ExperimentVariantValueSchema = z.object({ + templateId: z.number().nullish().describe("Template ID"), + sendTime: z.string().nullish().describe("Send time (ISO 8601)"), + stoGroup: z.string().nullish().describe("Send-time optimization group"), +}); + +/** + * GET /api/experiments/{id}/variants. + * id mirrors variantId. percentage mirrors currentPercentage. + */ + +export const TemplateContentSchema = z.object({ + subject: z.string().nullish().describe("Email subject line"), + preheader: z.string().nullish().describe("Email preheader"), + htmlSource: z.string().nullish().describe("HTML email content"), + plainText: z.string().nullish().describe("Plain text email content"), +}); + +export const ExperimentVariantContentSchema = z + .object({ + variantId: z.number().describe("Variant ID"), + name: z.string().describe("Variant name"), + value: ExperimentVariantValueSchema.describe("Typed variant value"), + currentPercentage: z.number().describe("Current traffic percentage"), + content: TemplateContentSchema.nullish().describe("Template content"), + }) + .transform((variant) => ({ + ...variant, + id: variant.variantId, + percentage: variant.currentPercentage, + })); + +export const GetExperimentVariantsResponseSchema = z.object({ + experimentId: z.number().describe("Experiment ID"), + variants: z.array(ExperimentVariantContentSchema), +}); + +export type ExperimentVariantContent = z.infer< + typeof ExperimentVariantContentSchema +>; +export type GetExperimentVariantsResponse = z.infer< + typeof GetExperimentVariantsResponseSchema +>; + +export const ExperimentResponseVariantSchema = z.object({ id: z.number().describe("Variant ID"), name: z.string().describe("Variant name"), - percentage: z.number().describe("Traffic percentage"), + value: ExperimentVariantValueSchema.describe("Typed variant value"), + currentPercentage: z.number().describe("Current traffic percentage"), + isWinner: z.boolean().describe("Whether this variant is the winner"), + isControl: z.boolean().describe("Whether this variant is the control"), +}); + +export const ExperimentSizingSchema = z.object({ + holdoutPercentage: z.number().nullish(), + perVariantPercentage: z.number().nullish(), + testGroupPercentage: z.number().nullish(), + winnerGroupPercentage: z.number().nullish(), + testDurationMinutes: z.number().nullish(), + sendsPerVariant: z.number().nullish(), + attributionPeriodHours: z.number().nullish(), +}); + +export const ExperimentMetaSchema = z.object({ + name: z.string().describe("Experiment name"), + conversionMetrics: z.array(z.string()).describe("Conversion metrics"), + campaignId: z.number().nullish().describe("Campaign ID"), + projectId: z.number().describe("Project ID"), + orgId: z.number().describe("Organization ID"), + customConversionId: z + .number() + .nullish() + .describe("Custom conversion catalog ID"), +}); + +export const ExperimentResponseConstraintsSchema = z.object({ + targetSegment: z.string().nullish(), + suppressionListIds: z.array(z.number()), }); -export const ExperimentConstraintsSchema = z +export const ExperimentResponseSchema = z .object({ - startDate: z.string().optional().describe("Experiment start date"), - endDate: z.string().optional().describe("Experiment end date"), - timezone: z.string().optional().describe("Timezone"), + id: z.number().describe("Experiment ID"), + status: ExperimentApiStatusSchema.describe("Experiment status"), + creationDate: FlexibleTimestampSchema.nullish().describe( + "Creation timestamp as epoch time in milliseconds" + ), + startDate: FlexibleTimestampSchema.nullish().describe( + "Start timestamp as epoch time in milliseconds" + ), + finishDate: FlexibleTimestampSchema.nullish().describe( + "Finish timestamp as epoch time in milliseconds" + ), + channelType: ExperimentChannelTypeSchema.describe("Channel type"), + experimentType: z.string().describe("Experiment type"), + allocationMode: AllocationModeSchema.describe("Traffic allocation mode"), + sizing: ExperimentSizingSchema.describe("Traffic allocation sizing"), + meta: ExperimentMetaSchema.describe("Experiment metadata"), + variants: z.array(ExperimentResponseVariantSchema).describe("Variants"), + constraints: ExperimentResponseConstraintsSchema.nullish().describe( + "Experiment constraints" + ), }) - .passthrough(); + .transform((experiment) => ({ + ...experiment, + name: experiment.meta.name, + campaignId: experiment.meta.campaignId, + variants: experiment.variants.map((variant) => ({ + ...variant, + percentage: variant.currentPercentage, + })), + })); + +export type ExperimentResponse = z.infer; + +export const ExperimentDetailsSchema = ExperimentResponseSchema; + +export type ExperimentDetails = ExperimentResponse; -export const ExperimentDetailsSchema = z.object({ +export const ExperimentIdParamsSchema = z + .object({ + experimentId: z.number().describe("Experiment ID"), + }) + .describe("Parameters that identify an experiment"); + +export type ExperimentIdParams = z.infer; + +/** + * GET /api/experiments/{id}/totals + */ + +export const ExperimentVariantTotalsSchema = z.object({ + id: z.number().describe("Variant ID"), + name: z.string().describe("Variant name"), + isControl: z.boolean().describe("Whether this variant is the control"), + isWinner: z.boolean().describe("Whether this variant is the winner"), + metrics: z + .record(z.string(), z.number()) + .describe("Lifetime send and conversion totals"), +}); + +export const ExperimentTotalsResponseSchema = 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" + status: ExperimentApiStatusSchema.describe("Experiment status"), + variants: z.array(ExperimentVariantTotalsSchema), +}); + +export const GetExperimentTotalsParamsSchema = ExperimentIdParamsSchema; + +export type ExperimentTotalsResponse = z.infer< + typeof ExperimentTotalsResponseSchema +>; +export type GetExperimentTotalsParams = z.infer< + typeof GetExperimentTotalsParamsSchema +>; + +/** + * GET /api/experiments/{id}/trends + */ + +export const ExperimentTrendsPointSchema = z.object({ + time: z.string().describe("Bucket start time (ISO 8601)"), + value: z.number().describe("Metric value for this bucket"), +}); + +export const ExperimentTrendsVariantSchema = z.object({ + id: z.number().describe("Variant ID, or -1 for holdout"), + name: z.string().describe("Variant name"), + isControl: z.boolean().describe("Whether this variant is the control"), + isWinner: z.boolean().describe("Whether this variant is the winner"), + series: z + .record(z.string(), z.array(ExperimentTrendsPointSchema)) + .describe("Histogram series keyed by metric name"), +}); + +export const ExperimentTrendsResponseSchema = z.object({ + id: z.number().describe("Experiment ID"), + status: ExperimentApiStatusSchema.describe("Experiment status"), + interval: z.string().describe("Histogram interval"), + startDateTime: z.string().describe("Series start (ISO 8601)"), + endDateTime: z.string().describe("Series end (ISO 8601)"), + variants: z.array(ExperimentTrendsVariantSchema), + holdout: ExperimentTrendsVariantSchema.nullish().describe( + "Holdout series when the experiment has a holdout group" ), }); -export const GetExperimentParamsSchema = z +export const GetExperimentTrendsParamsSchema = z .object({ experimentId: z.number().describe("Experiment ID"), + startDateTime: IterableISODateTimeSchema.optional().describe( + "Series start (ISO 8601). Required when endDateTime is set." + ), + endDateTime: IterableISODateTimeSchema.optional().describe( + "Series end (ISO 8601). Required when startDateTime is set." + ), }) - .describe("Parameters for getting experiment details"); + .refine( + (value) => + (value.startDateTime === undefined) === (value.endDateTime === undefined), + { + message: "Provide both startDateTime and endDateTime, or omit both.", + } + ) + .describe("Parameters for getting experiment trends"); -export type ExperimentVariantSummary = z.infer< - typeof ExperimentVariantSummarySchema +export type ExperimentTrendsResponse = z.infer< + typeof ExperimentTrendsResponseSchema +>; +export type GetExperimentTrendsParams = z.infer< + typeof GetExperimentTrendsParamsSchema >; -export type ExperimentConstraints = z.infer; -export type ExperimentDetails = z.infer; -export type GetExperimentParams = z.infer; /** - * Get experiment variants schemas and types + * Write routes. Success body is the public experiment response. */ -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 ExperimentTypeSchema = z.enum([ + "SubjectLine", + "PreheaderText", + "FromNameAndSender", + "EmailBody", + "WholeEmail", +]); -export const GetExperimentVariantsParamsSchema = z +export const CreateExperimentParamsSchema = z + .object({ + campaignId: z.number().describe("Campaign ID"), + experimentType: ExperimentTypeSchema.describe( + "What to test. Send-time, send-time optimization, and frequency optimization are not supported." + ), + name: z + .string() + .optional() + .describe( + "Display name. If omitted, Iterable uses '{campaign name} Experiment'." + ), + }) + .describe("Parameters for creating a draft campaign experiment"); + +export type CreateExperimentParams = z.infer< + typeof CreateExperimentParamsSchema +>; + +export const CopyExperimentVariantParamsSchema = z .object({ experimentId: z.number().describe("Experiment ID"), + copyFromTemplateId: z + .number() + .describe("Template ID to copy into a new variant"), + name: z.string().optional().describe("Display name for the new variant"), }) - .describe("Parameters for getting experiment variants"); + .describe("Parameters for copying a template as a new variant"); -export const GetExperimentVariantsResponseSchema = z.object({ - variants: z.array(ExperimentVariantContentSchema), +export type CopyExperimentVariantParams = z.infer< + typeof CopyExperimentVariantParamsSchema +>; + +export const ConversionEventSettingsSchema = z.object({ + systemConversionEventType: z + .enum(["Opens", "Clicks", "opens", "clicks"]) + .optional() + .describe( + "System conversion type. Use customConversionId for other events." + ), + customConversionId: z.number().optional().describe("Conversion catalog ID"), }); -export type ExperimentVariantContent = z.infer< - typeof ExperimentVariantContentSchema ->; -export type GetExperimentVariantsParams = z.infer< - typeof GetExperimentVariantsParamsSchema +export const HoldoutSettingsSchema = z.object({ + holdOutGroupSize: z + .number() + .int() + .min(1) + .max(99) + .describe("Holdout group size as a percent from 1 to 99"), + attributionPeriodHours: z + .number() + .int() + .describe("Attribution window in hours"), +}); + +export const BlastExplorationSettingsSchema = z.object({ + testSizePercentage: z + .number() + .int() + .min(1) + .max(99) + .describe("Test group size as a percent from 1 to 99"), + testDurationMinutes: z + .number() + .int() + .describe("Test phase duration in minutes"), +}); + +export const TriggerExplorationSettingsSchema = z.object({ + testSendsPerVariation: z + .number() + .int() + .describe( + "Minimum sends each variant must receive before a winner can be selected" + ), +}); + +export const UpdateExperimentSettingsParamsSchema = z + .object({ + experimentId: z.number().describe("Experiment ID"), + conversionEventSettings: ConversionEventSettingsSchema.nullable() + .optional() + .describe("Conversion settings. Omit to leave unchanged."), + holdoutSettings: HoldoutSettingsSchema.nullable() + .optional() + .describe( + "Holdout settings. Null removes them. Omit to leave unchanged." + ), + explorationBlastSettings: BlastExplorationSettingsSchema.nullable() + .optional() + .describe( + "Blast exploration settings. Null removes them. Omit to leave unchanged." + ), + explorationTriggerSettings: TriggerExplorationSettingsSchema.nullable() + .optional() + .describe( + "Trigger exploration settings. Null removes them. Omit to leave unchanged." + ), + evenlySplitVariations: z + .boolean() + .optional() + .describe( + "Set true to split traffic evenly and clear blast and trigger settings. false is rejected. Omit to leave allocation unchanged." + ), + }) + .describe("Parameters for updating experiment settings"); + +export type UpdateExperimentSettingsParams = z.infer< + typeof UpdateExperimentSettingsParamsSchema >; -export type GetExperimentVariantsResponse = z.infer< - typeof GetExperimentVariantsResponseSchema + +export const DeclareExperimentWinnerParamsSchema = z + .object({ + experimentId: z.number().describe("Experiment ID"), + variantId: z + .number() + .describe( + "Variant ID to declare as the winner. The holdout group cannot be selected." + ), + }) + .describe("Parameters for declaring a winning variant"); + +export type DeclareExperimentWinnerParams = z.infer< + typeof DeclareExperimentWinnerParamsSchema >; diff --git a/tests/unit/experiments.test.ts b/tests/unit/experiments.test.ts index fcfa104..d6665bd 100644 --- a/tests/unit/experiments.test.ts +++ b/tests/unit/experiments.test.ts @@ -353,22 +353,53 @@ describe("Experiment Operations", () => { describe("getExperiment", () => { it("should get experiment details by ID", async () => { - const mockResponse = { + mockAxiosInstance.get.mockResolvedValue({ data: { id: 123, - name: "Test Experiment", status: "running", - campaignId: 456, channelType: "email", - author: "test@example.com", + experimentType: "SubjectLine", + allocationMode: "even_split", + sizing: { + holdoutPercentage: null, + perVariantPercentage: 50, + testGroupPercentage: null, + winnerGroupPercentage: null, + testDurationMinutes: null, + sendsPerVariant: null, + attributionPeriodHours: null, + }, + creationDate: null, + startDate: null, + finishDate: null, + constraints: null, + meta: { + name: "Test Experiment", + conversionMetrics: ["opens"], + campaignId: 456, + projectId: 1, + orgId: 2, + }, variants: [ - { id: 1, name: "Control", percentage: 50 }, - { id: 2, name: "Variant A", percentage: 50 }, + { + id: 1, + name: "Control", + value: { templateId: 10 }, + currentPercentage: 50, + isWinner: false, + isControl: true, + }, + { + id: 2, + name: "Variant A", + value: { templateId: 11 }, + currentPercentage: 50, + isWinner: false, + isControl: false, + }, ], }, - }; - - mockAxiosInstance.get.mockResolvedValue(mockResponse); + }); const result = await client.getExperiment({ experimentId: 123 }); @@ -377,7 +408,12 @@ describe("Experiment Operations", () => { ); expect(result.id).toBe(123); expect(result.name).toBe("Test Experiment"); + expect(result.campaignId).toBe(456); + expect(result.meta.name).toBe("Test Experiment"); + expect(result.allocationMode).toBe("even_split"); expect(result.variants).toHaveLength(2); + expect(result.variants[0]?.percentage).toBe(50); + expect(result.variants[0]?.currentPercentage).toBe(50); }); it("should handle 404 error for non-existent experiment", async () => { @@ -396,24 +432,31 @@ describe("Experiment Operations", () => { it("should get experiment variants", async () => { const mockResponse = { data: { + experimentId: 123, variants: [ { - id: 1, + variantId: 0, name: "Control", - percentage: 50, - subject: "Control Subject", - preheader: "Control Preheader", - htmlSource: "Control", - plainText: "Control", + value: { templateId: 84 }, + currentPercentage: 50, + content: { + subject: "Control Subject", + preheader: "Control Preheader", + htmlSource: "Control", + plainText: "Control", + }, }, { - id: 2, + variantId: 1, name: "Variant A", - percentage: 50, - subject: "Variant A Subject", - preheader: "Variant A Preheader", - htmlSource: "Variant A", - plainText: "Variant A", + value: { templateId: 85 }, + currentPercentage: 50, + content: { + subject: "Variant A Subject", + preheader: "Variant A Preheader", + htmlSource: "Variant A", + plainText: "Variant A", + }, }, ], }, @@ -426,9 +469,14 @@ describe("Experiment Operations", () => { expect(mockAxiosInstance.get).toHaveBeenCalledWith( "/api/experiments/123/variants" ); + expect(result.experimentId).toBe(123); 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]?.variantId).toBe(0); + expect(result.variants[0]?.id).toBe(0); + expect(result.variants[0]?.currentPercentage).toBe(50); + expect(result.variants[0]?.percentage).toBe(50); + expect(result.variants[0]?.content?.subject).toBe("Control Subject"); + expect(result.variants[1]?.content?.subject).toBe("Variant A Subject"); }); it("should handle 404 error for non-existent experiment variants", async () => { @@ -445,11 +493,14 @@ describe("Experiment Operations", () => { it("should handle experiments with optional fields", async () => { const mockResponse = { data: { + experimentId: 123, variants: [ { - id: 1, + variantId: 0, name: "Control", - percentage: 100, + value: { templateId: 84 }, + currentPercentage: 100, + content: null, }, ], }, @@ -460,8 +511,235 @@ 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]?.id).toBe(0); + expect(result.variants[0]?.percentage).toBe(100); + expect(result.variants[0]?.content).toBeNull(); + }); + }); + + describe("experiment write and metrics routes", () => { + const experiment = { + id: 884102, + status: "draft", + channelType: "email", + experimentType: "SubjectLine", + allocationMode: "even_split", + sizing: { + holdoutPercentage: null, + perVariantPercentage: null, + testGroupPercentage: null, + winnerGroupPercentage: null, + testDurationMinutes: null, + sendsPerVariant: null, + attributionPeriodHours: null, + }, + creationDate: null, + startDate: null, + finishDate: null, + constraints: null, + meta: { + name: "Welcome Experiment", + conversionMetrics: ["opens"], + campaignId: 129500, + projectId: 1, + orgId: 2, + }, + variants: [ + { + id: 0, + name: "Control", + value: { templateId: 55 }, + currentPercentage: 50, + isWinner: false, + isControl: true, + }, + ], + }; + + it("gets lifetime totals", async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: { + id: 884102, + status: "running", + lift: 0.2, + variants: [ + { + id: 0, + name: "Control", + isControl: true, + isWinner: false, + confidence: 0.9, + metrics: { sends: 5000, emailOpen: 1250, purchase: 50 }, + }, + ], + }, + }); + + const result = await client.getExperimentTotals({ experimentId: 884102 }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/experiments/884102/totals" + ); + expect(result).toEqual({ + id: 884102, + status: "running", + variants: [ + { + id: 0, + name: "Control", + isControl: true, + isWinner: false, + metrics: { sends: 5000, emailOpen: 1250, purchase: 50 }, + }, + ], + }); + expect(result).not.toHaveProperty("lift"); + expect(result.variants[0]).not.toHaveProperty("confidence"); + }); + + it("gets trends for an explicit date window", async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: { + id: 884102, + status: "running", + interval: "day", + startDateTime: "2024-01-01T00:00:00.000Z", + endDateTime: "2024-01-07T00:00:00.000Z", + variants: [ + { + id: 0, + name: "Control", + isControl: true, + isWinner: false, + series: { + TotalEmailSend: [ + { time: "2024-01-01T00:00:00.000Z", value: 10 }, + ], + }, + }, + ], + holdout: null, + }, + }); + + await client.getExperimentTrends({ + experimentId: 884102, + startDateTime: "2024-01-01T00:00:00.000Z", + endDateTime: "2024-01-07T00:00:00.000Z", + }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/experiments/884102/trends?startDateTime=2024-01-01T00%3A00%3A00.000Z&endDateTime=2024-01-07T00%3A00%3A00.000Z" + ); + }); + + it("gets trends for the default run window", async () => { + mockAxiosInstance.get.mockResolvedValue({ + data: { + id: 884102, + status: "running", + interval: "day", + startDateTime: "2024-01-01T00:00:00.000Z", + endDateTime: "2024-01-07T00:00:00.000Z", + variants: [], + }, + }); + + await client.getExperimentTrends({ experimentId: 884102 }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/experiments/884102/trends" + ); + }); + + it("accepts epoch millisecond timestamps on the experiment body", async () => { + mockAxiosInstance.post.mockResolvedValue({ + data: { + ...experiment, + status: "finished", + creationDate: 1790263999585, + startDate: 1790264000000, + finishDate: 1790265000000, + }, + }); + + const result = await client.cancelExperiment({ experimentId: 884102 }); + + expect(result.creationDate).toBe(1790263999585); + expect(result.startDate).toBe(1790264000000); + expect(result.finishDate).toBe(1790265000000); + }); + + it("creates a draft and omits an absent name", async () => { + mockAxiosInstance.post.mockResolvedValue({ data: experiment }); + + const result = await client.createExperiment({ + campaignId: 129500, + experimentType: "SubjectLine", + }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith("/api/experiments", { + campaignId: 129500, + experimentType: "SubjectLine", + }); + expect(result.id).toBe(884102); + expect(result.sizing.holdoutPercentage).toBeNull(); + expect(result.constraints).toBeNull(); + }); + + it("copies a template into a new variant", async () => { + mockAxiosInstance.post.mockResolvedValue({ data: experiment }); + + await client.copyExperimentVariant({ + experimentId: 884102, + copyFromTemplateId: 55, + name: "Variant A", + }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + "/api/experiments/884102/variants", + { copyFromTemplateId: 55, name: "Variant A" } + ); + }); + + it("patches settings and sends null without omitted fields", async () => { + mockAxiosInstance.patch.mockResolvedValue({ data: experiment }); + + await client.updateExperimentSettings({ + experimentId: 884102, + holdoutSettings: null, + }); + + expect(mockAxiosInstance.patch).toHaveBeenCalledWith( + "/api/experiments/884102/settings", + { holdoutSettings: null } + ); + }); + + it.each([ + ["startExperiment", "/api/experiments/884102/start"], + ["cancelExperiment", "/api/experiments/884102/cancel"], + ["deleteExperiment", "/api/experiments/884102/delete"], + ] as const)("%s posts an empty body", async (method, url) => { + mockAxiosInstance.post.mockResolvedValue({ data: experiment }); + + await client[method]({ experimentId: 884102 }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith(url, {}); + }); + + it("declares a winner", async () => { + mockAxiosInstance.post.mockResolvedValue({ data: experiment }); + + await client.declareExperimentWinner({ + experimentId: 884102, + variantId: 2, + }); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + "/api/experiments/884102/winner", + { variantId: 2 } + ); }); }); });