From 28574f7d54be0e43b38a374b88bc31a5b8940a06 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 18 Oct 2026 12:08:45 +0200 Subject: [PATCH] feat(cli): add Archera comparison filters --- cmd/archera_comparison.go | 72 +++++++++++++++- cmd/archera_comparison_filters_test.go | 115 +++++++++++++++++++++++++ cmd/archera_comparison_test.go | 4 + docs/cli/archera-comparison.md | 25 +++++- 4 files changed, 211 insertions(+), 5 deletions(-) create mode 100644 cmd/archera_comparison_filters_test.go diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go index 047672887..efd30b218 100644 --- a/cmd/archera_comparison.go +++ b/cmd/archera_comparison.go @@ -31,9 +31,12 @@ const ( var archeraHTTPClient *http.Client var archeraOpts struct { - OrgID string - PlanID string - Format string + OrgID string + PlanID string + Format string + LineItemIDs []string + ContractTerms []string + PaymentOptions []string } var archeraComparisonCmd = &cobra.Command{ @@ -59,6 +62,9 @@ func init() { archeraComparisonCmd.Flags().StringVar(&archeraOpts.OrgID, "org-id", "", "Archera organization UUID (default: $"+archeraEnvOrgID+")") archeraComparisonCmd.Flags().StringVar(&archeraOpts.PlanID, "plan-id", "", "Archera commitment plan UUID (default: $"+archeraEnvPlanID+")") archeraComparisonCmd.Flags().StringVar(&archeraOpts.Format, "format", "table", "Output format: table or json") + archeraComparisonCmd.Flags().StringArrayVar(&archeraOpts.LineItemIDs, "line-item-ids", nil, "Archera line-item UUIDs, comma-separated or repeated (max 200)") + archeraComparisonCmd.Flags().StringArrayVar(&archeraOpts.ContractTerms, "contract-terms", nil, fmt.Sprintf("Exact Archera contract terms, comma-separated or repeated (max %d)", len(insurance.ContractTerms()))) + archeraComparisonCmd.Flags().StringArrayVar(&archeraOpts.PaymentOptions, "payment-options", nil, "Payment options: no_upfront, partial_upfront, all_upfront (comma-separated or repeated; max 3)") } // archeraSetting returns the flag value, else the environment value, trimmed. @@ -79,13 +85,18 @@ func runArcheraComparison(cmd *cobra.Command, _ []string) error { return err } + req, err := archeraComparisonRequest(planID) + if err != nil { + return err + } + // Config and client stay local to this call; never store them in a // package variable or struct. client, err := insurance.NewClient(insurance.Config{APIKey: insurance.NewSecret(key), OrgID: orgID}, archeraHTTPClient) if err != nil { return err } - comparison, err := client.Comparison(cmd.Context(), insurance.ComparisonRequest{PlanID: planID}) + comparison, err := client.Comparison(cmd.Context(), req) if err != nil { return archeraSafeError(err, key) } @@ -96,6 +107,50 @@ func runArcheraComparison(cmd *cobra.Command, _ []string) error { return renderArcheraTable(cmd.OutOrStdout(), dto) } +func archeraComparisonRequest(planID string) (insurance.ComparisonRequest, error) { + req := insurance.ComparisonRequest{PlanID: planID} + filters := []struct { + values *[]string + flag string + raw []string + limit int + }{ + {&req.LineItemIDs, "line-item-ids", archeraOpts.LineItemIDs, 200}, + {&req.ContractTerms, "contract-terms", archeraOpts.ContractTerms, len(insurance.ContractTerms())}, + } + for _, filter := range filters { + values, err := archeraFilterValues(filter.flag, filter.raw, filter.limit) + if err != nil { + return req, err + } + *filter.values = values + } + payments, err := archeraFilterValues("payment-options", archeraOpts.PaymentOptions, 3) + if err != nil { + return req, err + } + for _, payment := range payments { + req.PaymentOptions = append(req.PaymentOptions, insurance.PaymentOption(payment)) + } + return req, nil +} + +func archeraFilterValues(flag string, raw []string, limit int) ([]string, error) { + var values []string + for occurrence, entry := range raw { + for segment, value := range strings.Split(entry, ",") { + if strings.TrimSpace(value) == "" { + return nil, fmt.Errorf("--%s occurrence %d entry %d must not be empty", flag, occurrence+1, segment+1) + } + if len(values) >= limit { + return nil, fmt.Errorf("--%s accepts at most %d entries", flag, limit) + } + values = append(values, value) + } + } + return values, nil +} + // archeraSettings reads the key, org ID and plan ID, naming (never echoing) // the first missing setting. The key is returned to RunE only. func archeraSettings() (key, orgID, planID string, err error) { @@ -131,6 +186,15 @@ const archeraWithheld = "[message withheld: contained the API key]" func archeraSafeError(err error, key string) error { key = strings.TrimSpace(key) msg, suffix := err.Error(), "" + // The shared validator echoes rejected input; keep its validation but omit values. + switch { + case strings.HasPrefix(msg, "archera line item ID "): + msg = "--line-item-ids entries must be canonical UUIDs" + case strings.HasPrefix(msg, "unsupported contract term "): + msg = "--contract-terms entries must be supported Archera contract terms (exact case)" + case strings.HasPrefix(msg, "payment option: "): + msg = "--payment-options entries must be no_upfront, partial_upfront or all_upfront (exact case)" + } var httpErr *insurance.HTTPError isHTTP := errors.As(err, &httpErr) if isHTTP && httpErr.RetryAfter > 0 { diff --git a/cmd/archera_comparison_filters_test.go b/cmd/archera_comparison_filters_test.go new file mode 100644 index 000000000..d6847c96d --- /dev/null +++ b/cmd/archera_comparison_filters_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "net/http" + "net/url" + "strings" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const archeraTestLine = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" + +func TestArcheraComparisonFiltersQuery(t *testing.T) { + terms := insurance.ContractTerms() + cases := []struct { + name string + args []string + want url.Values + }{ + {"omitted", nil, url.Values{}}, + {"line items", []string{"--line-item-ids", archeraTestLine}, url.Values{"line_item_ids": {archeraTestLine}}}, + {"terms", []string{"--contract-terms", "one_year_gris,three_year"}, url.Values{"contract_terms": {"one_year_gris", "three_year"}}}, + {"payments", []string{"--payment-options", "no_upfront", "--payment-options", "all_upfront"}, url.Values{"payment_options": {"no_upfront", "all_upfront"}}}, + {"combined duplicates", []string{"--line-item-ids", archeraTestLine + "," + archeraTestOrg, "--line-item-ids", archeraTestLine, "--contract-terms", "one_year", "--payment-options", "partial_upfront"}, url.Values{"line_item_ids": {archeraTestLine, archeraTestOrg, archeraTestLine}, "contract_terms": {"one_year"}, "payment_options": {"partial_upfront"}}}, + {"200 IDs", []string{"--line-item-ids", strings.TrimSuffix(strings.Repeat(archeraTestLine+",", 200), ",")}, url.Values{"line_item_ids": strings.Split(strings.TrimSuffix(strings.Repeat(archeraTestLine+",", 200), ","), ",")}}, + {"all terms", []string{"--contract-terms", strings.Join(terms, ",")}, url.Values{"contract_terms": terms}}, + {"three payments", []string{"--payment-options", "no_upfront,partial_upfront,all_upfront"}, url.Values{"payment_options": {"no_upfront", "partial_upfront", "all_upfront"}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := setupArchera(t, okHandler) + out, stderr, err := runArchera(t, fullEnv(), tc.args...) + require.NoError(t, err) + require.Len(t, rec.requests, 1) + req := rec.requests[0] + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "/v1/org/"+archeraTestOrg+"/commitment-plans/"+archeraTestPlan+"/comparison", req.URL.Path) + assert.Equal(t, archeraTestKey, req.Header.Get("x-api-key")) + got := req.URL.Query() + require.Len(t, got, len(tc.want)) + for key, values := range tc.want { + assert.ElementsMatch(t, values, got[key]) + } + assert.NotContains(t, out+stderr, archeraTestKey) + }) + } +} + +func TestArcheraComparisonFiltersRejectBeforeHTTP(t *testing.T) { + for _, flag := range []string{"line-item-ids", "contract-terms", "payment-options"} { + for _, value := range []string{"", " ", ",", "one_year,", ",one_year", "one_year,,three_year", archeraTestKey, "ONE_YEAR"} { + t.Run(flag+"/"+value, func(t *testing.T) { + rec := setupArchera(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected HTTP request") }) + out, stderr, err := runArchera(t, fullEnv(), "--"+flag+"="+value) + require.Error(t, err) + assert.NotContains(t, err.Error(), "unknown flag") + assert.Contains(t, err.Error(), "--"+flag) + assert.NotContains(t, out+stderr+err.Error(), archeraTestKey) + assert.Empty(t, rec.requests) + }) + } + } + cases := []struct{ flag, value string }{ + {"line-item-ids", strings.TrimSuffix(strings.Repeat(archeraTestLine+",", 201), ",")}, + {"contract-terms", strings.Join(append(insurance.ContractTerms(), "one_year"), ",")}, + {"payment-options", "no_upfront,no_upfront,no_upfront,no_upfront"}, + {"line-item-ids", "cccccccccccc4ccc8ccccccccccccccc"}, + {"line-item-ids", "{" + archeraTestLine + "}"}, + {"line-item-ids", "urn:uuid:" + archeraTestLine}, + {"contract-terms", " one_year"}, + {"payment-options", "No_Upfront"}, + } + for _, tc := range cases { + t.Run(tc.flag+"/"+tc.value[:min(12, len(tc.value))], func(t *testing.T) { + rec := setupArchera(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected HTTP request") }) + _, _, err := runArchera(t, fullEnv(), "--"+tc.flag, tc.value) + require.ErrorContains(t, err, "--"+tc.flag) + assert.Empty(t, rec.requests) + }) + } + for _, tc := range []struct{ flag, valid string }{{"line-item-ids", archeraTestLine}, {"contract-terms", "one_year"}, {"payment-options", "no_upfront"}} { + t.Run("repeated empty "+tc.flag, func(t *testing.T) { + rec := setupArchera(t, func(http.ResponseWriter, *http.Request) { t.Error("unexpected HTTP request") }) + _, _, err := runArchera(t, fullEnv(), "--"+tc.flag, tc.valid, "--"+tc.flag+"=") + require.ErrorContains(t, err, "--"+tc.flag) + assert.Empty(t, rec.requests) + }) + } +} + +func TestArcheraComparisonFiltersHelpAndReset(t *testing.T) { + rec := setupArchera(t, okHandler) + out, _, err := runArchera(t, fullEnv(), "--help") + require.NoError(t, err) + for _, flag := range []string{"line-item-ids", "contract-terms", "payment-options"} { + assert.Contains(t, out, "--"+flag) + } + assert.Empty(t, rec.requests) + _, _, err = runArchera(t, fullEnv(), "--line-item-ids", archeraTestLine) + require.NoError(t, err) + _, _, err = runArchera(t, fullEnv()) + require.NoError(t, err) + require.Len(t, rec.requests, 2) + assert.Empty(t, rec.requests[1].URL.RawQuery) + for _, flag := range []string{"line-item-ids", "contract-terms", "payment-options"} { + assert.False(t, archeraComparisonCmd.Flags().Changed(flag)) + } + rootCmd.SetArgs([]string{"configure-gcp", "--help"}) + require.NoError(t, rootCmd.Execute()) + resetHelpFlags() + assert.Len(t, rec.requests, 2) +} diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go index 01a141627..89eb508b0 100644 --- a/cmd/archera_comparison_test.go +++ b/cmd/archera_comparison_test.go @@ -118,6 +118,10 @@ func runArchera(t *testing.T, env map[string]string, args ...string) (stdout, st } resetHelpFlags() archeraOpts.OrgID, archeraOpts.PlanID, archeraOpts.Format = "", "", "table" + archeraOpts.LineItemIDs, archeraOpts.ContractTerms, archeraOpts.PaymentOptions = nil, nil, nil + for _, name := range []string{"org-id", "plan-id", "format", "line-item-ids", "contract-terms", "payment-options"} { + archeraComparisonCmd.Flags().Lookup(name).Changed = false + } var out, errb bytes.Buffer rootCmd.SetOut(&out) rootCmd.SetErr(&errb) diff --git a/docs/cli/archera-comparison.md b/docs/cli/archera-comparison.md index eb087ff00..ac551ba48 100644 --- a/docs/cli/archera-comparison.md +++ b/docs/cli/archera-comparison.md @@ -26,8 +26,31 @@ export ARCHERA_ORG_ID= export ARCHERA_PLAN_ID= ri-helper archera-comparison # table ri-helper archera-comparison --format json # one JSON document on stdout +ri-helper archera-comparison --line-item-ids \ + --contract-terms one_year_gris,three_year \ + --payment-options no_upfront --payment-options all_upfront ``` +Optional filters narrow the vendor comparison rollups: + +| Flag | Values | Maximum entries | +|------|--------|-----------------| +| `--line-item-ids` | canonical Archera line-item UUIDs | 200 | +| `--contract-terms` | exact, case-sensitive Archera contract-term enum values | 74 | +| `--payment-options` | `no_upfront`, `partial_upfront`, `all_upfront` | 3 | + +Each filter accepts comma-separated entries, repeated flags, or both. Duplicate +entries are preserved and count toward the limit. Explicit empty occurrences, +empty comma segments and whitespace-only entries are errors before any request. +Entries are not trimmed or case-normalized. Invalid UUIDs and enum values also +fail before any request; errors do not echo rejected values. + +Omitting a filter sends no corresponding query parameter. The documented vendor +defaults are all selected line items, every distinct candidate term, and +`no_upfront` only, respectively. The CLI does not replace those defaults. +Line-item IDs come from Archera, not platform recommendation IDs. These filters do not prove +an exact recommendation association or economic equivalence. + Errors go to stderr and the exit status is 1 for every failure. A vendor error shows the sanitized HTTP status and message, plus `Retry-After` when given (capped at 24 hours). The command does not retry. @@ -41,7 +64,7 @@ shows the sanitized HTTP status and message, plus `Retry-After` when given - Unknown values print `unknown` in the table and `null` in JSON, never `0`. - Money is printed as an exact decimal. `discount_rate` is shown as sent (0-1 basis). -- The comparison is a hypothetical rollup for the whole plan. It is not a +- The comparison is a hypothetical rollup for the plan, narrowed by any filters. It is not a bindable quote, not a guarantee, and not mapped onto recommendation rows or CSV output. - Product support is `supported` only when Archera's documentation lists the