import { describe, expect, it } from "bun:test"; import type { FlowDismissedPayload, FlowViewedPayload } from "./host"; import { createCommandEnvelope, createCommandEnvelopeWithInstanceId, REACT_NATIVE_WEBVIEW_TRANSPORT_CAPABILITIES, } from "./host"; import { commandEnvelopeWithInstanceIdSchema } from "./host/command-envelope-schema.js"; import type { WebViewTransportWebMessage } from "./index"; import { ACCEPTED_SUBMISSION_FLOW_VERSION_PAYLOAD_GENERATIONS, appEventCapabilitySchema, appEventCustomerOccurrenceSchema, appEventFlagSchema, appEventFlowDismissedSchema, appEventIdentityTypeSchema, appEventOccurrenceTypeSchema, appEventPayloadSchema, appEventSurveyViewedSchema, appEventTrackSchema, attentionPolicyConfigSchema, buildFlowActionSucceededV1, buildFlowActionSucceededV1AppEvent, buildFlowViewedV1, buildFlowViewedV1AppEvent, DEFAULT_ATTENTION_POLICY_CONFIG, DEFAULT_AUTO_DETECT_COLOR_SCHEME_ATTRS, FLOW_ACTION_SUCCEEDED_EVENT_NAME, FLOW_ACTION_SUCCEEDED_EVENT_SCHEMA_ID, FLOW_ACTION_SUCCEEDED_EVENT_SCHEMA_VERSION, FLOW_VERSION_PAYLOAD_GENERATIONS, flowActionSucceededV1ContractSchema, flowActionSucceededV1InputSchema, flowVersionResolutionSchema, getScopeMeta, hostActionDefinitionsObservationRequestSchema, isFlowVersionPayloadGeneration, isPublicGrantScope, isScope, listScopes, parseConfigureOptions, parseInitOptions, parsePublicCommand, parseWebViewTransportNativeMessage, parseWebViewTransportWebMessage, publicGrantScopeIdSchema, publicWidgetCommandKindSchema, SCOPE_IDS, themeVersionResolutionSchema, } from "./index"; describe("@getuserfeedback/protocol root contract", () => { it("exports the fetchable Flow payload generations", () => { expect(FLOW_VERSION_PAYLOAD_GENERATIONS).toEqual(["3", "4"]); expect(isFlowVersionPayloadGeneration("2")).toBe(false); expect(isFlowVersionPayloadGeneration("4")).toBe(true); expect(ACCEPTED_SUBMISSION_FLOW_VERSION_PAYLOAD_GENERATIONS).toEqual([ "3", "4", ]); }); it("exports the canonical scope ids and metadata helpers", () => { expect(SCOPE_IDS).toContain("functionality.storage"); expect(getScopeMeta("analytics.measurement").description).toContain( "analytics events", ); expect(listScopes()).toHaveLength(SCOPE_IDS.length); expect(isScope("analytics.measurement")).toBe(true); expect(isScope("not-a-scope")).toBe(false); expect(isPublicGrantScope("analytics.measurement")).toBe(true); expect(isPublicGrantScope("functionality.storage")).toBe(false); expect( publicGrantScopeIdSchema.safeParse("analytics.measurement").success, ).toBe(true); expect( publicGrantScopeIdSchema.safeParse("functionality.storage").success, ).toBe(false); }); it("exports the canonical auto-detect color scheme defaults", () => { expect(DEFAULT_AUTO_DETECT_COLOR_SCHEME_ATTRS).toEqual([ "class", "data-theme", ]); }); it("bounds the host-action discovery observation contract", () => { expect( hostActionDefinitionsObservationRequestSchema.safeParse({ definitions: [{ key: "enable-feature", version: 1 }], }).success, ).toBe(true); expect( hostActionDefinitionsObservationRequestSchema.safeParse({ definitions: [{ key: "x".repeat(129), version: 1 }], }).success, ).toBe(false); expect( hostActionDefinitionsObservationRequestSchema.safeParse({ definitions: [{ key: "action", version: 0 }], }).success, ).toBe(false); expect( hostActionDefinitionsObservationRequestSchema.safeParse({ definitions: Array.from({ length: 101 }, () => ({ key: "action", version: 1, })), }).success, ).toBe(false); }); it("exports attention policy config defaults and schema", () => { expect( attentionPolicyConfigSchema.parse(DEFAULT_ATTENTION_POLICY_CONFIG), ).toEqual({ automaticInAppWeeklyLimit: 3, version: "attention-policy-default-v1", source: "default", recentFlowCooldownMs: 5 * 60 * 1000, recentInAppCooldownMs: 0, exclusiveSurfaceStrategy: "queue", blockedBroadcastStrategy: "queue", }); }); it("parses widget-config flag and capability inputs via canonical app-event schemas", () => { expect( appEventFlagSchema.parse({ key: "new_checkout", value: "variant_b", variant: "variant_b", }), ).toEqual({ key: "new_checkout", value: "variant_b", variant: "variant_b", }); expect( appEventCapabilitySchema.parse({ key: "checkout.drawer", source: "host-app", }), ).toEqual({ key: "checkout.drawer", source: "host-app", }); }); it("parses init options from the root contract", () => { const result = parseInitOptions({ apiKey: "test-key", colorScheme: { autoDetectColorScheme: ["data-theme"], }, defaultConsent: ["analytics.measurement"], capabilities: ["gx.command.open.v1"], flags: [ { key: "new_checkout", value: "variant_b", variant: "variant_b", provider: { name: "launchdarkly", environment: "production" }, }, ], hostCapabilities: [{ key: "checkout.drawer", source: "host-app" }], hostActions: [{ key: " enable-feature ", version: 1 }], }); expect(result.apiKey).toBe("test-key"); expect(result.defaultConsent).toEqual(["analytics.measurement"]); expect(result.capabilities).toEqual(["gx.command.open.v1"]); expect(result.flags?.[0]).toMatchObject({ key: "new_checkout", value: "variant_b", }); expect(result.hostCapabilities?.[0]).toEqual({ key: "checkout.drawer", source: "host-app", }); expect(result.hostActions).toEqual([{ key: "enable-feature", version: 1 }]); }); it("accepts essential scopes in consent array inputs (CMP compatibility)", () => { const result = parseInitOptions({ apiKey: "test-key", defaultConsent: ["functionality.storage", "analytics.measurement"], }); expect(result.defaultConsent).toEqual([ "functionality.storage", "analytics.measurement", ]); }); it("parses configure options from the root contract", () => { const result = parseConfigureOptions({ auth: { jwt: { token: "jwt-token", }, }, capabilities: [ "checkout.drawer", { key: "checkout.popover", source: "host-app" }, ], consent: ["ads.storage"], }); expect(result.auth?.jwt?.token).toBe("jwt-token"); expect(result.capabilities).toEqual([ "checkout.drawer", { key: "checkout.popover", source: "host-app" }, ]); expect(result.consent).toEqual(["ads.storage"]); }); it("parses public commands from the root contract", () => { const result = parsePublicCommand({ kind: "configure", opts: { consent: ["analytics.storage"], }, }); expect(result.kind).toBe("configure"); if (result.kind === "configure") { expect(result.opts.consent).toEqual(["analytics.storage"]); } }); it("parses public customer track commands from the root contract", () => { const result = parsePublicCommand({ kind: "track", origin: "customer", event: "Checkout Started", properties: { plan: "starter" }, }); expect(result).toEqual({ kind: "track", origin: "customer", event: "Checkout Started", properties: { plan: "starter" }, }); }); it("parses public embedded host context and signal commands from the root contract", () => { expect( parsePublicCommand({ kind: "updateHostContext", context: { url: "app://screen/checkout", page: { path: "/checkout" }, env: { locale: "en-US" }, storage: { cartId: "cart_123" }, }, }), ).toEqual({ kind: "updateHostContext", context: { url: "app://screen/checkout", page: { path: "/checkout" }, env: { locale: "en-US" }, storage: { cartId: "cart_123" }, }, }); expect( parsePublicCommand({ kind: "emitHostSignal", name: "screenFocused", data: { path: "/checkout" }, }), ).toEqual({ kind: "emitHostSignal", name: "screenFocused", data: { path: "/checkout" }, }); }); it("parses theme resolution payloads with runtime artifacts", () => { expect( themeVersionResolutionSchema.parse({ themeRuntimeArtifactId: "thra_xyz", themeVersionId: "tv_xyz", }), ).toEqual({ themeRuntimeArtifactId: "thra_xyz", themeVersionId: "tv_xyz", }); }); it("exposes public widget command kind validation aligned with public payloads", () => { expect(publicWidgetCommandKindSchema.safeParse("open").success).toBe(true); expect(publicWidgetCommandKindSchema.safeParse("telemetry").success).toBe( false, ); }); it("parses public open commands with response metadata from the root contract", () => { const result = parsePublicCommand({ kind: "open", flowId: "sur_123", metadata: { tags: { campaign: "spring-sale", segments: ["beta", "enterprise"], }, }, }); expect(result).toEqual({ kind: "open", flowId: "sur_123", metadata: { tags: { campaign: "spring-sale", segments: ["beta", "enterprise"], }, }, }); }); it("strips legacy open.containerRequirement for enum values any and hostOnly", () => { for (const containerRequirement of ["any", "hostOnly"] as const) { const result = parsePublicCommand({ kind: "open", flowId: "sur_123", flowHandleId: "handle_open_123", containerRequirement, hideCloseButton: true, }); expect(result.kind).toBe("open"); expect(result).toEqual({ kind: "open", flowId: "sur_123", flowHandleId: "handle_open_123", hideCloseButton: true, }); expect("containerRequirement" in result).toBe(false); } }); it("parses public identify commands from the root contract", () => { const withUserId = parsePublicCommand({ kind: "identify", userId: "user_123", traits: { email: "user@example.com", }, options: { externalIds: [ { id: "gid://shopify/Customer/123", type: "shopify_customer_id", collection: "users", encoding: "none", }, ], }, }); const withTraitsOnly = parsePublicCommand({ kind: "identify", traits: { plan: "pro", }, }); expect(withUserId.kind).toBe("identify"); if (withUserId.kind === "identify" && "userId" in withUserId) { expect(withUserId.userId).toBe("user_123"); expect(withUserId.traits).toEqual({ email: "user@example.com", }); expect(withUserId.options?.externalIds).toEqual([ { id: "gid://shopify/Customer/123", type: "shopify_customer_id", collection: "users", encoding: "none", }, ]); } expect(withTraitsOnly.kind).toBe("identify"); if (withTraitsOnly.kind === "identify" && !("userId" in withTraitsOnly)) { expect(withTraitsOnly.traits).toEqual({ plan: "pro", }); } }); it("builds canonical public command envelopes from the host contract", () => { const envelope = createCommandEnvelope({ command: { kind: "configure", opts: { consent: ["analytics.storage"], }, }, instanceId: "instance_123", clientMeta: { loader: "sdk", transport: "snippet", }, }); expect(envelope.version).toBe("1"); expect(envelope.command.kind).toBe("configure"); expect(envelope.instanceId).toBe("instance_123"); expect(envelope.idempotencyKey).toBe(envelope.requestId); expect(envelope.clientMeta).toEqual({ loader: "sdk", transport: "snippet", }); }); it("normalizes blank optional envelope identifiers", () => { const envelope = createCommandEnvelope({ command: { kind: "configure", opts: { consent: ["analytics.storage"], }, }, instanceId: " ", requestId: " request_123 ", idempotencyKey: " ", }); expect(envelope.requestId).toBe("request_123"); expect(envelope.idempotencyKey).toBe("request_123"); expect("instanceId" in envelope).toBe(false); }); it("builds envelopes with required instance ids by construction", () => { const envelope = createCommandEnvelopeWithInstanceId({ command: { kind: "init", opts: { apiKey: "key_test" }, }, instanceId: " instance_123 ", }); const instanceId: string = envelope.instanceId; expect(instanceId).toBe("instance_123"); expect(envelope.command.kind).toBe("init"); }); it("rejects blank required instance ids", () => { expect(() => createCommandEnvelopeWithInstanceId({ command: { kind: "reset" }, instanceId: " ", }), ).toThrow("instanceId must be a non-empty string"); }); it("parses public command envelopes with required instance ids", () => { const result = commandEnvelopeWithInstanceIdSchema.safeParse({ version: "1", instanceId: "instance_123", requestId: "request_123", idempotencyKey: "request_123", command: { kind: "init", opts: { apiKey: "key_test", runtimeEndpoints: { coreUrl: "https://cdn.example.com/core.html", apiUrl: "https://api.example.com/v1", }, }, }, }); expect(result.success).toBe(true); if (result.success) { expect(result.data.instanceId).toBe("instance_123"); expect(result.data.command.kind).toBe("init"); } }); it("rejects public command envelopes without instance ids", () => { const result = commandEnvelopeWithInstanceIdSchema.safeParse({ version: "1", requestId: "request_123", idempotencyKey: "request_123", command: { kind: "reset", }, }); expect(result.success).toBe(false); }); it("rejects internal navigation context commands from the public contract", () => { expect(() => parsePublicCommand({ kind: "hostContextUpdated", context: { url: "https://example.com/pricing", }, }), ).toThrow(); }); it("parses WebView transport queue and host-event messages from the root contract", () => { const envelope = { version: "1", requestId: "request_123", idempotencyKey: "request_123", instanceId: "instance_123", clientMeta: { loader: "custom", clientName: "@getuserfeedback/react-native", protocolCapabilities: [ REACT_NATIVE_WEBVIEW_TRANSPORT_CAPABILITIES.LAYOUT_TRANSITION_POLICY, ], transport: "loader", }, command: { kind: "updateHostContext", context: { url: "app://screen/checkout" }, }, } as const; expect( parseWebViewTransportNativeMessage({ kind: "enqueueCommand", id: "webview_message_1", envelope, }), ).toEqual({ kind: "enqueueCommand", id: "webview_message_1", envelope, }); expect(envelope.clientMeta.protocolCapabilities).toEqual([ REACT_NATIVE_WEBVIEW_TRANSPORT_CAPABILITIES.LAYOUT_TRANSITION_POLICY, ]); expect( parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:flow:state-changed", detail: { instanceId: "instance_123", isOpen: true, isLoading: false, width: 320, height: 480, transitionPolicy: { kind: "timed", durationMs: 180, easing: [0.2, 0, 0, 1], }, flowHandleId: "handle_123", }, }, }), ).toEqual({ kind: "hostEvent", event: { name: "instance:flow:state-changed", detail: { instanceId: "instance_123", isOpen: true, isLoading: false, width: 320, height: 480, transitionPolicy: { kind: "timed", durationMs: 180, easing: [0.2, 0, 0, 1], }, flowHandleId: "handle_123", }, }, }); expect( parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:flow:state-changed:instance", detail: { instanceId: "instance_123", isOpen: false, isLoading: true, }, }, }), ).toEqual({ kind: "hostEvent", event: { name: "instance:flow:state-changed:instance", detail: { instanceId: "instance_123", isOpen: false, isLoading: true, }, }, }); expect( parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:view-host:snapshots-changed", detail: { instanceId: "instance_123", snapshots: [ { kind: "flowRun", flowRunId: "flow_run_123", flowHandleId: "handle_123", isOpen: true, isLoading: false, width: 320, height: 480, transitionSchedule: { sequence: 3, transactionId: "flow_run_123:layout-3", startAtEpochMs: 1_735_689_600_000, }, }, { kind: "pendingFlowHandle", flowHandleId: "handle_456", isOpen: false, isLoading: true, frameAppearance: { kind: "none" }, }, ], }, }, }), ).toEqual({ kind: "hostEvent", event: { name: "instance:view-host:snapshots-changed", detail: { instanceId: "instance_123", snapshots: [ { kind: "flowRun", flowRunId: "flow_run_123", flowHandleId: "handle_123", isOpen: true, isLoading: false, width: 320, height: 480, transitionSchedule: { sequence: 3, transactionId: "flow_run_123:layout-3", startAtEpochMs: 1_735_689_600_000, }, }, { kind: "pendingFlowHandle", flowHandleId: "handle_456", isOpen: false, isLoading: true, frameAppearance: { kind: "none" }, }, ], }, }, }); expect( parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:flow:state-changed:instance", detail: { instanceId: "instance_123", isOpen: false, isLoading: true, shouldRender: true, width: 320, height: 480, flowHandleId: "handle_123", }, }, }), ).toEqual({ kind: "hostEvent", event: { name: "instance:flow:state-changed:instance", detail: { instanceId: "instance_123", isOpen: false, isLoading: true, shouldRender: true, width: 320, height: 480, flowHandleId: "handle_123", }, }, }); expect( parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:command:settled", detail: { requestId: "request_123", instanceId: "instance_123", kind: "updateHostContext", ok: true, }, }, }), ).toEqual({ kind: "hostEvent", event: { name: "instance:command:settled", detail: { requestId: "request_123", instanceId: "instance_123", kind: "updateHostContext", ok: true, }, }, }); expect( parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:command:settled", detail: { requestId: "request_456", instanceId: "instance_123", kind: "open", ok: false, error: { message: "Command failed", code: "COMMAND_FAILED", }, }, }, }), ).toEqual({ kind: "hostEvent", event: { name: "instance:command:settled", detail: { requestId: "request_456", instanceId: "instance_123", kind: "open", ok: false, error: { message: "Command failed", code: "COMMAND_FAILED", }, }, }, }); }); it("rejects WebView transport commands and settlements with invalid state", () => { expect(() => parseWebViewTransportNativeMessage({ kind: "enqueueCommand", id: "webview_message_1", envelope: { version: "1", requestId: "request_123", idempotencyKey: "request_123", command: { kind: "close" }, }, }), ).toThrow(); expect(() => parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:command:settled", detail: { requestId: "request_123", instanceId: "instance_123", kind: "open", ok: false, }, }, }), ).toThrow(); expect(() => parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:command:settled", detail: { requestId: "request_456", instanceId: "instance_123", kind: "open", ok: true, error: { message: "Should not be present", }, }, }, }), ).toThrow(); const blankReasonMessage = { kind: "hostEvent", event: { name: "instance:handle:invalidated", detail: { instanceId: "instance_123", handleKind: "survey", handleId: "handle_123", reasonCode: "STALE_HANDLE", reasonMessage: " ", source: "loader", at: 1_735_689_600_000, }, }, } satisfies WebViewTransportWebMessage; expect(() => parseWebViewTransportWebMessage(blankReasonMessage)).toThrow(); expect(() => parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:flow:state-changed:instance", detail: { instanceId: "instance_123", isOpen: true, isLoading: false, height: 420, transitionPolicy: { kind: "timed", durationMs: 180, easing: [0.2, Number.POSITIVE_INFINITY, 0, 1], }, }, }, }), ).toThrow(); expect(() => parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:view-host:snapshots-changed", detail: { instanceId: "instance_123", snapshots: [ { kind: "flowRun", flowRunId: "run_123", isOpen: true, isLoading: false, frameAppearance: { kind: "page", border: "only" }, }, ], }, }, }), ).toThrow(); const inheritedAppearance = Object.create({ kind: "page", border: "none", borderRadius: "0", boxShadow: "none", }) as unknown; expect(() => parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:view-host:snapshots-changed", detail: { instanceId: "instance_123", snapshots: [ { kind: "flowRun", flowRunId: "run_123", isOpen: true, isLoading: false, frameAppearance: inheritedAppearance, }, ], }, }, }), ).toThrow(); expect(() => parseWebViewTransportWebMessage({ kind: "hostEvent", event: { name: "instance:view-host:snapshots-changed", detail: { instanceId: "instance_123", snapshots: [ { kind: "flowRun", flowRunId: "run_123", isOpen: true, isLoading: false, transitionSchedule: { sequence: -1, transactionId: "run_123:layout-1", startAtEpochMs: 1_000, }, }, ], }, }, }), ).toThrow(); }); it("parses public API flow and theme version resolution payloads", () => { expect( flowVersionResolutionSchema.parse({ flowVersionId: "fv_abc" }), ).toEqual({ flowVersionId: "fv_abc" }); expect( themeVersionResolutionSchema.parse({ themeRuntimeArtifactId: "thra_xyz", themeVersionId: "tv_xyz", }), ).toEqual({ themeRuntimeArtifactId: "thra_xyz", themeVersionId: "tv_xyz", }); expect(() => themeVersionResolutionSchema.parse({ themeVersionId: "tv_xyz" }), ).toThrow(); expect(() => flowVersionResolutionSchema.parse({ flowVersionId: " " }), ).toThrow(); }); it("parses app event track and payload shapes", () => { const track = appEventTrackSchema.parse({ event: "Flow Viewed", references: { surveyId: "sur_1" }, identities: [{ type: "anonymousId", value: "anon" }], context: { flags: [ { key: "new_checkout", value: "variant_b", variant: "variant_b", provider: { name: "launchdarkly", project: "mobile", environment: "production", }, }, ], capabilities: [{ key: "new_checkout" }], }, }); expect(track.event).toBe("Flow Viewed"); expect(track.origin).toBe("system"); expect(track.references.surveyId).toBe("sur_1"); expect(track.context?.flags?.[0]).toMatchObject({ key: "new_checkout", value: "variant_b", }); expect(track.context?.capabilities?.[0]).toEqual({ key: "new_checkout", }); const viewed = appEventPayloadSchema.parse({ origin: "system", event: "Flow Viewed", references: { surveyId: "sur_2" }, properties: { gx_flow_run_id: "run_2" }, }); expect(viewed.event).toBe("Flow Viewed"); expect(viewed.properties).toEqual({ gx_flow_run_id: "run_2" }); expect( appEventPayloadSchema.safeParse({ origin: "system", event: "Flow Viewed", references: { surveyId: "sur_2" }, properties: { arbitrary: "not part of the legacy correlation seam" }, }).success, ).toBe(false); const customer = appEventTrackSchema.parse({ origin: "customer", event: "Checkout Started", properties: { plan: "starter", step: 1, renewal: null, items: ["seat", { count: 2 }], }, }); expect(customer).toEqual({ origin: "customer", event: "Checkout Started", properties: { plan: "starter", step: 1, renewal: null, items: ["seat", { count: 2 }], }, }); expect( appEventTrackSchema.safeParse({ origin: "customer", event: "Checkout Started", properties: { gx_flow_completion_id: "browser-owned" }, }).success, ).toBe(false); for (const properties of [ { callback: () => undefined }, { amount: Number.NaN }, { amount: Number.POSITIVE_INFINITY }, { orderId: BigInt(1) }, { renewedAt: new globalThis.Date("2026-05-08T00:00:00.000Z") }, JSON.parse('{"nested":{"__proto__":{"polluted":true}}}'), ]) { expect(() => appEventTrackSchema.parse({ origin: "customer", event: "Checkout Started", properties, }), ).toThrow(); } expect(() => appEventTrackSchema.parse({ event: "Unknown", references: { surveyId: "sur_1" }, }), ).toThrow(); expect(() => appEventTrackSchema.parse({ origin: "customer", event: "Flow Viewed", }), ).toThrow(); expect(() => appEventTrackSchema.parse({ origin: "customer", event: "flow viewed", }), ).toThrow(); for (const event of ["Flow Action Succeeded", "flow action succeeded"]) { expect(() => appEventTrackSchema.parse({ origin: "customer", event, }), ).toThrow(); } }); it("preserves source-neutral customer occurrence types without defaulting omission", () => { expect(appEventOccurrenceTypeSchema.parse("track")).toBe("track"); expect(appEventOccurrenceTypeSchema.parse("page")).toBe("page"); expect(appEventOccurrenceTypeSchema.parse("screen")).toBe("screen"); const legacyTrack = { origin: "customer" as const, event: "Checkout Started", properties: { plan: "starter" }, }; expect(appEventCustomerOccurrenceSchema.parse(legacyTrack)).toEqual( legacyTrack, ); for (const type of ["page", "screen"] as const) { const parsed = appEventCustomerOccurrenceSchema.parse({ ...legacyTrack, type, }); expect(parsed.type).toBe(type); expect(appEventTrackSchema.parse(parsed)).toEqual(parsed); } for (const type of ["page", "screen"] as const) { expect( appEventPayloadSchema.safeParse({ origin: "system", type, event: "Flow Viewed", references: { surveyId: "sur_1" }, }).success, ).toBe(false); } }); it("builds the exact placement-neutral Flow Action Succeeded v1 contract", () => { const event = buildFlowActionSucceededV1({ flowId: " flow_1 ", flowVersionId: " flow_version_1 ", flowVersionNumber: 3, flowRunId: " flow_run_1 ", flowSurface: "widget", actionPlacementId: " action_placement_1 ", actionInvocationId: " action_invocation_1 ", actionKey: "upgrade-to-pro", actionVersion: 2, actionKind: "runtime-action", }); expect(event).toEqual({ event: FLOW_ACTION_SUCCEEDED_EVENT_NAME, eventSchema: FLOW_ACTION_SUCCEEDED_EVENT_SCHEMA_ID, eventSchemaVersion: FLOW_ACTION_SUCCEEDED_EVENT_SCHEMA_VERSION, properties: { gx_event_schema: "gx_flow_action_succeeded", gx_event_schema_version: 1, gx_flow_id: "flow_1", gx_flow_version_id: "flow_version_1", gx_flow_version_number: 3, gx_flow_run_id: "flow_run_1", gx_flow_surface: "widget", gx_action_id: "action_placement_1", gx_action_invocation_id: "action_invocation_1", gx_action_key: "upgrade-to-pro", gx_action_version: 2, gx_action_kind: "runtime-action", }, }); expect(event.eventSchema).toBe("gx_flow_action_succeeded"); expect(event.eventSchemaVersion).toBeGreaterThan(0); expect( Object.keys(event.properties).every((property) => property.startsWith("gx_"), ), ).toBe(true); expect("gx_flow_ending_id" in event.properties).toBe(false); const payload = buildFlowActionSucceededV1AppEvent({ event, timestamp: 1_786_000_000_000, }); expect(appEventTrackSchema.parse(payload)).toEqual({ origin: "system", ...event, messageId: "gx_flow_action_succeeded:v1:action_invocation_1", timestamp: 1_786_000_000_000, }); expect( appEventTrackSchema.safeParse({ ...payload, messageId: "wrong-invocation", }).success, ).toBe(false); const unknownV2 = { origin: "system" as const, event: FLOW_ACTION_SUCCEEDED_EVENT_NAME, eventSchema: FLOW_ACTION_SUCCEEDED_EVENT_SCHEMA_ID, eventSchemaVersion: 2, messageId: "future-v2-message", timestamp: 1_786_000_000_000, properties: { gx_event_schema: FLOW_ACTION_SUCCEEDED_EVENT_SCHEMA_ID, gx_event_schema_version: 2, gx_future_fact: { nested: true }, }, }; expect(appEventTrackSchema.parse(unknownV2)).toEqual(unknownV2); expect( appEventTrackSchema.safeParse({ ...payload, properties: { ...payload.properties, gx_flow_completion_id: "browser-owned", }, }).success, ).toBe(false); expect( appEventTrackSchema.safeParse({ ...unknownV2, properties: { ...unknownV2.properties, gx_flow_completion_id: "browser-owned", }, }).success, ).toBe(false); expect( appEventTrackSchema.safeParse({ ...unknownV2, properties: { ...unknownV2.properties, gx_event_schema_version: 3, }, }).success, ).toBe(false); }); it("accepts versioned Flow Viewed while preserving the legacy producer corridor", () => { const viewedV1 = buildFlowViewedV1AppEvent({ event: buildFlowViewedV1({ flowId: "flow_1", flowVersionId: "flow_version_1", flowVersionNumber: 2, flowRunId: "flow_run_1", flowSurface: "widget", }), flowOccurrenceId: "view_1", timestamp: 1_786_000_000_000, }); expect(appEventTrackSchema.parse(viewedV1)).toEqual(viewedV1); expect(appEventPayloadSchema.parse(viewedV1)).toEqual(viewedV1); const unknownV2 = { origin: "system" as const, event: "Flow Viewed" as const, eventSchema: "gx_flow_viewed" as const, eventSchemaVersion: 2, messageId: "gx_flow_viewed:v2:future", timestamp: 1_786_000_000_001, properties: { gx_event_schema: "gx_flow_viewed" as const, gx_event_schema_version: 2, gx_future_coordinate: "future", }, }; expect(appEventTrackSchema.parse(unknownV2)).toEqual(unknownV2); expect(appEventPayloadSchema.parse(unknownV2)).toEqual(unknownV2); expect( appEventTrackSchema.safeParse({ ...unknownV2, properties: { ...unknownV2.properties, gx_event_schema_version: 3, }, }).success, ).toBe(false); for (const reserved of [ "gx_response_id", "gx_flow_ending_id", "gx_flow_completion_id", "gx_flow_occurrence_id", "response_signals", ]) { expect( appEventTrackSchema.safeParse({ ...unknownV2, properties: { ...unknownV2.properties, [reserved]: "forbidden" }, }).success, ).toBe(false); } const legacyViewed = { event: "Flow Viewed" as const, references: { surveyId: "sur_1" }, }; expect(appEventTrackSchema.parse(legacyViewed)).toMatchObject({ origin: "system", ...legacyViewed, }); }); it("validates Flow Action Succeeded v1 invocation identity and enums strictly", () => { const validInput = { flowId: "flow_1", flowVersionId: "flow_version_1", flowVersionNumber: 1, flowRunId: "flow_run_1", flowSurface: "web_page" as const, actionPlacementId: "action_placement_1", actionInvocationId: "action_invocation_1", actionKey: "upgrade-to-pro", actionVersion: 1, actionKind: "navigation-action" as const, }; expect(flowActionSucceededV1InputSchema.parse(validInput)).toEqual( validInput, ); for (const invalidInput of [ { ...validInput, flowId: " " }, { ...validInput, actionInvocationId: " " }, { ...validInput, flowVersionNumber: 0 }, { ...validInput, flowVersionNumber: 1.5 }, { ...validInput, flowVersionNumber: Number.MAX_SAFE_INTEGER + 1 }, { ...validInput, actionVersion: 0 }, { ...validInput, flowSurface: "iframe" }, { ...validInput, actionKind: "loader" }, { ...validInput, endingId: "ending_1" }, { ...validInput, responseId: "response_1" }, ]) { expect( flowActionSucceededV1InputSchema.safeParse(invalidInput).success, ).toBe(false); } const event = buildFlowActionSucceededV1(validInput); expect( flowActionSucceededV1ContractSchema.parse({ ...event, properties: { ...event.properties, gx_future_optional: "future-value", }, }).properties.gx_future_optional, ).toBe("future-value"); expect( flowActionSucceededV1ContractSchema.safeParse({ ...event, responseId: "response_1", }).success, ).toBe(false); expect( flowActionSucceededV1ContractSchema.safeParse({ ...event, properties: { ...event.properties, gx_future_optional: () => undefined, }, }).success, ).toBe(false); expect( flowActionSucceededV1ContractSchema.safeParse({ ...event, properties: { ...event.properties, future_optional: "future-value", }, }).success, ).toBe(false); expect( flowActionSucceededV1ContractSchema.safeParse({ ...event, properties: { ...event.properties, gx_flow_ending_id: "ending_1", }, }).success, ).toBe(false); expect( flowActionSucceededV1ContractSchema.safeParse({ ...event, properties: { ...event.properties, gx_response_id: "response_1", }, }).success, ).toBe(false); const propertiesWithPrototypeKey = { ...event.properties, ...JSON.parse('{"__proto__":{"polluted":true}}'), }; expect( flowActionSucceededV1ContractSchema.safeParse({ ...event, properties: propertiesWithPrototypeKey, }).success, ).toBe(false); }); it("keeps flag value validation separate from track properties", () => { expect( appEventFlagSchema.parse({ key: "new_checkout", value: { variant: "treatment", enabled: true, rollout: 50, }, }), ).toEqual({ key: "new_checkout", value: { variant: "treatment", enabled: true, rollout: 50, }, }); expect(() => appEventFlagSchema.parse({ key: "new_checkout", value: { callback: () => undefined }, }), ).toThrow(); }); it("keeps exported system app event schemas compatible with legacy no-origin inputs", () => { const viewed = appEventSurveyViewedSchema.parse({ event: "Flow Viewed", references: { surveyId: "sur_1" }, }); const dismissed = appEventFlowDismissedSchema.parse({ event: "Flow Dismissed", references: { surveyId: "sur_2" }, }); expect(viewed.origin).toBe("system"); expect(dismissed.origin).toBe("system"); }); it("preserves only the supported Flow run property in exported lifecycle schemas", () => { for (const [schema, event] of [ [appEventSurveyViewedSchema, "Flow Viewed"], [appEventFlowDismissedSchema, "Flow Dismissed"], ] as const) { const parsed = schema.parse({ event, references: { surveyId: "sur_1" }, properties: { gx_flow_run_id: "run_1" }, }); expect(parsed.properties).toEqual({ gx_flow_run_id: "run_1" }); expect(() => schema.parse({ event, references: { surveyId: "sur_1" }, properties: { arbitrary: "value" }, }), ).toThrow(); } }); it("keeps legacy system app event SDK input types origin-optional", () => { const viewed: FlowViewedPayload = { event: "Flow Viewed", references: { surveyId: "sur_1" }, properties: { gx_flow_run_id: "run_1" }, }; const dismissed: FlowDismissedPayload = { event: "Flow Dismissed", references: { surveyId: "sur_2" }, properties: { gx_flow_run_id: "run_2" }, }; expect(viewed.origin).toBeUndefined(); expect(dismissed.origin).toBeUndefined(); }); it("suggests canonical identity types for app event aliases", () => { expect(() => appEventTrackSchema.parse({ event: "Flow Viewed", references: { surveyId: "sur_1" }, identities: [{ type: "email", value: "person@example.com" }], }), ).toThrow(/traits\.email/); }); it("keeps identity type guidance on invalid app event identity values", () => { expect(() => appEventIdentityTypeSchema.safeParse("email")).not.toThrow(); const result = appEventIdentityTypeSchema.safeParse("email"); expect(result.success).toBe(false); if (!result.success) { expect(result.error.issues).toEqual([ expect.objectContaining({ code: "custom", message: 'Unsupported identity type "email". Use "traits.email" instead.', }), ]); } expect(() => appEventIdentityTypeSchema.parse("email")).toThrow( /traits\.email/, ); }); it("rejects legacy custom app event identity types", () => { expect(() => appEventIdentityTypeSchema.parse("custom.shopifyCustomerId"), ).toThrow(/context\.externalIds/); }); it("accepts Segment-compatible external IDs in app event context", () => { const result = appEventTrackSchema.parse({ event: "Checkout Started", origin: "customer", context: { externalIds: [ { id: "gid://shopify/123", type: "shopify_customer_id", collection: "users", encoding: "none", }, ], }, }); expect(result.context?.externalIds).toEqual([ { id: "gid://shopify/123", type: "shopify_customer_id", collection: "users", encoding: "none", }, ]); }); it("normalizes external ID values before app event context output", () => { const result = appEventTrackSchema.parse({ event: "Checkout Started", origin: "customer", context: { externalIds: [ { id: " gid://shopify/123 ", type: "shopify_customer_id", collection: "users", encoding: "none", }, ], }, }); expect(result.context?.externalIds).toEqual([ { id: "gid://shopify/123", type: "shopify_customer_id", collection: "users", encoding: "none", }, ]); }); it("caps app event context external IDs to identity cardinality limits", () => { expect(() => appEventTrackSchema.parse({ event: "Checkout Started", origin: "customer", context: { externalIds: Array.from({ length: 21 }, (_, index) => ({ id: `customer_${index}`, type: "shopify_customer_id", collection: "users", encoding: "none", })), }, }), ).toThrow(); }); it("rejects uppercase external ID types", () => { expect(() => appEventTrackSchema.parse({ event: "Checkout Started", origin: "customer", context: { externalIds: [ { id: "gid://shopify/123", type: "Shopify_Customer_Id", collection: "users", encoding: "none", }, ], }, }), ).toThrow(/lowercase/); }); });