diff --git a/docs/command-surface.md b/docs/command-surface.md index 80274e23..9e66a0c3 100644 --- a/docs/command-surface.md +++ b/docs/command-surface.md @@ -198,6 +198,12 @@ rc paywalls delete [--force] # attached/published pa # Flows (paywalls, web funnels, and multi-step experiences) rc flows list [--type paywall|web-funnel|general] [--exclude-type ...] # experimental (hidden from --help until Checkpoint rules roll out); repeatable; shows type and publish state +rc flows show [id] # editable draft, including steps and screen content (GET /flows/{id}?expand=steps) +rc flows export [id] --output # save an update request containing the current revision and all steps +rc flows update [id] --config # save the exported JSON or a partial update; only deleted_step_ids removes steps (PATCH /flows/{id}) +rc flows create --config # create an unpublished web-funnel or general flow (type paywall is rejected) +rc flows duplicate [id] [--name ] [--source-version draft|published] # create an unpublished copy +rc flows validate [id] [--revision ] # return saved-draft validation issues # Experiments — development v2 endpoints, normal discoverable CLI surface rc experiments list [--status ] rc experiments show # human detail view shows variants, targeting, placements, and chronological timeline diff --git a/docs/specs/cli-coverage.yaml b/docs/specs/cli-coverage.yaml index 166b91bc..0f4b84b5 100644 --- a/docs/specs/cli-coverage.yaml +++ b/docs/specs/cli-coverage.yaml @@ -289,3 +289,15 @@ endpoints: path: /projects/{project_id}/store_state/plans - method: GET path: /projects/{project_id}/store_state/plans/{plan_id} + + # Flow authoring (development API) + - method: POST + path: /projects/{project_id}/flows + - method: POST + path: /projects/{project_id}/flows/{flow_id}/actions/duplicate + - method: POST + path: /projects/{project_id}/flows/{flow_id}/actions/validate + - method: GET + path: /projects/{project_id}/flows/{flow_id} + - method: PATCH + path: /projects/{project_id}/flows/{flow_id} diff --git a/docs/specs/v2-beta-overlay.yaml b/docs/specs/v2-beta-overlay.yaml index 62a8d760..af637abe 100644 --- a/docs/specs/v2-beta-overlay.yaml +++ b/docs/specs/v2-beta-overlay.yaml @@ -936,6 +936,119 @@ paths: responses: '200': description: OK + post: + summary: Create a Flow draft + operationId: create-flow + x-release-status: development + x-revenuecat-release-gatekeeping: false + x-scopes: + - project_configuration:workflows:read_write + responses: + '200': + description: OK + /projects/{project_id}/flows/{flow_id}/actions/duplicate: + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: flow_id + in: path + required: true + schema: + type: string + post: + summary: Duplicate a Flow + operationId: duplicate-flow + x-release-status: development + x-revenuecat-release-gatekeeping: false + x-scopes: + - project_configuration:workflows:read_write + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DuplicateFlow' + responses: + '200': + description: OK + /projects/{project_id}/flows/{flow_id}/actions/validate: + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: flow_id + in: path + required: true + schema: + type: string + post: + summary: Validate a Flow draft + operationId: validate-flow + x-release-status: development + x-revenuecat-release-gatekeeping: false + x-scopes: + - project_configuration:workflows:read + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateFlow' + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/FlowValidation' + /projects/{project_id}/flows/{flow_id}: + parameters: + - name: project_id + in: path + required: true + schema: + type: string + - name: flow_id + in: path + required: true + schema: + type: string + get: + summary: Get a Flow + operationId: get-flow + x-release-status: development + x-revenuecat-release-gatekeeping: false + x-scopes: + - project_configuration:workflows:read + parameters: + - name: expand + in: query + description: Accepted values are `steps` (adds the draft steps, settings and revision). + required: false + schema: + type: array + items: + type: string + enum: + - steps + responses: + '200': + description: OK + patch: + summary: Update a Flow + operationId: update-flow + x-release-status: development + x-revenuecat-release-gatekeeping: false + x-scopes: + - project_configuration:workflows:read_write + responses: + '200': + description: OK /projects/{project_id}/checkpoints: parameters: - name: project_id @@ -1416,3 +1529,123 @@ components: - ZAR - ZMW - ZWL + DuplicateFlow: + type: object + additionalProperties: false + properties: + display_name: + type: string + description: Name of the duplicate. + minLength: 1 + maxLength: 1500 + example: Onboarding Copy + source_version: + type: string + enum: + - draft + - published + default: draft + description: Which source version to copy. The copy is always unpublished. + example: draft + ValidateFlow: + type: object + additionalProperties: false + properties: + revision: + type: integer + description: Current draft revision. Saves must supply this value to reject + stale writes. + example: 1 + format: int64 + minimum: 1 + FlowValidation: + type: object + additionalProperties: true + required: + - object + - id + - valid + - revision + - step_issues + - workflow_errors + properties: + object: + type: string + enum: + - flow_validation + description: Type of this object. + example: flow_validation + id: + type: string + description: Flow ID. + example: wf_123456789a + valid: + type: boolean + description: Whether the draft can be published. Warnings do not prevent publication. + example: false + revision: + type: integer + description: Current draft revision. Saves must supply this value to reject + stale writes. + example: 1 + format: int64 + minimum: 1 + nullable: true + step_issues: + type: object + description: Issues keyed by step ID. + additionalProperties: + type: array + items: + $ref: '#/components/schemas/FlowValidationIssue' + example: {} + workflow_errors: + type: array + description: Issues affecting the entire flow. + items: + $ref: '#/components/schemas/FlowValidationError' + example: [] + FlowValidationIssue: + type: object + FlowValidationError: + type: object + additionalProperties: true + required: + - key + - message + - severity + properties: + key: + type: string + description: Workflow validation error key. + enum: + - Workflow_ExperimentRunningCombosOverLimit + - Workflow_ExperimentPotentialCombosOverLimit + - Workflow_NoPaywallScreen + - Workflow_RestoreButtonNotOnFinalScreen + - Workflow_NoInitialStep + - Workflow_InitialStepNotFound + - Workflow_NoCheckoutStep + - Workflow_CheckoutNotReachable + - Workflow_CheckoutNoSuccessStep + - Workflow_NoWebConfig + - Workflow_NoWebProducts + - Workflow_ExclusiveRuleMixedOutcome + - Workflow_CookieConsentNoPrivacyPolicy + example: Workflow_ExperimentRunningCombosOverLimit + message: + description: A human-readable description of the issue. + type: string + example: Add a name to your paywall. + severity: + description: The severity of the validation issue. + type: string + enum: + - error + - warning + example: error + action: + type: string + description: Suggested action to resolve the issue. + nullable: true + example: add_screen diff --git a/internal/api/flows.go b/internal/api/flows.go index a21776ba..6e0ac489 100644 --- a/internal/api/flows.go +++ b/internal/api/flows.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "net/http" "net/url" "strconv" @@ -10,7 +11,9 @@ import ( // FlowsService wraps /flows. Feedback surveys are not returned by the API. type FlowsService struct{ c *Client } +// Flow carries the settings and Steps only when fetched with FlowExpandSteps. type Flow struct { + FlowSettings ID string `json:"id"` Object string `json:"object,omitempty"` Name string `json:"name"` @@ -19,6 +22,44 @@ type Flow struct { CreatedAt Millis `json:"created_at,omitempty"` PublishedAt *Millis `json:"published_at"` HasUnpublishedChanges bool `json:"has_unpublished_changes"` + Revision int `json:"revision"` + + Steps map[string]json.RawMessage `json:"steps,omitempty"` +} + +type FlowSettings struct { + DisplayName *string `json:"display_name,omitempty"` + InitialStepID json.RawMessage `json:"initial_step_id,omitempty"` + SingleStepFallbackID json.RawMessage `json:"single_step_fallback_id,omitempty"` + AppID json.RawMessage `json:"app_id,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + CustomContextParametersConfig json.RawMessage `json:"custom_context_parameters_config,omitempty"` + ContentMaxWidth json.RawMessage `json:"content_max_width,omitempty"` +} + +type UpdateFlow struct { + FlowSettings + Revision int `json:"revision"` + UpdatedSteps map[string]json.RawMessage `json:"updated_steps,omitempty"` + DeletedStepIDs []string `json:"deleted_step_ids,omitempty"` +} + +type CreateFlow struct { + FlowSettings + Type string `json:"type,omitempty"` + Steps map[string]json.RawMessage `json:"steps,omitempty"` +} + +type FlowValidationIssue struct { + Key string `json:"key"` + Message string `json:"message"` + Severity string `json:"severity"` + ComponentID *string `json:"component_id,omitempty"` + Property *string `json:"property,omitempty"` + Locale *string `json:"locale,omitempty"` + LocalizationID *string `json:"localization_id,omitempty"` + Path *string `json:"path,omitempty"` + Action *string `json:"action,omitempty"` } // FlowListOptions filters by Flow type. Type and ExcludeType can't be combined. @@ -55,3 +96,47 @@ func (s *FlowsService) List(ctx context.Context, projectID string, opts FlowList } return &out, nil } + +// FlowExpand names the optional sections get-flow can embed. +type FlowExpand string + +// FlowExpandSteps adds the draft steps, settings and revision. +const FlowExpandSteps FlowExpand = "steps" + +func (s *FlowsService) Get(ctx context.Context, projectID, id string, expand ...FlowExpand) (*Flow, error) { + path := pathFlow(projectID, id) + if len(expand) > 0 { + query := url.Values{} + for _, e := range expand { + query.Add("expand", string(e)) + } + path += "?" + query.Encode() + } + var out Flow + err := s.c.do(ctx, http.MethodGet, path, nil, &out) + return &out, err +} + +func (s *FlowsService) Update(ctx context.Context, projectID, id string, body UpdateFlow) (*Flow, error) { + var out Flow + err := s.c.do(ctx, http.MethodPatch, pathFlow(projectID, id), body, &out) + return &out, err +} + +func (s *FlowsService) Create(ctx context.Context, projectID string, body CreateFlow) (*Flow, error) { + var out Flow + err := s.c.do(ctx, http.MethodPost, pathFlows(projectID), body, &out) + return &out, err +} + +func (s *FlowsService) Duplicate(ctx context.Context, projectID, id string, body DuplicateFlow) (*Flow, error) { + var out Flow + err := s.c.do(ctx, http.MethodPost, pathFlowActionsDuplicate(projectID, id), body, &out) + return &out, err +} + +func (s *FlowsService) Validate(ctx context.Context, projectID, id string, body ValidateFlow) (*FlowValidation, error) { + var out FlowValidation + err := s.c.do(ctx, http.MethodPost, pathFlowActionsValidate(projectID, id), body, &out) + return &out, err +} diff --git a/internal/api/flows_authoring_test.go b/internal/api/flows_authoring_test.go new file mode 100644 index 00000000..4b5b551c --- /dev/null +++ b/internal/api/flows_authoring_test.go @@ -0,0 +1,72 @@ +package api_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/revenuecat/cli/internal/api" +) + +func TestFlowAuthoringRoutes(t *testing.T) { + published := api.DuplicateFlowSourceVersionPublished + for _, tc := range []struct { + name, method, suffix, body string + call func(*api.Client) error + }{ + {"get steps", "GET", "?expand=steps", "", func(c *api.Client) error { + _, err := c.Flows.Get(context.Background(), "proj", "wf_1", api.FlowExpandSteps) + return err + }}, + {"update", "PATCH", "", `{"revision":3,"metadata":null,"deleted_step_ids":["old"]}`, func(c *api.Client) error { + _, err := c.Flows.Update(context.Background(), "proj", "wf_1", api.UpdateFlow{FlowSettings: api.FlowSettings{Metadata: json.RawMessage("null")}, Revision: 3, DeletedStepIDs: []string{"old"}}) + return err + }}, + {"duplicate", "POST", "/actions/duplicate", `{"source_version":"published"}`, func(c *api.Client) error { + _, err := c.Flows.Duplicate(context.Background(), "proj", "wf_1", api.DuplicateFlow{SourceVersion: &published}) + return err + }}, + {"validate latest", "POST", "/actions/validate", `{}`, func(c *api.Client) error { + _, err := c.Flows.Validate(context.Background(), "proj", "wf_1", api.ValidateFlow{}) + return err + }}, + {"validate revision", "POST", "/actions/validate", `{"revision":3}`, func(c *api.Client) error { + revision := int64(3) + _, err := c.Flows.Validate(context.Background(), "proj", "wf_1", api.ValidateFlow{Revision: &revision}) + return err + }}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != tc.method || r.URL.RequestURI() != "/projects/proj/flows/wf_1"+tc.suffix { + t.Errorf("request = %s %s", r.Method, r.URL.RequestURI()) + } + body, _ := io.ReadAll(r.Body) + if tc.body == "" { + if len(body) != 0 { + t.Errorf("unexpected body %s", body) + } + } else { + var got, want any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + _ = json.Unmarshal([]byte(tc.body), &want) + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(want) + if string(gotJSON) != string(wantJSON) { + t.Errorf("body = %s, want %s", gotJSON, wantJSON) + } + } + _, _ = io.WriteString(w, `{"id":"wf_1","revision":3,"valid":true}`) + })) + defer server.Close() + if err := tc.call(api.NewClient(api.Options{APIKey: "sk", BaseURL: server.URL})); err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/internal/api/paths_gen.go b/internal/api/paths_gen.go index ab8439b0..60d00b75 100644 --- a/internal/api/paths_gen.go +++ b/internal/api/paths_gen.go @@ -162,6 +162,18 @@ func pathExperiments(projectID string) string { return encodePath("projects", projectID, "experiments") } +func pathFlow(projectID string, flowID string) string { + return encodePath("projects", projectID, "flows", flowID) +} + +func pathFlowActionsDuplicate(projectID string, flowID string) string { + return encodePath("projects", projectID, "flows", flowID, "actions", "duplicate") +} + +func pathFlowActionsValidate(projectID string, flowID string) string { + return encodePath("projects", projectID, "flows", flowID, "actions", "validate") +} + func pathFlows(projectID string) string { return encodePath("projects", projectID, "flows") } diff --git a/internal/api/types_gen.go b/internal/api/types_gen.go index 0ec9f15e..43fefc2e 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -1975,6 +1975,24 @@ func (e DeletedObjectObject) Valid() bool { } } +// Defines values for DuplicateFlowSourceVersion. +const ( + DuplicateFlowSourceVersionDraft DuplicateFlowSourceVersion = "draft" + DuplicateFlowSourceVersionPublished DuplicateFlowSourceVersion = "published" +) + +// Valid indicates whether the value is a known member of the DuplicateFlowSourceVersion enum. +func (e DuplicateFlowSourceVersion) Valid() bool { + switch e { + case DuplicateFlowSourceVersionDraft: + return true + case DuplicateFlowSourceVersionPublished: + return true + default: + return false + } +} + // Defines values for Duration. const ( P1M Duration = "P1M" @@ -2155,6 +2173,90 @@ func (e ExtendSubscriptionUntilDateExtendReasonCode) Valid() bool { } } +// Defines values for FlowValidationObject. +const ( + FlowValidationObjectFlowValidation FlowValidationObject = "flow_validation" +) + +// Valid indicates whether the value is a known member of the FlowValidationObject enum. +func (e FlowValidationObject) Valid() bool { + switch e { + case FlowValidationObjectFlowValidation: + return true + default: + return false + } +} + +// Defines values for FlowValidationErrorKey. +const ( + WorkflowCheckoutNoSuccessStep FlowValidationErrorKey = "Workflow_CheckoutNoSuccessStep" + WorkflowCheckoutNotReachable FlowValidationErrorKey = "Workflow_CheckoutNotReachable" + WorkflowCookieConsentNoPrivacyPolicy FlowValidationErrorKey = "Workflow_CookieConsentNoPrivacyPolicy" + WorkflowExclusiveRuleMixedOutcome FlowValidationErrorKey = "Workflow_ExclusiveRuleMixedOutcome" + WorkflowExperimentPotentialCombosOverLimit FlowValidationErrorKey = "Workflow_ExperimentPotentialCombosOverLimit" + WorkflowExperimentRunningCombosOverLimit FlowValidationErrorKey = "Workflow_ExperimentRunningCombosOverLimit" + WorkflowInitialStepNotFound FlowValidationErrorKey = "Workflow_InitialStepNotFound" + WorkflowNoCheckoutStep FlowValidationErrorKey = "Workflow_NoCheckoutStep" + WorkflowNoInitialStep FlowValidationErrorKey = "Workflow_NoInitialStep" + WorkflowNoPaywallScreen FlowValidationErrorKey = "Workflow_NoPaywallScreen" + WorkflowNoWebConfig FlowValidationErrorKey = "Workflow_NoWebConfig" + WorkflowNoWebProducts FlowValidationErrorKey = "Workflow_NoWebProducts" + WorkflowRestoreButtonNotOnFinalScreen FlowValidationErrorKey = "Workflow_RestoreButtonNotOnFinalScreen" +) + +// Valid indicates whether the value is a known member of the FlowValidationErrorKey enum. +func (e FlowValidationErrorKey) Valid() bool { + switch e { + case WorkflowCheckoutNoSuccessStep: + return true + case WorkflowCheckoutNotReachable: + return true + case WorkflowCookieConsentNoPrivacyPolicy: + return true + case WorkflowExclusiveRuleMixedOutcome: + return true + case WorkflowExperimentPotentialCombosOverLimit: + return true + case WorkflowExperimentRunningCombosOverLimit: + return true + case WorkflowInitialStepNotFound: + return true + case WorkflowNoCheckoutStep: + return true + case WorkflowNoInitialStep: + return true + case WorkflowNoPaywallScreen: + return true + case WorkflowNoWebConfig: + return true + case WorkflowNoWebProducts: + return true + case WorkflowRestoreButtonNotOnFinalScreen: + return true + default: + return false + } +} + +// Defines values for FlowValidationErrorSeverity. +const ( + FlowValidationErrorSeverityError FlowValidationErrorSeverity = "error" + FlowValidationErrorSeverityWarning FlowValidationErrorSeverity = "warning" +) + +// Valid indicates whether the value is a known member of the FlowValidationErrorSeverity enum. +func (e FlowValidationErrorSeverity) Valid() bool { + switch e { + case FlowValidationErrorSeverityError: + return true + case FlowValidationErrorSeverityWarning: + return true + default: + return false + } +} + // Defines values for IndicativePriceObject. const ( IndicativePriceObjectIndicativePrice IndicativePriceObject = "indicative_price" @@ -17152,6 +17254,21 @@ func (e GetProductsFromEntitlement503JSONResponseBodyType) Valid() bool { } } +// Defines values for GetFlowParamsExpand. +const ( + Steps GetFlowParamsExpand = "steps" +) + +// Valid indicates whether the value is a known member of the GetFlowParamsExpand enum. +func (e GetFlowParamsExpand) Valid() bool { + switch e { + case Steps: + return true + default: + return false + } +} + // Defines values for ListWebhookIntegrations400JSONResponseBodyObject. const ( ListWebhookIntegrations400JSONResponseBodyObjectError ListWebhookIntegrations400JSONResponseBodyObject = "error" @@ -35643,6 +35760,24 @@ type DeletedObject struct { // DeletedObjectObject The type of the deleted object type DeletedObjectObject string +// DuplicateFlow defines model for DuplicateFlow. +type DuplicateFlow struct { + // DisplayName Name of the duplicate. + // + // Example: Onboarding Copy + DisplayName *string `json:"display_name,omitempty"` + + // SourceVersion Which source version to copy. The copy is always unpublished. + // + // Example: draft + SourceVersion *DuplicateFlowSourceVersion `json:"source_version,omitempty"` +} + +// DuplicateFlowSourceVersion Which source version to copy. The copy is always unpublished. +// +// Example: draft +type DuplicateFlowSourceVersion string + // Duration The duration of the product subscription. This field is only supported for the test store and it is ignored for other stores. // // Example: P1W @@ -35787,6 +35922,79 @@ type ExtendSubscriptionUntilDate struct { // Example: customer_satisfaction type ExtendSubscriptionUntilDateExtendReasonCode string +// FlowValidation defines model for FlowValidation. +type FlowValidation struct { + // ID Flow ID. + // + // Example: wf_123456789a + ID string `json:"id"` + + // Object Type of this object. + // + // Example: flow_validation + Object FlowValidationObject `json:"object"` + + // Revision Current draft revision. Saves must supply this value to reject stale writes. + // + // Example: 1 + Revision *int64 `json:"revision"` + + // StepIssues Issues keyed by step ID. + // + // Example: {} + StepIssues map[string][]FlowValidationIssue `json:"step_issues"` + + // Valid Whether the draft can be published. Warnings do not prevent publication. + // + // Example: false + Valid bool `json:"valid"` + + // WorkflowErrors Issues affecting the entire flow. + // + // Example: [] + WorkflowErrors []FlowValidationError `json:"workflow_errors"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// FlowValidationObject Type of this object. +// +// Example: flow_validation +type FlowValidationObject string + +// FlowValidationError defines model for FlowValidationError. +type FlowValidationError struct { + // Action Suggested action to resolve the issue. + // + // Example: add_screen + Action *string `json:"action,omitempty"` + + // Key Workflow validation error key. + // + // Example: Workflow_ExperimentRunningCombosOverLimit + Key FlowValidationErrorKey `json:"key"` + + // Message A human-readable description of the issue. + // + // Example: Add a name to your paywall. + Message string `json:"message"` + + // Severity The severity of the validation issue. + // + // Example: error + Severity FlowValidationErrorSeverity `json:"severity"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// FlowValidationErrorKey Workflow validation error key. +// +// Example: Workflow_ExperimentRunningCombosOverLimit +type FlowValidationErrorKey string + +// FlowValidationErrorSeverity The severity of the validation issue. +// +// Example: error +type FlowValidationErrorSeverity string + // IndicativePrice An indicative price for a product, in the default currency/country (USD / US). Uses the same price source as the get-offering-prices endpoint. type IndicativePrice struct { // AmountMicros The price in micros. For example, USD 1 would be represented as 1000000 @@ -38074,6 +38282,14 @@ type UpdateWebhookIntegrationInput struct { // UpdateWebhookIntegrationInputEnvironment The environment the webhook integration is configured for type UpdateWebhookIntegrationInputEnvironment string +// ValidateFlow defines model for ValidateFlow. +type ValidateFlow struct { + // Revision Current draft revision. Saves must supply this value to reject stale writes. + // + // Example: 1 + Revision *int64 `json:"revision,omitempty"` +} + // VirtualCurrency defines model for VirtualCurrency. type VirtualCurrency struct { // Code The unique code for this virtual currency @@ -41596,6 +41812,15 @@ type GetProductsFromEntitlement503JSONResponseBodyObject string // GetProductsFromEntitlement503JSONResponseBodyType defines parameters for GetProductsFromEntitlement. type GetProductsFromEntitlement503JSONResponseBodyType string +// GetFlowParams defines parameters for GetFlow. +type GetFlowParams struct { + // Expand Accepted values are `steps` (adds the draft steps, settings and revision). + Expand *[]GetFlowParamsExpand `form:"expand,omitempty" json:"expand,omitempty"` +} + +// GetFlowParamsExpand defines parameters for GetFlow. +type GetFlowParamsExpand string + // ListWebhookIntegrationsParams defines parameters for ListWebhookIntegrations. type ListWebhookIntegrationsParams struct { // StartingAfter Pagination cursor. Pass the id of the last item from the previous page to fetch the next page; omit for the first page. @@ -45432,6 +45657,12 @@ type AttachProductsToEntitlementJSONRequestBody AttachProductsToEntitlementJSONB // DetachProductsFromEntitlementJSONRequestBody defines body for DetachProductsFromEntitlement for application/json ContentType. type DetachProductsFromEntitlementJSONRequestBody DetachProductsFromEntitlementJSONBody +// DuplicateFlowJSONRequestBody defines body for DuplicateFlow for application/json ContentType. +type DuplicateFlowJSONRequestBody = DuplicateFlow + +// ValidateFlowJSONRequestBody defines body for ValidateFlow for application/json ContentType. +type ValidateFlowJSONRequestBody = ValidateFlow + // CreateWebhookIntegrationJSONRequestBody defines body for CreateWebhookIntegration for application/json ContentType. type CreateWebhookIntegrationJSONRequestBody = CreateWebhookIntegrationInput @@ -46500,6 +46731,248 @@ func (a ChartSeries) MarshalJSON() ([]byte, error) { return json.Marshal(object) } +// Getter for additional properties for FlowValidation. Returns the specified +// element and whether it was found +func (a FlowValidation) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for FlowValidation +func (a *FlowValidation) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for FlowValidation to handle AdditionalProperties +func (a *FlowValidation) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["id"]; found { + err = json.Unmarshal(raw, &a.ID) + if err != nil { + return fmt.Errorf("error reading 'id': %w", err) + } + delete(object, "id") + } + + if raw, found := object["object"]; found { + err = json.Unmarshal(raw, &a.Object) + if err != nil { + return fmt.Errorf("error reading 'object': %w", err) + } + delete(object, "object") + } + + if raw, found := object["revision"]; found { + err = json.Unmarshal(raw, &a.Revision) + if err != nil { + return fmt.Errorf("error reading 'revision': %w", err) + } + delete(object, "revision") + } + + if raw, found := object["step_issues"]; found { + err = json.Unmarshal(raw, &a.StepIssues) + if err != nil { + return fmt.Errorf("error reading 'step_issues': %w", err) + } + delete(object, "step_issues") + } + + if raw, found := object["valid"]; found { + err = json.Unmarshal(raw, &a.Valid) + if err != nil { + return fmt.Errorf("error reading 'valid': %w", err) + } + delete(object, "valid") + } + + if raw, found := object["workflow_errors"]; found { + err = json.Unmarshal(raw, &a.WorkflowErrors) + if err != nil { + return fmt.Errorf("error reading 'workflow_errors': %w", err) + } + delete(object, "workflow_errors") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for FlowValidation to handle AdditionalProperties +func (a FlowValidation) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + object["id"], err = json.Marshal(a.ID) + if err != nil { + return nil, fmt.Errorf("error marshaling 'id': %w", err) + } + + object["object"], err = json.Marshal(a.Object) + if err != nil { + return nil, fmt.Errorf("error marshaling 'object': %w", err) + } + + object["revision"], err = json.Marshal(a.Revision) + if err != nil { + return nil, fmt.Errorf("error marshaling 'revision': %w", err) + } + + if a.StepIssues != nil { + object["step_issues"], err = json.Marshal(a.StepIssues) + if err != nil { + return nil, fmt.Errorf("error marshaling 'step_issues': %w", err) + } + } + + object["valid"], err = json.Marshal(a.Valid) + if err != nil { + return nil, fmt.Errorf("error marshaling 'valid': %w", err) + } + + if a.WorkflowErrors != nil { + object["workflow_errors"], err = json.Marshal(a.WorkflowErrors) + if err != nil { + return nil, fmt.Errorf("error marshaling 'workflow_errors': %w", err) + } + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// Getter for additional properties for FlowValidationError. Returns the specified +// element and whether it was found +func (a FlowValidationError) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for FlowValidationError +func (a *FlowValidationError) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for FlowValidationError to handle AdditionalProperties +func (a *FlowValidationError) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["action"]; found { + err = json.Unmarshal(raw, &a.Action) + if err != nil { + return fmt.Errorf("error reading 'action': %w", err) + } + delete(object, "action") + } + + if raw, found := object["key"]; found { + err = json.Unmarshal(raw, &a.Key) + if err != nil { + return fmt.Errorf("error reading 'key': %w", err) + } + delete(object, "key") + } + + if raw, found := object["message"]; found { + err = json.Unmarshal(raw, &a.Message) + if err != nil { + return fmt.Errorf("error reading 'message': %w", err) + } + delete(object, "message") + } + + if raw, found := object["severity"]; found { + err = json.Unmarshal(raw, &a.Severity) + if err != nil { + return fmt.Errorf("error reading 'severity': %w", err) + } + delete(object, "severity") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for FlowValidationError to handle AdditionalProperties +func (a FlowValidationError) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + if a.Action != nil { + object["action"], err = json.Marshal(a.Action) + if err != nil { + return nil, fmt.Errorf("error marshaling 'action': %w", err) + } + } + + object["key"], err = json.Marshal(a.Key) + if err != nil { + return nil, fmt.Errorf("error marshaling 'key': %w", err) + } + + object["message"], err = json.Marshal(a.Message) + if err != nil { + return nil, fmt.Errorf("error marshaling 'message': %w", err) + } + + object["severity"], err = json.Marshal(a.Severity) + if err != nil { + return nil, fmt.Errorf("error marshaling 'severity': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + // Getter for additional properties for MacAppStoreApp. Returns the specified // element and whether it was found func (a MacAppStoreApp) Get(fieldName string) (value interface{}, found bool) { diff --git a/internal/cli/experiment_config.go b/internal/cli/experiment_config.go index b9e7d3c5..c495f9e8 100644 --- a/internal/cli/experiment_config.go +++ b/internal/cli/experiment_config.go @@ -11,6 +11,10 @@ import ( const maxExperimentConfigBytes = 1 << 20 func readJSONConfig(path string, out any) error { + return readJSONConfigWithLimit(path, out, maxExperimentConfigBytes) +} + +func readJSONConfigWithLimit(path string, out any, maxBytes int64) error { var reader io.Reader if path == "-" { reader = os.Stdin @@ -22,12 +26,12 @@ func readJSONConfig(path string, out any) error { defer file.Close() reader = file } - data, err := io.ReadAll(io.LimitReader(reader, maxExperimentConfigBytes+1)) + data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1)) if err != nil { return err } - if len(data) > maxExperimentConfigBytes { - return fmt.Errorf("config exceeds 1 MiB") + if int64(len(data)) > maxBytes { + return fmt.Errorf("config exceeds %d MiB", maxBytes>>20) } decoder := json.NewDecoder(bytes.NewReader(data)) decoder.DisallowUnknownFields() diff --git a/internal/cli/flows.go b/internal/cli/flows.go index 01de574b..68e05061 100644 --- a/internal/cli/flows.go +++ b/internal/cli/flows.go @@ -1,6 +1,9 @@ package cli import ( + "context" + "fmt" + "github.com/spf13/cobra" "github.com/revenuecat/cli/internal/api" @@ -13,13 +16,15 @@ func newFlowsCmd() *cobra.Command { Aliases: []string{"flow"}, // Hidden from --help until Checkpoint rules roll out. Annotations: map[string]string{annotationSurface: surfaceExperimental}, - Short: "View Flows (paywalls, web funnels, and multi-step experiences)", + Short: "Manage Flows (paywalls, web funnels, and multi-step experiences)", Long: `A Flow is a paywall, web funnel, or other multi-step experience built in the RevenueCat dashboard. Checkpoint rules decide which Flow a customer sees.`, Example: ` rc flows list rc flows list --type paywall`, } cmd.AddCommand(newFlowsListCmd()) + cmd.AddCommand(newFlowsShowCmd(), newFlowsExportCmd(), newFlowsUpdateCmd(), + newFlowsCreateCmd(), newFlowsDuplicateCmd(), newFlowsValidateCmd()) return cmd } @@ -79,3 +84,15 @@ func flowPublishState(f api.Flow) string { return formatMillis(int64(*f.PublishedAt)) } } + +func flowPickerItems(ctx context.Context, client *api.Client, projectID string) ([]PickerItem, error) { + page, err := client.Flows.List(ctx, projectID, api.FlowListOptions{}) + if err != nil { + return nil, err + } + items := make([]PickerItem, len(page.Items)) + for i, f := range page.Items { + items[i] = PickerItem{ID: f.ID, Label: fmt.Sprintf("%s (%s)", f.Name, f.Type)} + } + return items, nil +} diff --git a/internal/cli/flows_authoring.go b/internal/cli/flows_authoring.go new file mode 100644 index 00000000..c4ff4566 --- /dev/null +++ b/internal/cli/flows_authoring.go @@ -0,0 +1,248 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/revenuecat/cli/internal/api" +) + +const maxFlowConfigBytes = 5 << 20 + +func newFlowsShowCmd() *cobra.Command { + return &cobra.Command{ + Use: "show [id]", Short: "Show a Flow's editable draft", Args: cobra.MaximumNArgs(1), + Example: ` rc flows show wf_abc + rc flows show wf_abc --json`, + RunE: func(cmd *cobra.Command, args []string) error { + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err + } + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) + if err != nil { + return err + } + draft, err := client.Flows.Get(cmd.Context(), projectID, flowID, api.FlowExpandSteps) + if err != nil { + return err + } + return rt.Out.Render(draft) + }, + } +} + +func newFlowsExportCmd() *cobra.Command { + var path string + cmd := &cobra.Command{ + Use: "export [id]", Short: "Export an editable Flow update to a JSON file", Args: cobra.MaximumNArgs(1), + Long: "Writes the current revision, settings, and updated_steps for use with flows update --config. Steps omitted from updated_steps are preserved. Add IDs to deleted_step_ids to remove them.", + Example: ` rc flows export wf_abc --output flow.json + rc flows update wf_abc --config flow.json`, + RunE: func(cmd *cobra.Command, args []string) error { + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err + } + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) + if err != nil { + return err + } + draft, err := client.Flows.Get(cmd.Context(), projectID, flowID, api.FlowExpandSteps) + if err != nil { + return err + } + body := api.UpdateFlow{FlowSettings: draft.FlowSettings, Revision: draft.Revision, UpdatedSteps: draft.Steps} + data, err := json.MarshalIndent(body, "", " ") + if err != nil { + return err + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + return err + } + return rt.Out.Render(struct { + Path string `json:"path"` + FlowID string `json:"flow_id"` + Revision int `json:"revision"` + }{path, draft.ID, draft.Revision}) + }, + } + cmd.Flags().StringVar(&path, "output", "", "write the editable update to this file") + _ = cmd.MarkFlagRequired("output") + return cmd +} + +func newFlowsUpdateCmd() *cobra.Command { + var config string + cmd := &cobra.Command{ + Use: "update [id]", Short: "Save changes to a Flow draft", Args: cobra.MaximumNArgs(1), + Long: "Saves settings and step changes atomically. The config must include the revision from flows export or flows show. Omitted steps are preserved; deleted_step_ids removes steps explicitly. A stale revision fails without saving. Does not publish.", + Example: ` rc flows update wf_abc --config flow.json + cat flow.json | rc flows update wf_abc --config -`, + RunE: func(cmd *cobra.Command, args []string) error { + var body api.UpdateFlow + if err := readJSONConfigWithLimit(config, &body, maxFlowConfigBytes); err != nil { + return err + } + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err + } + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) + if err != nil { + return err + } + flow, err := client.Flows.Update(cmd.Context(), projectID, flowID, body) + if err != nil { + return err + } + rt.Out.Success(fmt.Sprintf("Saved draft of %s", flow.ID)) + return rt.Out.Render(flow) + }, + } + cmd.Flags().StringVar(&config, "config", "", "JSON update file, or - for stdin (up to 5 MiB)") + _ = cmd.MarkFlagRequired("config") + return cmd +} + +func newFlowsCreateCmd() *cobra.Command { + var config string + cmd := &cobra.Command{ + Use: "create", Short: "Create an unpublished Flow from JSON", Args: cobra.NoArgs, + Long: "Creates a draft with display_name, type (web-funnel or general), optional settings, and steps keyed by step ID. Type defaults to general. Does not publish or attach a checkpoint rule.", + Example: ` rc flows create --config flow.json + cat flow.json | rc flows create --config -`, + RunE: func(cmd *cobra.Command, _ []string) error { + var body api.CreateFlow + if err := readJSONConfigWithLimit(config, &body, maxFlowConfigBytes); err != nil { + return err + } + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err + } + flow, err := client.Flows.Create(cmd.Context(), projectID, body) + if err != nil { + return err + } + rt.Out.Success(fmt.Sprintf("Created %s", flow.ID)) + return rt.Out.Render(flow) + }, + } + cmd.Flags().StringVar(&config, "config", "", "JSON flow file, or - for stdin (up to 5 MiB)") + _ = cmd.MarkFlagRequired("config") + return cmd +} + +func newFlowsDuplicateCmd() *cobra.Command { + var name, sourceVersion string + cmd := &cobra.Command{ + Use: "duplicate [id]", Short: "Duplicate a Flow into an unpublished draft", Args: cobra.MaximumNArgs(1), + Example: ` rc flows duplicate wf_abc --name "Onboarding copy" + rc flows duplicate wf_abc --source-version published`, + RunE: func(cmd *cobra.Command, args []string) error { + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err + } + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) + if err != nil { + return err + } + source := api.DuplicateFlowSourceVersion(sourceVersion) + body := api.DuplicateFlow{SourceVersion: &source} + if name != "" { + body.DisplayName = &name + } + flow, err := client.Flows.Duplicate(cmd.Context(), projectID, flowID, body) + if err != nil { + return err + } + rt.Out.Success(fmt.Sprintf("Duplicated %s as %s", flowID, flow.ID)) + return rt.Out.Render(flow) + }, + } + cmd.Flags().StringVar(&name, "name", "", "name of the duplicate") + cmd.Flags().StringVar(&sourceVersion, "source-version", string(api.DuplicateFlowSourceVersionDraft), "source to copy: draft or published") + return cmd +} + +func newFlowsValidateCmd() *cobra.Command { + var revision int64 + cmd := &cobra.Command{ + Use: "validate [id]", Short: "Validate a saved Flow draft", Args: cobra.MaximumNArgs(1), + Long: "Returns workflow errors and step issues for the saved draft. Returns a nonzero exit status when the draft is invalid. Does not save or publish.", + Example: ` rc flows validate wf_abc + rc flows validate wf_abc --revision 4`, + RunE: func(cmd *cobra.Command, args []string) error { + rt := RuntimeFrom(cmd.Context()) + projectID, err := requireProject(rt) + if err != nil { + return err + } + client, err := rt.API() + if err != nil { + return err + } + flowID, err := requireID(rt, argAt(args, 0), "flow", func() ([]PickerItem, error) { + return flowPickerItems(cmd.Context(), client, projectID) + }) + if err != nil { + return err + } + var body api.ValidateFlow + if cmd.Flags().Changed("revision") { + body.Revision = &revision + } + validation, err := client.Flows.Validate(cmd.Context(), projectID, flowID, body) + if err != nil { + return err + } + if err := rt.Out.Render(validation); err != nil { + return err + } + if !validation.Valid { + return &SilentExitError{Code: 1} + } + return nil + }, + } + cmd.Flags().Int64Var(&revision, "revision", 0, "require this draft revision") + return cmd +} diff --git a/internal/cli/flows_authoring_test.go b/internal/cli/flows_authoring_test.go new file mode 100644 index 00000000..8aba7899 --- /dev/null +++ b/internal/cli/flows_authoring_test.go @@ -0,0 +1,139 @@ +package cli_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/revenuecat/cli/internal/cli" +) + +func TestFlowExportUpdateRoundTrip(t *testing.T) { + var update map[string]json.RawMessage + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/projects/proj/flows/wf_1" { + t.Errorf("path = %s", r.URL.Path) + } + switch r.Method { + case http.MethodGet: + if r.URL.Query().Get("expand") != "steps" { + t.Errorf("expand = %q, want steps", r.URL.Query().Get("expand")) + } + _, _ = io.WriteString(w, `{"id":"wf_1","object":"flow","revision":4,"type":"web-funnel","display_name":"Checkout","initial_step_id":"screen","single_step_fallback_id":null,"app_id":null,"metadata":{"large":9007199254740993},"custom_context_parameters_config":null,"content_max_width":null,"steps":{"screen":{"type":"screen","paywall":{"components_config":{"large":9007199254740993},"components_localizations":{"en_US":{"title":"Hello"}},"haptics_enabled":false}}}}`) + case http.MethodPatch: + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + t.Error(err) + } + _, _ = io.WriteString(w, `{"id":"wf_1","revision":5}`) + default: + t.Errorf("method = %s", r.Method) + } + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "flow.json") + out, _, err := runProjectSetupCommand(t, server.URL, "flows", "export", "wf_1", "--output", path, "--json", "--no-input") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, `"schema_version"`) { + t.Fatalf("missing envelope: %s", out) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "schema_version") { + t.Fatal("export contains CLI envelope") + } + _, _, err = runProjectSetupCommand(t, server.URL, "flows", "update", "wf_1", "--config", path, "--json", "--no-input") + if err != nil { + t.Fatal(err) + } + if string(update["revision"]) != "4" || string(update["app_id"]) != "null" { + t.Fatalf("revision/null changed: %s", update) + } + if !strings.Contains(string(update["updated_steps"]), "9007199254740993") || !strings.Contains(string(update["metadata"]), "9007199254740993") { + t.Fatal("large integer lost precision") + } + if !strings.Contains(string(update["updated_steps"]), `"haptics_enabled":false`) || !strings.Contains(string(update["updated_steps"]), `"en_US"`) { + t.Fatalf("screen fields lost: %s", update["updated_steps"]) + } + if _, exists := update["deleted_step_ids"]; exists { + t.Fatal("export inferred step deletions") + } +} + +func TestFlowValidationInvalidRendersIssuesAndFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/projects/proj/flows/wf_1/actions/validate" || r.Method != "POST" { + t.Errorf("request = %s %s", r.Method, r.URL.Path) + } + _, _ = io.WriteString(w, `{"object":"flow_validation","id":"wf_1","revision":4,"valid":false,"step_issues":{},"workflow_errors":[{"key":"no_initial_step","message":"Choose a start step","severity":"error"}]}`) + })) + defer server.Close() + out, _, err := runProjectSetupCommand(t, server.URL, "flows", "validate", "wf_1", "--revision", "4", "--json", "--no-input") + if cli.ExitCodeFor(err) != 1 { + t.Fatalf("exit = %d, error = %v", cli.ExitCodeFor(err), err) + } + if !strings.Contains(out, "Choose a start step") || !strings.Contains(out, `"valid": false`) { + t.Fatalf("validation output = %s", out) + } +} + +func TestFlowUpdateRejectsInvalidConfigsBeforeRequest(t *testing.T) { + for _, data := range []string{`{"revision":1,"unknown":true}`, `{"revision":1} {"revision":2}`} { + path := filepath.Join(t.TempDir(), "flow.json") + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + _, _, err := runProjectSetupCommand(t, "http://127.0.0.1:0", "flows", "update", "wf_1", "--config", path, "--no-input") + if err == nil || strings.Contains(err.Error(), "connection refused") { + t.Fatalf("config %s was not rejected before HTTP: %v", data, err) + } + } +} + +func TestFlowCreateAndDuplicateRemainUnpublished(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path) + if r.Method != "POST" { + t.Errorf("method = %s", r.Method) + } + var body map[string]json.RawMessage + _ = json.NewDecoder(r.Body).Decode(&body) + if r.URL.Path == "/projects/proj/flows" { + if string(body["type"]) != `"web-funnel"` || !strings.Contains(string(body["steps"]), `"screen"`) { + t.Errorf("create body = %s", body) + } + } else if r.URL.Path == "/projects/proj/flows/wf_1/actions/duplicate" { + if string(body["source_version"]) != `"published"` || string(body["display_name"]) != `"Copy"` { + t.Errorf("duplicate body = %s", body) + } + } else { + t.Errorf("unexpected mutation: %s", r.URL.Path) + } + _, _ = io.WriteString(w, `{"id":"wf_copy","revision":1,"published_at":null}`) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "flow.json") + if err := os.WriteFile(path, []byte(`{"display_name":"Original","type":"web-funnel","steps":{"screen":{"type":"screen"}}}`), 0o600); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"flows", "create", "--config", path, "--json", "--no-input"}, + {"flows", "duplicate", "wf_1", "--name", "Copy", "--source-version", "published", "--json", "--no-input"}, + } { + if _, _, err := runProjectSetupCommand(t, server.URL, args...); err != nil { + t.Fatal(err) + } + } + if len(paths) != 2 { + t.Fatalf("requests = %v", paths) + } +} diff --git a/internal/cli/targeting_checkpoint_test.go b/internal/cli/targeting_checkpoint_test.go deleted file mode 100644 index e49389da..00000000 --- a/internal/cli/targeting_checkpoint_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package cli_test - -import ( - "encoding/json" - "fmt" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestCheckpointTargetingCreatePreservesPositionAfterApproval(t *testing.T) { - for _, position := range []int{0, 3} { - t.Run(fmt.Sprint(position), func(t *testing.T) { - var posted struct { - Checkpoints []struct { - ID string `json:"checkpoint_id"` - Position *int `json:"position"` - } `json:"checkpoints"` - } - mutations := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mutations++ - if err := json.NewDecoder(r.Body).Decode(&posted); err != nil { - t.Error(err) - } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"id":"chkptrule1","rule_type":"checkpoint","state":"active","display_name":"Onboarding"}`) - })) - t.Cleanup(srv.Close) - t.Setenv("RC_BASE_URL", srv.URL) - config := fmt.Sprintf(`{"rule_type":"checkpoint","state":"active","display_name":"Onboarding","audience_id":"aud1","flow_id":"wf1","checkpoints":[{"checkpoint_id":"chkpt1","position":%d}]}`, position) - path := filepath.Join(t.TempDir(), "create.json") - if err := os.WriteFile(path, []byte(config), 0o600); err != nil { - t.Fatal(err) - } - args := []string{"targeting", "create", "--config", path, "--project-id", "proj", "--api-key", "sk_test", "--no-input"} - _, preview, err := runAgentCmd(t, args...) - if err == nil || !strings.Contains(err.Error(), "--yes") || mutations != 0 || !strings.Contains(preview, fmt.Sprintf("chkpt1 (position %d)", position)) { - t.Fatalf("approval preview: err=%v mutations=%d preview=%s", err, mutations, preview) - } - _, _, err = runAgentCmd(t, append(args, "--yes", "--json")...) - if err != nil || mutations != 1 || len(posted.Checkpoints) != 1 { - t.Fatalf("create: err=%v mutations=%d body=%+v", err, mutations, posted) - } - checkpoint := posted.Checkpoints[0] - if checkpoint.ID != "chkpt1" || checkpoint.Position == nil || *checkpoint.Position != position { - t.Fatalf("checkpoint position changed: %+v", checkpoint) - } - }) - } -} - -func TestCheckpointTargetingCreateRejectsInvalidCheckpointsBeforeMutation(t *testing.T) { - for _, checkpoints := range []string{`[]`, `null`, `{}`, `[{"checkpoint_id":""}]`, `[{}]`, `[{"checkpoint_id":"a"},{"checkpoint_id":"b"}]`} { - t.Run(checkpoints, func(t *testing.T) { - requests := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - requests++ - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"id":"chkptrule1","rule_type":"checkpoint"}`) - })) - t.Cleanup(srv.Close) - t.Setenv("RC_BASE_URL", srv.URL) - config := `{"rule_type":"checkpoint","state":"active","display_name":"Onboarding","audience_id":"aud1","flow_id":"wf1","checkpoints":` + checkpoints + `}` - path := filepath.Join(t.TempDir(), "create.json") - if err := os.WriteFile(path, []byte(config), 0o600); err != nil { - t.Fatal(err) - } - _, _, err := runAgentCmd(t, "targeting", "create", "--config", path, "--project-id", "proj", "--api-key", "sk_test", "--no-input", "--yes") - if err == nil || !strings.Contains(err.Error(), "checkpoint") || requests != 0 { - t.Fatalf("err=%v requests=%d; expected validation before mutation", err, requests) - } - }) - } -} - -func TestCheckpointTargetingUpdateGuardsLiveAndScheduledChanges(t *testing.T) { - for _, tc := range []struct { - name, currentState, newState string - approval bool - }{ - {"activate", "inactive", "active", true}, - {"schedule", "inactive", "scheduled", true}, - {"edit active", "active", "", true}, - {"edit scheduled", "scheduled", "", true}, - {"edit inactive", "inactive", "", false}, - } { - t.Run(tc.name, func(t *testing.T) { - var posted map[string]any - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/projects/proj/targeting_rules/chkptrule1" { - t.Errorf("unexpected path: %s", r.URL.Path) - } - if r.Method == http.MethodPost { - if err := json.NewDecoder(r.Body).Decode(&posted); err != nil { - t.Error(err) - } - } - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"id":"chkptrule1","rule_type":"checkpoint","state":%q,"display_name":"Onboarding","flow_id":"wf_original","audience_id":"aud_original"}`, tc.currentState) - })) - t.Cleanup(srv.Close) - t.Setenv("RC_BASE_URL", srv.URL) - config := map[string]any{ - "display_name": "New onboarding", "flow_id": "wf_new", "audience_id": "aud_new", - "checkpoints": []map[string]string{{"checkpoint_id": "chkpt_new"}}, - "schedule": map[string]string{"start_date": "2030-12-01T00:00:00Z", "end_date": "2030-12-31T23:59:59Z"}, - } - if tc.newState != "" { - config["state"] = tc.newState - } - data, err := json.Marshal(config) - if err != nil { - t.Fatal(err) - } - configPath := filepath.Join(t.TempDir(), "checkpoint.json") - if err := os.WriteFile(configPath, data, 0o600); err != nil { - t.Fatal(err) - } - args := []string{"targeting", "update", "chkptrule1", "--config", configPath, "--project-id", "proj", "--api-key", "sk_test", "--no-input"} - if tc.approval { - _, preview, err := runAgentCmd(t, args...) - if err == nil || !strings.Contains(err.Error(), "--yes") || posted != nil { - t.Fatalf("approval did not precede mutation: err=%v posted=%v", err, posted) - } - for _, want := range []string{"Current flow", "wf_original", "wf_new", "chkpt_new", "2030-12-01T00:00:00Z"} { - if !strings.Contains(preview, want) { - t.Fatalf("missing %q in preview: %s", want, preview) - } - } - args = append(args, "--yes") - } - out, stderr, err := runAgentCmd(t, append(args, "--json")...) - if err != nil || stderr != "" || posted == nil || !strings.Contains(out, `"rule_type": "checkpoint"`) { - t.Fatalf("checkpoint update failed: err=%v posted=%v stdout=%s stderr=%s", err, posted, out, stderr) - } - postedJSON, err := json.Marshal(posted) - if err != nil || string(postedJSON) != string(data) { - t.Fatalf("request changed: got=%s want=%s err=%v", postedJSON, data, err) - } - }) - } -} - -func TestTargetingUpdateRejectsFieldsForWrongRuleType(t *testing.T) { - for _, tc := range []struct{ name, ruleType, config, want string }{ - {"checkpoint position", "checkpoint", `{"position":1}`, "cannot be set on checkpoint"}, - {"checkpoint offering", "checkpoint", `{"offering_id":"ofrng1"}`, "cannot be set on checkpoint"}, - {"checkpoint conditions", "checkpoint", `{"conditions":[]}`, "cannot be set on checkpoint"}, - {"checkpoint placements", "checkpoint", `{"placements":null}`, "cannot be set on checkpoint"}, - {"checkpoint null audience", "checkpoint", `{"audience_id":null}`, "nonempty string"}, - {"Offering flow", "legacy", `{"flow_id":"wf1"}`, "only be set on checkpoint"}, - {"Offering checkpoints", "legacy", `{"checkpoints":[{"checkpoint_id":"chkpt1"}]}`, "only be set on checkpoint"}, - {"Offering scheduled state", "legacy", `{"state":"scheduled"}`, "state must be active or inactive"}, - } { - t.Run(tc.name, func(t *testing.T) { - mutations := 0 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - mutations++ - } - w.Header().Set("Content-Type", "application/json") - _, _ = fmt.Fprintf(w, `{"id":"rule1","rule_type":%q,"state":"inactive"}`, tc.ruleType) - })) - t.Cleanup(srv.Close) - t.Setenv("RC_BASE_URL", srv.URL) - configPath := filepath.Join(t.TempDir(), "update.json") - if err := os.WriteFile(configPath, []byte(tc.config), 0o600); err != nil { - t.Fatal(err) - } - _, _, err := runAgentCmd(t, "targeting", "update", "rule1", "--config", configPath, "--project-id", "proj", "--api-key", "sk_test", "--no-input", "--yes") - if err == nil || !strings.Contains(err.Error(), tc.want) || mutations != 0 { - t.Fatalf("err=%v mutations=%d; want %q before mutation", err, mutations, tc.want) - } - }) - } -} diff --git a/oapi-codegen.yaml b/oapi-codegen.yaml index 8fd88bba..5cdba218 100644 --- a/oapi-codegen.yaml +++ b/oapi-codegen.yaml @@ -29,3 +29,4 @@ output-options: - App - AppType - AppObject + - FlowValidationIssue