From 91d1dd429f25c9040bcb6c62af49fe2243b236e2 Mon Sep 17 00:00:00 2001 From: Greg Methvin Date: Tue, 25 Aug 2026 10:24:04 -0700 Subject: [PATCH] Add typed Journey DSL client support --- src/client/journeys.ts | 20 ++ src/types/journeys.ts | 287 ++++++++++++++++- tests/integration/journeys.test.ts | 182 +++++++++++ tests/unit/journeys.test.ts | 491 +++++++++++++++++++++++++++++ 4 files changed, 979 insertions(+), 1 deletion(-) diff --git a/src/client/journeys.ts b/src/client/journeys.ts index 5c5bd20..49dade5 100644 --- a/src/client/journeys.ts +++ b/src/client/journeys.ts @@ -4,9 +4,14 @@ import { IterableSuccessResponseSchema, } from "../types/common.js"; import { + GetJourneyDslGraphParams, + GetJourneyDslGraphResponse, + GetJourneyDslGraphResponseSchema, GetJourneysParams, GetJourneysResponse, GetJourneysResponseSchema, + JourneyDslTileTypeManifest, + JourneyDslTileTypeManifestSchema, TriggerJourneyParams, } from "../types/journeys.js"; import type { BaseIterableClient, Constructor } from "./base.js"; @@ -51,5 +56,20 @@ export function Journeys>(Base: T) { const response = await this.client.get(url); return validateResponse(response, GetJourneysResponseSchema); } + + async getJourneyDslSchema(): Promise { + const response = await this.client.get("/api/journeys/dsl/schema"); + return validateResponse(response, JourneyDslTileTypeManifestSchema); + } + + async getJourneyDslGraph( + params: GetJourneyDslGraphParams + ): Promise { + const response = await this.client.post( + "/api/journeys/dsl/graph", + params + ); + return validateResponse(response, GetJourneyDslGraphResponseSchema); + } }; } diff --git a/src/types/journeys.ts b/src/types/journeys.ts index 1ff4f7a..f9333b2 100644 --- a/src/types/journeys.ts +++ b/src/types/journeys.ts @@ -10,6 +10,14 @@ import { * Journey (workflow) management schemas and types */ +export const JourneyDraftDetailsSchema = z.object({ + id: z.number(), + createdAt: UnixTimestampSchema, + updatedAt: UnixTimestampSchema, + name: z.string(), + creatorUserId: z.string(), +}); + export const JourneySchema = z.object({ id: z.number(), name: z.string(), @@ -24,6 +32,7 @@ export const JourneySchema = z.object({ createdAt: UnixTimestampSchema, // API docs: "format": "int32" updatedAt: UnixTimestampSchema, // API docs: "format": "int32" creatorUserId: z.string().optional(), + draft: JourneyDraftDetailsSchema.nullable().optional(), }); export const GetJourneysResponseSchema = z.object({ @@ -33,6 +42,7 @@ export const GetJourneysResponseSchema = z.object({ previousPageUrl: z.string().optional(), }); +export type JourneyDraftDetails = z.infer; export type Journey = z.infer; export type GetJourneysResponse = z.infer; @@ -83,6 +93,281 @@ export const TriggerJourneyParamsSchema = z.object({ .describe("Data fields for the journey"), }); -// Type exports +const MAX_JOURNEY_DSL_SELECTIONS = 50; + +/** + * Opaque/pre-1.0 JSON object. Nested values are not interpreted so extra keys + * and evolving blobs such as tileData are not stripped. + */ +const JsonObjectSchema = z.record(z.string(), z.unknown()); + +export const JourneyWorkflowTypeSchema = z + .enum(["Draft", "Published"]) + .describe("Whether to retrieve the draft or published workflow"); + +export const JourneyDslSelectionSchema = z + .object({ + workflowId: z + .number() + .int() + .positive() + .describe( + "Workflow ID from get_journeys: use journey.id for a Published journey, journey.draft.id for its nested draft, or journey.id when journeyType is Draft" + ), + workflowType: JourneyWorkflowTypeSchema.default("Published").describe( + "Use Draft with a draft workflow ID; use Published (the default) with a published workflow ID" + ), + }) + .describe("A draft or published workflow to include in a DSL graph request"); + +function uniqueJourneyDslSelections( + journeys: Array<{ workflowId: number; workflowType: "Draft" | "Published" }>, + ctx: z.RefinementCtx +): void { + const uniqueKeys = new Set( + journeys.map( + ({ workflowId, workflowType }) => `${workflowType}:${workflowId}` + ) + ); + if (uniqueKeys.size !== journeys.length) { + ctx.addIssue({ + code: "custom", + message: + "journeys contains duplicate (workflowType, workflowId) selections", + }); + } +} + +export const GetJourneyDslGraphParamsSchema = z + .object({ + journeys: z + .array(JourneyDslSelectionSchema) + .min(1) + .max(MAX_JOURNEY_DSL_SELECTIONS) + .superRefine(uniqueJourneyDslSelections) + .describe("Workflows to retrieve"), + }) + .describe("Parameters for retrieving Journey DSL graphs"); + +export const JourneyDslSchemaManifestSchema = z + .object({ + version: z.string(), + schemaEndpoint: z.string(), + }) + .passthrough(); + +export const JourneyDslEntityRefSchema = z + .object({ + field: z.string(), + entityType: z.string(), + id: z.number(), + path: z.string().nullable().optional(), + }) + .passthrough(); + +export const JourneyDslValidationIssueSchema = z + .object({ + code: z.string(), + message: z.string(), + nodeId: z.number().nullable().optional(), + }) + .passthrough(); + +export const JourneyDslCompletenessSchema = z + .object({ + requiredFieldsPresent: z.boolean(), + issues: z.array(JourneyDslValidationIssueSchema), + }) + .passthrough(); + +export const JourneyDslNodeSchema = z + .object({ + nodeId: z.number(), + nodeType: z.string(), + title: z.string(), + tileData: JsonObjectSchema, + entityRefs: z.array(JourneyDslEntityRefSchema), + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), + }) + .passthrough(); + +export const JourneyDslEdgeSchema = z + .object({ + srcNodeId: z.number(), + destNodeId: z.number(), + outputIndex: z.number().int(), + label: z.string().nullable().optional(), + }) + .passthrough(); + +export type JourneyDslTriggerRule = { + ruleType: string; + eventName?: string | null; + searchQuery?: Record | null; + subRules?: JourneyDslTriggerRule[] | null; +}; + +export const JourneyDslTriggerRuleSchema: z.ZodType = + z.lazy(() => + z + .object({ + ruleType: z.string(), + eventName: z.string().nullable().optional(), + searchQuery: JsonObjectSchema.nullable().optional(), + subRules: z.array(JourneyDslTriggerRuleSchema).nullable().optional(), + }) + .passthrough() + ); + +export const JourneyDslExitRuleSchema = z + .object({ + id: z.string(), + status: z.string(), + source: z.string(), + triggerRule: JourneyDslTriggerRuleSchema, + sendToJourneyId: z.number().nullable().optional(), + entityRefs: z.array(JourneyDslEntityRefSchema), + }) + .passthrough(); + +export const JourneyDslCustomConversionSchema = z + .object({ + eventName: z.string(), + attributionMode: z.string(), + attributionPeriodHours: z.number().int().nullable().optional(), + }) + .passthrough(); + +export const JourneyDslConversionGoalSchema = z + .object({ + id: z.number(), + isActive: z.boolean(), + conversions: z.array(JourneyDslCustomConversionSchema), + }) + .passthrough(); + +export const JourneyDslJourneySchema = z + .object({ + journeyId: z.number(), + name: z.string(), + description: z.string(), + status: z.string(), + journeyType: JourneyWorkflowTypeSchema, + createdAt: z.string().nullable().optional(), + updatedAt: z.string().nullable().optional(), + simultaneousLimit: z.number().nullable().optional(), + lifetimeLimit: z.number().nullable().optional(), + triggerEventNames: z.array(z.string()), + exitRules: z.array(JourneyDslExitRuleSchema), + conversionGoals: z.array(JourneyDslConversionGoalSchema), + labelIds: z.array(z.number()), + isArchived: z.boolean(), + }) + .passthrough(); + +export const JourneyDslDocumentSchema = z + .object({ + schemaVersion: z.string(), + journey: JourneyDslJourneySchema, + startNodeId: z.number(), + nodes: z.array(JourneyDslNodeSchema), + edges: z.array(JourneyDslEdgeSchema), + completeness: JourneyDslCompletenessSchema, + schema: JourneyDslSchemaManifestSchema, + }) + .passthrough(); + +export const JourneyDslJourneyErrorSchema = z + .object({ + workflowId: z.number(), + workflowType: JourneyWorkflowTypeSchema, + error: z.string(), + message: z.string(), + }) + .passthrough(); + +export const GetJourneyDslGraphResponseSchema = z + .object({ + journeys: z.array(JourneyDslDocumentSchema), + errors: z.array(JourneyDslJourneyErrorSchema), + schema: JourneyDslSchemaManifestSchema, + }) + .passthrough(); + +export const JourneyDslOutputCountSchema = z.union([ + z.number().int().nonnegative(), + z.literal("dynamic"), +]); + +export const JourneyDslTileTypeSchema = z + .object({ + tileType: z.string(), + category: z.string(), + numOutputs: JourneyDslOutputCountSchema, + description: z.string(), + tileDataSchema: JsonObjectSchema, + outputs: z + .array( + z + .object({ + index: z.number().int(), + label: z.string(), + description: z.string(), + }) + .passthrough() + ) + .nullable() + .optional(), + }) + .passthrough(); + +export const JourneyDslTileTypeManifestSchema = z + .object({ + kind: z.string(), + version: z.string(), + tileSchemas: z.array(JourneyDslTileTypeSchema), + entityTypes: z.array(z.object({ entityType: z.string() }).passthrough()), + }) + .passthrough(); + +export type JourneyWorkflowType = z.infer; +export type JourneyDslSelection = z.input; +export type GetJourneyDslGraphParams = z.input< + typeof GetJourneyDslGraphParamsSchema +>; +export type JourneyDslSchemaManifest = z.infer< + typeof JourneyDslSchemaManifestSchema +>; +export type JourneyDslEntityRef = z.infer; +export type JourneyDslValidationIssue = z.infer< + typeof JourneyDslValidationIssueSchema +>; +export type JourneyDslCompleteness = z.infer< + typeof JourneyDslCompletenessSchema +>; +export type JourneyDslNode = z.infer; +export type JourneyDslEdge = z.infer; +export type JourneyDslExitRule = z.infer; +export type JourneyDslCustomConversion = z.infer< + typeof JourneyDslCustomConversionSchema +>; +export type JourneyDslConversionGoal = z.infer< + typeof JourneyDslConversionGoalSchema +>; +export type JourneyDslJourney = z.infer; +export type JourneyDslDocument = z.infer; +export type JourneyDslJourneyError = z.infer< + typeof JourneyDslJourneyErrorSchema +>; +export type GetJourneyDslGraphResponse = z.infer< + typeof GetJourneyDslGraphResponseSchema +>; +export type JourneyDslOutputCount = z.infer; +export type JourneyDslTileType = z.infer; +export type JourneyDslTileTypeManifest = z.infer< + typeof JourneyDslTileTypeManifestSchema +>; + export type GetJourneysParams = z.infer; export type TriggerJourneyParams = z.infer; diff --git a/tests/integration/journeys.test.ts b/tests/integration/journeys.test.ts index 21cd340..0a2e181 100644 --- a/tests/integration/journeys.test.ts +++ b/tests/integration/journeys.test.ts @@ -1,6 +1,12 @@ import { afterAll, beforeAll, describe, expect, it } from "@jest/globals"; import { IterableClient } from "../../src/client"; +import type { + GetJourneyDslGraphResponse, + Journey, + JourneyDslSelection, + JourneyDslTileTypeManifest, +} from "../../src/types/journeys"; // import { expectValidationError } from "../utils/error-matchers"; import { cleanupTestUser, @@ -116,3 +122,179 @@ describe("Journeys Integration Tests", () => { ).rejects.toHaveProperty("statusCode", 400); }); }); + +const describeJourneyDsl = + process.env.ITERABLE_ENABLE_JOURNEY_DSL === "true" ? describe : describe.skip; + +function getJourneyDslSelections( + journeys: Journey[] +): Required[] { + return journeys + .flatMap((journey): Required[] => { + if (journey.journeyType === "Draft") { + return [{ workflowId: journey.id, workflowType: "Draft" }]; + } + + const published: Required = { + workflowId: journey.id, + workflowType: "Published", + }; + const draft = journey.draft + ? [ + { + workflowId: journey.draft.id, + workflowType: "Draft" as const, + }, + ] + : []; + return [published, ...draft]; + }) + .slice(0, 10); +} + +describeJourneyDsl("Journey DSL Integration Tests", () => { + let client: IterableClient; + let dslSchema: JourneyDslTileTypeManifest; + let graphResponse: GetJourneyDslGraphResponse; + let successfulSelection: Required; + + beforeAll(async () => { + client = new IterableClient(); + dslSchema = await withTimeout(client.getJourneyDslSchema()); + + const firstPage = await withTimeout( + client.getJourneys({ page: 1, pageSize: 50 }) + ); + const totalPages = Math.max( + 1, + Math.ceil(firstPage.totalJourneysCount / 50) + ); + const sampledPages = [1, Math.ceil(totalPages / 2), totalPages].filter( + (page, index, pages) => pages.indexOf(page) === index + ); + + const responses: GetJourneyDslGraphResponse[] = []; + for (const pageNumber of sampledPages) { + const page = + pageNumber === 1 + ? firstPage + : await withTimeout( + client.getJourneys({ page: pageNumber, pageSize: 50 }) + ); + const selections = getJourneyDslSelections(page.journeys); + if (selections.length > 0) { + responses.push( + await withTimeout(client.getJourneyDslGraph({ journeys: selections })) + ); + } + } + + if (responses.length === 0) { + throw new Error( + "Journey DSL integration testing requires at least one journey" + ); + } + + graphResponse = { + journeys: responses.flatMap(({ journeys }) => journeys), + errors: responses.flatMap(({ errors }) => errors), + schema: responses[0]!.schema, + }; + const document = graphResponse.journeys[0]; + if (!document) { + const errors = graphResponse.errors + .map(({ workflowId, workflowType, error }) => + [workflowType, workflowId, error].join(":") + ) + .join(", "); + throw new Error( + `Journey DSL integration testing requires at least one readable graph; errors: ${errors}` + ); + } + + successfulSelection = { + workflowId: document.journey.journeyId, + workflowType: + document.journey.journeyType === "Draft" ? "Draft" : "Published", + }; + }); + + afterAll(() => { + client.destroy(); + }); + + it("should retrieve the live tile schema", () => { + expect(dslSchema.kind).toBe("tileTypeManifest"); + expect(dslSchema.version).toBe("0.1"); + expect(dslSchema.tileSchemas.length).toBeGreaterThan(0); + expect(dslSchema.entityTypes.length).toBeGreaterThan(0); + }); + + it("should retrieve a structurally valid graph from discovered journeys", () => { + expect(graphResponse.journeys.length).toBeGreaterThan(0); + + graphResponse.journeys.forEach((document) => { + const nodeIds = new Set(document.nodes.map(({ nodeId }) => nodeId)); + + expect(document.schemaVersion).toBe("0.1"); + expect(document.schema.schemaEndpoint).toBe("/api/journeys/dsl/schema"); + expect(document.nodes.length).toBeGreaterThan(0); + expect(nodeIds.has(document.startNodeId)).toBe(true); + expect(typeof document.completeness.requiredFieldsPresent).toBe( + "boolean" + ); + document.edges.forEach(({ srcNodeId, destNodeId }) => { + expect(nodeIds.has(srcNodeId)).toBe(true); + expect(nodeIds.has(destNodeId)).toBe(true); + }); + document.nodes.forEach(({ tileData }) => { + expect(tileData).toEqual(expect.any(Object)); + }); + }); + }); + + it("should provide a tile schema for every sampled graph node type", () => { + const schemaTileTypes = new Set( + dslSchema.tileSchemas.map(({ tileType }) => tileType) + ); + const graphNodeTypes = new Set( + graphResponse.journeys.flatMap(({ nodes }) => + nodes.map(({ nodeType }) => nodeType) + ) + ); + + expect( + [...graphNodeTypes].filter((nodeType) => !schemaTileTypes.has(nodeType)) + ).toEqual([]); + }); + + it("should return a real graph and a per-journey error in a mixed batch", async () => { + const nonexistentWorkflowId = Number.MAX_SAFE_INTEGER; + const response = await withTimeout( + client.getJourneyDslGraph({ + journeys: [ + successfulSelection, + { + workflowId: nonexistentWorkflowId, + workflowType: "Published", + }, + ], + }) + ); + + expect( + response.journeys.some( + ({ journey }) => journey.journeyId === successfulSelection.workflowId + ) + ).toBe(true); + expect(response.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + workflowId: nonexistentWorkflowId, + workflowType: "Published", + error: "NotFound", + }), + ]) + ); + }); +}); diff --git a/tests/unit/journeys.test.ts b/tests/unit/journeys.test.ts index 9725ea7..4ed49cc 100644 --- a/tests/unit/journeys.test.ts +++ b/tests/unit/journeys.test.ts @@ -8,8 +8,151 @@ import { } from "@jest/globals"; import { IterableClient } from "../../src/client"; +import { IterableResponseValidationError } from "../../src/errors.js"; +import { + GetJourneyDslGraphParamsSchema, + JourneyDslSelectionSchema, +} from "../../src/types/journeys.js"; import { createMockClient } from "../utils/test-helpers"; +const schemaManifest = { + version: "0.1", + schemaEndpoint: "/api/journeys/dsl/schema", +}; + +const dynamicTileData = { + campaignId: 5001, + templateId: 8002, + searchCombo: { + combinator: "And", + searchQueries: [ + { + dataType: "user", + searchCombo: { combinator: "Or", searchQueries: [] }, + }, + ], + }, + channels: { + Email: { campaignId: 11, templateId: 21 }, + Push: { campaignId: 12 }, + }, + opaqueBlob: { nested: { stillHere: true, count: 3 } }, + extraFutureField: "keep-me", +}; + +function createDslDocument(overrides: Record = {}) { + return { + schemaVersion: "0.1", + journey: { + journeyId: 42, + name: "Welcome Journey", + description: "Onboarding", + status: "Running", + journeyType: "Published", + createdAt: "2024-01-02T03:04:05.000Z", + updatedAt: "2024-02-03T04:05:06.000Z", + simultaneousLimit: 1, + lifetimeLimit: null, + triggerEventNames: ["signup"], + exitRules: [ + { + id: "exit-1", + status: "enabled", + source: "global", + triggerRule: { + ruleType: "And", + eventName: null, + searchQuery: { + combinator: "And", + extraNested: { keep: true }, + }, + subRules: [ + { + ruleType: "CustomEventTrigger", + eventName: "unsubscribe", + searchQuery: null, + subRules: null, + }, + ], + }, + sendToJourneyId: 99, + entityRefs: [ + { + field: "sendToJourneyId", + entityType: "journey", + id: 99, + }, + ], + }, + ], + conversionGoals: [ + { + id: 7, + isActive: true, + conversions: [ + { + eventName: "purchase", + attributionMode: "LastTouch", + attributionPeriodHours: 24, + }, + ], + }, + ], + labelIds: [10, 20], + isArchived: false, + }, + startNodeId: 100, + nodes: [ + { + nodeId: 100, + nodeType: "ReceivedApiTriggerTrigger", + title: "Start", + tileData: {}, + entityRefs: [], + }, + { + nodeId: 101, + nodeType: "SendEmailAction", + title: "Send", + tileData: dynamicTileData, + entityRefs: [ + { + field: "campaignId", + entityType: "campaign", + id: 5001, + path: "channels.Email.campaignId", + }, + ], + createdAt: "2024-01-02T03:04:05.000Z", + updatedAt: null, + }, + ], + edges: [ + { + srcNodeId: 100, + destNodeId: 101, + outputIndex: 0, + label: "entered", + }, + ], + completeness: { + requiredFieldsPresent: true, + issues: [], + }, + schema: schemaManifest, + ...overrides, + }; +} + +function createGraphResponse(overrides: Record = {}) { + return { + journeys: [createDslDocument()], + errors: [], + schema: schemaManifest, + ...overrides, + }; +} + describe("Journeys", () => { let client: IterableClient; let mockAxiosInstance: any; @@ -22,6 +165,7 @@ describe("Journeys", () => { afterEach(() => { jest.clearAllMocks(); }); + describe("getJourneys", () => { it("should build pagination query parameters", async () => { const mockResponse = { @@ -124,6 +268,60 @@ describe("Journeys", () => { expect(result.totalJourneysCount).toBe(1); }); + it("should preserve nested draft metadata", async () => { + const mockJourney = { + id: 123, + name: "Published Journey", + description: "Has a draft", + enabled: true, + isArchived: false, + journeyType: "Published", + createdAt: 1673633396379, + updatedAt: 1673633396567, + creatorUserId: "owner@example.com", + draft: { + id: 456, + createdAt: 1673633400000, + updatedAt: 1673633500000, + name: "Draft name", + creatorUserId: "editor@example.com", + }, + }; + mockAxiosInstance.get.mockResolvedValue({ + data: { + journeys: [mockJourney], + totalJourneysCount: 1, + }, + }); + + const result = await client.getJourneys(); + + expect(result.journeys[0]?.draft).toEqual(mockJourney.draft); + }); + + it("should preserve a null draft field", async () => { + const mockJourney = { + id: 123, + name: "Published Journey", + enabled: true, + isArchived: false, + journeyType: "Published", + createdAt: 1673633396379, + updatedAt: 1673633396567, + draft: null, + }; + mockAxiosInstance.get.mockResolvedValue({ + data: { + journeys: [mockJourney], + totalJourneysCount: 1, + }, + }); + + const result = await client.getJourneys(); + + expect(result.journeys[0]?.draft).toBeNull(); + }); + it("should handle pagination metadata properly", async () => { const mockResponse = { data: { @@ -146,4 +344,297 @@ describe("Journeys", () => { ); }); }); + + describe("getJourneyDslSchema", () => { + const manifest = { + kind: "tileTypeManifest", + version: "0.1", + tileSchemas: [ + { + tileType: "SendEmailAction", + category: "action", + numOutputs: 1, + description: "Sends an email", + tileDataSchema: { + type: "object", + properties: { + campaignId: { + type: "integer", + format: "int64", + entityType: "campaign", + }, + searchCombo: { + type: "object", + "x-iterable-opaque": true, + extraVendorKey: { nested: true }, + }, + }, + required: [], + extraSchemaKey: "keep-me", + }, + outputs: [ + { + index: 0, + label: "sent", + description: "Email sent", + }, + ], + }, + { + tileType: "ABSplitFilter", + category: "filter", + numOutputs: "dynamic", + description: "A/B split", + tileDataSchema: { + type: "object", + properties: {}, + required: [], + }, + }, + ], + entityTypes: [{ entityType: "campaign" }, { entityType: "template" }], + }; + + it("should GET the schema endpoint", async () => { + mockAxiosInstance.get.mockResolvedValue({ data: manifest }); + + const result = await client.getJourneyDslSchema(); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith( + "/api/journeys/dsl/schema" + ); + expect(result.kind).toBe("tileTypeManifest"); + expect(result.version).toBe("0.1"); + }); + + it("should preserve opaque and evolving nested schema blobs", async () => { + mockAxiosInstance.get.mockResolvedValue({ data: manifest }); + + const result = await client.getJourneyDslSchema(); + const sendEmail = result.tileSchemas[0]; + + expect(sendEmail?.tileDataSchema).toEqual( + manifest.tileSchemas[0]?.tileDataSchema + ); + expect( + (sendEmail?.tileDataSchema.properties as Record) + .searchCombo + ).toEqual({ + type: "object", + "x-iterable-opaque": true, + extraVendorKey: { nested: true }, + }); + expect(sendEmail?.numOutputs).toBe(1); + expect(result.tileSchemas[1]?.numOutputs).toBe("dynamic"); + }); + }); + + describe("getJourneyDslGraph", () => { + it("should POST the exact URL and body", async () => { + const params = { + journeys: [ + { workflowId: 42, workflowType: "Published" as const }, + { workflowId: 43, workflowType: "Draft" as const }, + ], + }; + mockAxiosInstance.post.mockResolvedValue({ + data: createGraphResponse(), + }); + + await client.getJourneyDslGraph(params); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + "/api/journeys/dsl/graph", + params + ); + }); + + it("should POST omitted workflowType without rewriting the body", async () => { + const params = { journeys: [{ workflowId: 42 }] }; + mockAxiosInstance.post.mockResolvedValue({ + data: createGraphResponse(), + }); + + await client.getJourneyDslGraph(params); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith( + "/api/journeys/dsl/graph", + params + ); + }); + + it("should preserve dynamic tileData and opaque nested JSON", async () => { + const response = createGraphResponse(); + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await client.getJourneyDslGraph({ + journeys: [{ workflowId: 42 }], + }); + + expect(result.journeys[0]?.nodes[1]?.tileData).toEqual(dynamicTileData); + expect( + result.journeys[0]?.journey.exitRules[0]?.triggerRule.searchQuery + ).toEqual({ + combinator: "And", + extraNested: { keep: true }, + }); + expect(result.schema).toEqual(schemaManifest); + }); + + it("should return partial success with journeys and errors", async () => { + const response = createGraphResponse({ + errors: [ + { + workflowId: 43, + workflowType: "Draft", + error: "NotFound", + message: "Journey not found for workflowId=43 workflowType=Draft", + }, + ], + }); + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await client.getJourneyDslGraph({ + journeys: [ + { workflowId: 42, workflowType: "Published" }, + { workflowId: 43, workflowType: "Draft" }, + ], + }); + + expect(result.journeys).toHaveLength(1); + expect(result.journeys[0]?.journey.journeyId).toBe(42); + expect(result.errors).toEqual(response.errors); + }); + + it("should accept an errors-only HTTP 200 body", async () => { + const response = { + journeys: [], + errors: [ + { + workflowId: 42, + workflowType: "Published", + error: "InvalidGraph", + message: "Journey 42 graph is invalid (1 issue(s))", + }, + ], + schema: schemaManifest, + }; + mockAxiosInstance.post.mockResolvedValue({ data: response }); + + const result = await client.getJourneyDslGraph({ + journeys: [{ workflowId: 42 }], + }); + + expect(result.journeys).toEqual([]); + expect(result.errors[0]?.error).toBe("InvalidGraph"); + }); + + it("should throw IterableResponseValidationError for an invalid envelope", async () => { + mockAxiosInstance.post.mockResolvedValue({ + data: { journeys: [] }, + }); + + await expect( + client.getJourneyDslGraph({ journeys: [{ workflowId: 42 }] }) + ).rejects.toBeInstanceOf(IterableResponseValidationError); + }); + }); + + describe("GetJourneyDslGraphParamsSchema", () => { + it("should default workflowType to Published", () => { + const parsed = JourneyDslSelectionSchema.parse({ workflowId: 42 }); + + expect(parsed).toEqual({ + workflowId: 42, + workflowType: "Published", + }); + }); + + it("should accept Draft and Published selections of the same workflowId", () => { + const parsed = GetJourneyDslGraphParamsSchema.parse({ + journeys: [ + { workflowId: 42, workflowType: "Published" }, + { workflowId: 42, workflowType: "Draft" }, + ], + }); + + expect(parsed.journeys).toHaveLength(2); + }); + + it("should accept 50 unique selections", () => { + const journeys = Array.from({ length: 50 }, (_, index) => ({ + workflowId: index + 1, + })); + + expect(() => + GetJourneyDslGraphParamsSchema.parse({ journeys }) + ).not.toThrow(); + }); + + it("should reject an empty journeys array", () => { + expect(() => + GetJourneyDslGraphParamsSchema.parse({ journeys: [] }) + ).toThrow(); + }); + + it("should reject more than 50 selections", () => { + const journeys = Array.from({ length: 51 }, (_, index) => ({ + workflowId: index + 1, + })); + + expect(() => + GetJourneyDslGraphParamsSchema.parse({ journeys }) + ).toThrow(); + }); + + it("should reject duplicate (workflowType, workflowId) selections after defaulting", () => { + const duplicateResult = GetJourneyDslGraphParamsSchema.safeParse({ + journeys: [{ workflowId: 42 }, { workflowId: 42 }], + }); + + expect(duplicateResult.success).toBe(false); + if (!duplicateResult.success) { + expect(duplicateResult.error.issues[0]?.message).toBe( + "journeys contains duplicate (workflowType, workflowId) selections" + ); + } + + expect(() => + GetJourneyDslGraphParamsSchema.parse({ + journeys: [ + { workflowId: 42 }, + { workflowId: 42, workflowType: "Published" }, + ], + }) + ).toThrow(); + }); + + it("should reject non-positive workflow IDs", () => { + expect(() => + GetJourneyDslGraphParamsSchema.parse({ + journeys: [{ workflowId: 0 }], + }) + ).toThrow(); + + expect(() => + GetJourneyDslGraphParamsSchema.parse({ + journeys: [{ workflowId: -1 }], + }) + ).toThrow(); + + expect(() => + GetJourneyDslGraphParamsSchema.parse({ + journeys: [{ workflowId: 1.5 }], + }) + ).toThrow(); + }); + + it("should reject an invalid workflowType", () => { + expect(() => + GetJourneyDslGraphParamsSchema.parse({ + journeys: [{ workflowId: 42, workflowType: "Live" }], + }) + ).toThrow(); + }); + }); });