From fea93beb89043f40322fcee001ca696d334d133f Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Tue, 2 Jun 2026 11:25:39 -0700 Subject: [PATCH] fix(application): more precise plan upgrade/downgrade direction --- api/dashboard/client_test.go | 17 ++ api/dashboard/types.go | 30 ++- pkg/cmd/application/downgrade/downgrade.go | 1 + pkg/cmd/application/planchange/planchange.go | 174 +++++++++++++++--- .../application/planchange/planchange_test.go | 126 ++++++++++++- pkg/cmd/application/upgrade/upgrade.go | 1 + 6 files changed, 314 insertions(+), 35 deletions(-) diff --git a/api/dashboard/client_test.go b/api/dashboard/client_test.go index 0383efc1..bbd41938 100644 --- a/api/dashboard/client_test.go +++ b/api/dashboard/client_test.go @@ -246,6 +246,23 @@ func TestGetApplication_Success(t *testing.T) { assert.Equal(t, "api-key-123", app.APIKey) } +func TestGetApplication_ParsesPlanLabel(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/application/APP1", func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte( + `{"data":{"id":"APP1","type":"application","attributes":{"name":"My App","application_id":"APP1","plan":{"name":"v8.5-plg-grow-plus","version":9,"label":"Grow Plus","pay_as_you_go":true}}}}`, + )) + require.NoError(t, err) + }) + + ts, client := newTestClient(mux) + defer ts.Close() + + app, err := client.GetApplication("test-token", "APP1") + require.NoError(t, err) + assert.Equal(t, "Grow Plus", app.PlanLabel) +} + func TestCreateApplication_Success(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/1/applications", func(w http.ResponseWriter, r *http.Request) { diff --git a/api/dashboard/types.go b/api/dashboard/types.go index 7070eb90..da309f4d 100644 --- a/api/dashboard/types.go +++ b/api/dashboard/types.go @@ -30,16 +30,27 @@ type ApplicationResource struct { // ApplicationAttributes contains the actual application fields. type ApplicationAttributes struct { - Name string `json:"name"` - ApplicationID string `json:"application_id"` - APIKey string `json:"api_key"` + Name string `json:"name"` + ApplicationID string `json:"application_id"` + APIKey string `json:"api_key"` + Plan ApplicationPlan `json:"plan"` +} + +// ApplicationPlan is the plan applied to an application (attributes.plan). +// Label (e.g. "Grow Plus") matches a self-serve plan template's Name. +type ApplicationPlan struct { + Name string `json:"name"` + Label string `json:"label"` + Version int `json:"version"` + PayAsYouGo bool `json:"pay_as_you_go"` } // Application is a flattened view of an Algolia application for CLI consumption. type Application struct { - ID string `json:"id"` - Name string `json:"name"` - APIKey string `json:"api_key,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + APIKey string `json:"api_key,omitempty"` + PlanLabel string `json:"plan_label,omitempty"` // current plan label, e.g. "Grow Plus" } // PaginationMeta contains page-based pagination metadata. @@ -160,9 +171,10 @@ type DashboardCrawlerError struct { // toApplication flattens a JSON:API resource into a simple Application. func (r *ApplicationResource) toApplication() Application { return Application{ - ID: r.Attributes.ApplicationID, - Name: r.Attributes.Name, - APIKey: r.Attributes.APIKey, + ID: r.Attributes.ApplicationID, + Name: r.Attributes.Name, + APIKey: r.Attributes.APIKey, + PlanLabel: r.Attributes.Plan.Label, } } diff --git a/pkg/cmd/application/downgrade/downgrade.go b/pkg/cmd/application/downgrade/downgrade.go index 01f1a7b4..27013e51 100644 --- a/pkg/cmd/application/downgrade/downgrade.go +++ b/pkg/cmd/application/downgrade/downgrade.go @@ -14,6 +14,7 @@ func NewDowngradeCmd(f *cmdutil.Factory) *cobra.Command { opts := &planchange.Options{ IO: f.IOStreams, Config: f.Config, + Direction: planchange.DirectionDowngrade, PrintFlags: cmdutil.NewPrintFlags(), NewDashboardClient: func(clientID string) *dashboard.Client { return dashboard.NewClient(clientID) diff --git a/pkg/cmd/application/planchange/planchange.go b/pkg/cmd/application/planchange/planchange.go index c99d6f77..3ef55f47 100644 --- a/pkg/cmd/application/planchange/planchange.go +++ b/pkg/cmd/application/planchange/planchange.go @@ -19,15 +19,25 @@ import ( "github.com/algolia/cli/pkg/prompt" ) +// Direction selects whether the flow offers higher-tier (upgrade) or +// lower-tier (downgrade) plans. +type Direction int + +const ( + DirectionUpgrade Direction = iota + DirectionDowngrade +) + // Options carries everything the shared plan-change flow needs. The upgrade and // downgrade commands populate it the same way. type Options struct { IO *iostreams.IOStreams Config config.IConfig - Plan string // --plan (optional): target plan, e.g. "free", "grow", "grow-plus" - DryRun bool // --dry-run: preview without calling the API - AcceptTerms bool // --accept-terms: accept ToS in non-interactive mode + Direction Direction // upgrade or downgrade + Plan string // --plan (optional): target plan, e.g. "free", "grow", "grow-plus" + DryRun bool // --dry-run: preview without calling the API + AcceptTerms bool // --accept-terms: accept ToS in non-interactive mode PrintFlags *cmdutil.PrintFlags @@ -85,10 +95,26 @@ func Run(opts *Options) error { user = nil } - target, err := selectTarget(opts, client, &token, appID, plans) + app := fetchApplication(opts, client, &token, appID) + + target, err := resolveTarget(opts, appID, app, plans) if err != nil { return err } + if target == nil { + return nil + } + + if isCurrentPlan(app, *target) { + fmt.Fprintf( + opts.IO.Out, + "%s Application %s is already on the %s plan; no change needed.\n", + cs.WarningIcon(), + cs.Bold(appID), + cs.Bold(target.Name), + ) + return nil + } // Paid plans require a payment method that the CLI cannot collect. Only // block when we positively know there is none (user fetched, flag false); @@ -195,49 +221,149 @@ func offerCostManagementBudget(opts *Options, dashboardURL, appID string) error return browser(url) } -// selectTarget resolves the target plan from the --plan flag or, when -// interactive and no flag is set, an interactive picker over the available -// plans. -func selectTarget( +// resolveTarget picks the target plan: --plan overrides the direction filter, +// otherwise candidates are filtered by direction and chosen interactively. A +// nil plan with a nil error means there is nothing to switch to. +func resolveTarget( opts *Options, - client *dashboard.Client, - token *string, appID string, + app *dashboard.Application, plans []dashboard.Plan, ) (*dashboard.Plan, error) { if opts.Plan != "" { return resolvePlan(plans, opts.Plan) } + candidates := filterByDirection(plans, app, opts.Direction) + + if len(candidates) == 0 { + reportNoCandidates(opts, appID, app, opts.Direction) + return nil, nil + } + if !opts.IO.CanPrompt() { return nil, cmdutil.FlagErrorf( "--plan is required in non-interactive mode (one of: %s)", - strings.Join(planChoices(plans), ", "), + strings.Join(planChoices(candidates), ", "), ) } - cs := opts.IO.ColorScheme() - appLabel := cs.Bold(appID) - if name := currentAppName(opts, client, token, appID); name != "" { - appLabel = fmt.Sprintf("%s (%s)", cs.Bold(appID), name) - } - fmt.Fprintf(opts.IO.Out, "Current application: %s\n\n", appLabel) + printCurrentApplication(opts, appID, app) - return pickPlan(plans) + return pickPlan(candidates) } -// currentAppName resolves the current application's display name, best-effort. -// It returns "" when the name can't be fetched so callers fall back to the ID. -func currentAppName(opts *Options, client *dashboard.Client, token *string, appID string) string { +// fetchApplication returns the current application, or nil if it can't be fetched. +func fetchApplication( + opts *Options, + client *dashboard.Client, + token *string, + appID string, +) *dashboard.Application { var app *dashboard.Application if err := callWithReauth(opts.IO, client, token, "Fetching application", func(t string) error { var e error app, e = client.GetApplication(t, appID) return e - }); err != nil || app == nil { - return "" + }); err != nil { + return nil + } + return app +} + +// filterByDirection returns plans above (upgrade) or below (downgrade) the +// current plan in the API's tier order, or all plans when it isn't found. +func filterByDirection( + plans []dashboard.Plan, + app *dashboard.Application, + dir Direction, +) []dashboard.Plan { + idx := currentPlanIndex(plans, app) + if idx < 0 { + return plans + } + if dir == DirectionDowngrade { + return plans[:idx] + } + return plans[idx+1:] +} + +// normalizePlanKey normalizes a plan label/name for comparison; the CLI joins +// the current plan to the self-serve list by matching label against Name. +func normalizePlanKey(s string) string { + return strings.TrimSpace(strings.ToLower(s)) +} + +// currentPlanIndex returns the index of the app's current plan in plans, or -1. +func currentPlanIndex(plans []dashboard.Plan, app *dashboard.Application) int { + if app == nil { + return -1 + } + label := normalizePlanKey(app.PlanLabel) + if label == "" { + return -1 + } + for i := range plans { + if normalizePlanKey(plans[i].Name) == label { + return i + } + } + return -1 +} + +// isCurrentPlan reports whether target is the plan the application is already on. +func isCurrentPlan(app *dashboard.Application, target dashboard.Plan) bool { + if app == nil { + return false + } + label := normalizePlanKey(app.PlanLabel) + return label != "" && label == normalizePlanKey(target.Name) +} + +// reportNoCandidates tells the user they're already at the highest/lowest plan. +func reportNoCandidates( + opts *Options, + appID string, + app *dashboard.Application, + dir Direction, +) { + cs := opts.IO.ColorScheme() + current := "" + if app != nil && app.PlanLabel != "" { + current = fmt.Sprintf(" (%s)", app.PlanLabel) + } + tier, verb := "highest", "upgrade" + if dir == DirectionDowngrade { + tier, verb = "lowest", "downgrade" + } + fmt.Fprintf( + opts.IO.Out, + "%s Application %s is already on the %s self-serve plan%s; nothing to %s to.\n", + cs.WarningIcon(), + cs.Bold(appID), + tier, + current, + verb, + ) +} + +// printCurrentApplication prints the current app and plan before the picker. +func printCurrentApplication(opts *Options, appID string, app *dashboard.Application) { + cs := opts.IO.ColorScheme() + label := cs.Bold(appID) + if app != nil && app.Name != "" { + label = fmt.Sprintf("%s (%s)", cs.Bold(appID), app.Name) + } + if app != nil && app.PlanLabel != "" { + fmt.Fprintf( + opts.IO.Out, + "Current application: %s — current plan: %s\n\n", + label, + cs.Bold(app.PlanLabel), + ) + return } - return app.Name + fmt.Fprintf(opts.IO.Out, "Current application: %s\n\n", label) } // resolvePlan maps a --plan value to one of the fetched plans. diff --git a/pkg/cmd/application/planchange/planchange_test.go b/pkg/cmd/application/planchange/planchange_test.go index e7ae0b5a..bd637118 100644 --- a/pkg/cmd/application/planchange/planchange_test.go +++ b/pkg/cmd/application/planchange/planchange_test.go @@ -79,8 +79,9 @@ func samplePlanTemplates() []dashboard.PlanTemplateResource { type planChangeServer struct { *httptest.Server - patchCalls int - lastPlan string + patchCalls int + lastPlan string + currentPlanLabel string } // newServer spins up a dashboard stub. userJSON is the raw GET /1/user body; @@ -112,6 +113,7 @@ func newServer(t *testing.T, userJSON string) *planChangeServer { Attributes: dashboard.ApplicationAttributes{ ApplicationID: "APP1", Name: "My App", + Plan: dashboard.ApplicationPlan{Label: srv.currentPlanLabel}, }, }, })) @@ -175,6 +177,17 @@ func newOpts( return opts, out, opened } +// stubPicker forces the plan picker to choose the candidate at index. +func stubPicker(t *testing.T, index int) { + t.Helper() + orig := prompt.SurveyAskOne + prompt.SurveyAskOne = func(_ survey.Prompt, response interface{}, _ ...survey.AskOpt) error { + *(response.(*int)) = index + return nil + } + t.Cleanup(func() { prompt.SurveyAskOne = orig }) +} + func TestRun_WithPlanFlag(t *testing.T) { srv := newServer(t, `{"has_payment_method": true}`) defer srv.Close() @@ -330,3 +343,112 @@ func TestRun_OutputJSON(t *testing.T) { assert.Contains(t, out.String(), `"plan":"grow"`) assert.Contains(t, out.String(), `"application_id":"APP1"`) } + +func TestRun_UpgradeFiltersToHigherPlans(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + srv.currentPlanLabel = "Grow" + defer srv.Close() + + stubPicker(t, 0) + defer prompt.StubConfirm(true)() + + opts, out, _ := newOpts(t, srv, true) + opts.Direction = DirectionUpgrade + + require.NoError(t, Run(opts)) + assert.Equal(t, 1, srv.patchCalls) + assert.Equal(t, "grow-plus", srv.lastPlan) + assert.Contains(t, out.String(), "current plan: Grow") +} + +func TestRun_DowngradeFiltersToLowerPlans(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + srv.currentPlanLabel = "Grow" + defer srv.Close() + + stubPicker(t, 0) + defer prompt.StubConfirm(true)() + + opts, _, _ := newOpts(t, srv, true) + opts.Direction = DirectionDowngrade + + require.NoError(t, Run(opts)) + assert.Equal(t, 1, srv.patchCalls) + assert.Equal(t, "build", srv.lastPlan) +} + +func TestRun_UpgradeAtHighestPlanIsNoOp(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + srv.currentPlanLabel = "Grow Plus" + defer srv.Close() + + opts, out, _ := newOpts(t, srv, true) + opts.Direction = DirectionUpgrade + + require.NoError(t, Run(opts)) + assert.Equal(t, 0, srv.patchCalls) + assert.Contains(t, out.String(), "already on the highest") + assert.Contains(t, out.String(), "nothing to upgrade") +} + +func TestRun_DowngradeAtLowestPlanIsNoOp(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + srv.currentPlanLabel = "Build" + defer srv.Close() + + opts, out, _ := newOpts(t, srv, true) + opts.Direction = DirectionDowngrade + + require.NoError(t, Run(opts)) + assert.Equal(t, 0, srv.patchCalls) + assert.Contains(t, out.String(), "already on the lowest") + assert.Contains(t, out.String(), "nothing to downgrade") +} + +func TestRun_PlanFlagOverridesDirection(t *testing.T) { + // "upgrade --plan free" is an explicit override: it is honored even though + // free is below the current "Grow" plan. + srv := newServer(t, `{"has_payment_method": false}`) + srv.currentPlanLabel = "Grow" + defer srv.Close() + + opts, _, _ := newOpts(t, srv, false) + opts.Direction = DirectionUpgrade + opts.Plan = "free" + opts.AcceptTerms = true + + require.NoError(t, Run(opts)) + assert.Equal(t, 1, srv.patchCalls) + assert.Equal(t, "build", srv.lastPlan) +} + +func TestRun_SamePlanIsNoOp(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + srv.currentPlanLabel = "Grow" + defer srv.Close() + + opts, out, _ := newOpts(t, srv, false) + opts.Plan = "grow" + opts.AcceptTerms = true + + require.NoError(t, Run(opts)) + assert.Equal(t, 0, srv.patchCalls) + assert.Contains(t, out.String(), "already on the Grow plan") + assert.Contains(t, out.String(), "no change needed") +} + +func TestRun_UnknownCurrentPlanShowsAllPlans(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + srv.currentPlanLabel = "Enterprise" + defer srv.Close() + + stubPicker(t, 0) + defer prompt.StubConfirm(true)() + + opts, _, _ := newOpts(t, srv, true) + opts.Direction = DirectionUpgrade + + require.NoError(t, Run(opts)) + assert.Equal(t, 1, srv.patchCalls) + assert.Equal(t, "build", srv.lastPlan) +} diff --git a/pkg/cmd/application/upgrade/upgrade.go b/pkg/cmd/application/upgrade/upgrade.go index f852c560..ed1cd632 100644 --- a/pkg/cmd/application/upgrade/upgrade.go +++ b/pkg/cmd/application/upgrade/upgrade.go @@ -14,6 +14,7 @@ func NewUpgradeCmd(f *cmdutil.Factory) *cobra.Command { opts := &planchange.Options{ IO: f.IOStreams, Config: f.Config, + Direction: planchange.DirectionUpgrade, PrintFlags: cmdutil.NewPrintFlags(), NewDashboardClient: func(clientID string) *dashboard.Client { return dashboard.NewClient(clientID)