From a8d6be381520c6ab83b41cdf3f6e2a2b8e2acb8b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 17 Oct 2026 19:05:16 +0200 Subject: [PATCH 1/2] feat(insurance): compare one stored recommendation with its Archera line item Co-Authored-By: Claude Opus 5.5 --- internal/api/handler_insurance_comparison.go | 33 ++-- .../handler_insurance_recommendation_fetch.go | 66 +++++++ ...ler_insurance_recommendation_fetch_test.go | 161 ++++++++++++++++++ 3 files changed, 248 insertions(+), 12 deletions(-) create mode 100644 internal/api/handler_insurance_recommendation_fetch.go create mode 100644 internal/api/handler_insurance_recommendation_fetch_test.go diff --git a/internal/api/handler_insurance_comparison.go b/internal/api/handler_insurance_comparison.go index 7c5d227df..4d94d91cd 100644 --- a/internal/api/handler_insurance_comparison.go +++ b/internal/api/handler_insurance_comparison.go @@ -28,19 +28,9 @@ func (h *Handler) getInsuranceComparison(ctx context.Context, req *events.Lambda if err := h.requireInsuranceAccess(ctx, req); err != nil { return nil, err } - if h.insurance == nil { - return nil, notConfiguredError(archera.Settings{}.Missing()) - } - if st := h.insurance.Status(); !st.Configured { - return nil, notConfiguredError(st.Missing) - } - // The raw client lives in this local only. - client, err := h.insurance.Client(ctx) + client, err := h.insuranceClient(ctx) if err != nil { - if errors.Is(err, archera.ErrNotConfigured) { - return nil, notConfiguredError(h.insurance.Status().Missing) - } - return nil, NewClientError(503, "Archera API key could not be resolved from "+archera.EnvKeySecret) + return nil, err } cmp, err := client.Comparison(ctx, insurance.ComparisonRequest{PlanID: h.insurance.PlanID()}) if err != nil { @@ -58,6 +48,25 @@ func (h *Handler) getInsuranceComparison(ctx context.Context, req *events.Lambda return dto, nil } +// insuranceClient resolves the Archera client, or a fixed client error. The raw +// client lives in the caller's local only. +func (h *Handler) insuranceClient(ctx context.Context) (insurance.QuoteClient, error) { + if h.insurance == nil { + return nil, notConfiguredError(archera.Settings{}.Missing()) + } + if st := h.insurance.Status(); !st.Configured { + return nil, notConfiguredError(st.Missing) + } + client, err := h.insurance.Client(ctx) + if err != nil { + if errors.Is(err, archera.ErrNotConfigured) { + return nil, notConfiguredError(h.insurance.Status().Missing) + } + return nil, NewClientError(503, "Archera API key could not be resolved from "+archera.EnvKeySecret) + } + return client, nil +} + func notConfiguredError(missing []string) error { return NewClientError(503, "Archera comparison is not configured; missing settings: "+strings.Join(missing, ", ")) } diff --git a/internal/api/handler_insurance_recommendation_fetch.go b/internal/api/handler_insurance_recommendation_fetch.go new file mode 100644 index 000000000..f88380900 --- /dev/null +++ b/internal/api/handler_insurance_recommendation_fetch.go @@ -0,0 +1,66 @@ +package api + +import ( + "context" + "errors" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/archera" +) + +// insuranceRecommendationTimeout bounds secret resolution and the vendor reads. A var so tests can shorten it. +var insuranceRecommendationTimeout = 20 * time.Second + +// lineItemClient is the QuoteClient plus the line-item read that the published interface lacks. +type lineItemClient interface { + insurance.QuoteClient + LineItems(ctx context.Context, planID string) ([]insurance.LineItem, error) +} + +// FetchRecommendationComparison reads every line item, selects the recommendation's own one and asks for +// a comparison scoped to it. No line item, or an ambiguous one, returns an empty comparison without a +// comparison call. Any failure returns no partial result. +func (h *Handler) FetchRecommendationComparison(ctx context.Context, rec *archera.RecommendationIdentity) (*insurance.Comparison, []insurance.LineItem, error) { + cctx, cancel := context.WithTimeout(ctx, insuranceRecommendationTimeout) + defer cancel() + c, items, err := h.readRecommendationVendor(cctx, rec) + if err == nil { + return c, items, nil + } + if cctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) { + return nil, nil, NewClientError(504, "Archera comparison timed out") + } + if _, ok := IsClientError(err); ok { + return nil, nil, err + } + return nil, nil, mapInsuranceError(err) +} + +func (h *Handler) readRecommendationVendor(ctx context.Context, rec *archera.RecommendationIdentity) (*insurance.Comparison, []insurance.LineItem, error) { + quote, err := h.insuranceClient(ctx) + if err != nil { + return nil, nil, err + } + client, ok := quote.(lineItemClient) + if !ok { + return nil, nil, NewClientError(503, "Archera recommendation comparison is not supported") + } + planID := h.insurance.PlanID() + items, err := client.LineItems(ctx, planID) + if err != nil { + return nil, nil, err + } + _, identities, err := archera.ProjectContract(&insurance.Comparison{}, items) + if err != nil { + return nil, nil, err + } + selected, status := archera.SelectLineItem(rec, identities) + if status != archera.SelectOne { + return &insurance.Comparison{FetchedAt: time.Now()}, items, nil + } + c, err := client.Comparison(ctx, insurance.ComparisonRequest{PlanID: planID, LineItemIDs: []string{selected}, + PaymentOptions: []insurance.PaymentOption{insurance.PaymentNoUpfront, insurance.PaymentPartialUpfront, insurance.PaymentAllUpfront}}) + return c, items, err +} diff --git a/internal/api/handler_insurance_recommendation_fetch_test.go b/internal/api/handler_insurance_recommendation_fetch_test.go new file mode 100644 index 000000000..458cef095 --- /dev/null +++ b/internal/api/handler_insurance_recommendation_fetch_test.go @@ -0,0 +1,161 @@ +package api + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/archera" + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" +) + +const ( + recItem = "22222222-2222-4222-8222-222222222222" + recOffer = "11111111-1111-4111-8111-111111111111" + recSpace = "sensitive-vendor-text" +) + +type recClient struct { + items []insurance.LineItem + cmp *insurance.Comparison + itemsErr error + cmpErr error + hang bool + itemCalls int + cmpCalls int + request insurance.ComparisonRequest +} + +func (c *recClient) LineItems(ctx context.Context, _ string) ([]insurance.LineItem, error) { + c.itemCalls++ + if c.hang { + <-ctx.Done() + return nil, ctx.Err() + } + return c.items, c.itemsErr +} + +func (c *recClient) Comparison(_ context.Context, r insurance.ComparisonRequest) (*insurance.Comparison, error) { + c.cmpCalls++ + c.request = r + return c.cmp, c.cmpErr +} + +func (c *recClient) Plan(context.Context, string) (*insurance.Plan, error) { + panic("unexpected plan read") +} + +type recProvider struct { + client insurance.QuoteClient + keys int +} + +func (p *recProvider) Status() archera.Status { return archera.Status{Configured: true} } +func (p *recProvider) PlanID() string { return insTestPlan } +func (p *recProvider) Client(context.Context) (insurance.QuoteClient, error) { + p.keys++ + return p.client, nil +} + +type quoteOnly struct{ insurance.QuoteClient } + +func recRecord() config.RecommendationRecord { + source, cloud := "Source-account-a", "cloud-account-a" + return config.RecommendationRecord{ID: "stored-a", Provider: "aws", Service: "savings-plans-ec2instance", Region: "us-east-1", + SourceAccountID: &source, CloudAccountID: &cloud, Term: 1, Payment: "no-upfront", + Details: []byte(`{"offering_id":"provider-offer-a","plan_type":"EC2Instance","region":"us-east-1","instance_family":"m5","hourly_commitment":0.5}`)} +} + +func recVendor(t *testing.T) ([]insurance.LineItem, *insurance.Comparison) { + t.Helper() + item := `{"id":"` + recItem + `","plan_id":"` + insTestPlan + `","offer_id":"` + recOffer + `","account_id":"Source-account-a","selected_quantity":7,"selected_commitment":0.5,"is_selected":true,"contract_term":"one_year_gris","payment_option":"No Upfront","contract_spec":{"commitment_type":"x"},"offer":{"id":"` + recOffer + `","type":"savings_plan","provider":"aws","provider_offering_id":"provider-offer-a","offering_id":"cat","duration_seconds":31536000,"region":"us-east-1","az":null,"instance_type":null,"instance_family":"m5","product_description":null,"tenancy":null,"plan_type":"EC2Instance","is_multi_az":null,"is_flexible":null,"payment_option":"No Upfront"}}` + items, err := insurance.DecodeLineItems(strings.NewReader("["+item+"]"), insTestPlan) + require.NoError(t, err) + cur := `{"is_current":true,"offer_id":"` + recOffer + `","selected_amount":0.5,"commitment_type":"savings_plan","contract_term":"one_year_gris","payment_option":"no_upfront","offer":{"provider":"aws","region":"us-east-1"},"discount_rate":0,"commitment_upfront_cost":0,"commitment_financials_monthly_rate":{},"delta_vs_current":{"monthly_net_savings":0,"upfront_cost":0,"discount_rate":0}}` + body := `{"current_totals":{"commitment_financials_monthly_rate":{},"commitment_upfront_cost":0},"hypothetical_totals":[],"data":[{"line_item_id":"` + recItem + `","current":` + cur + `,"candidates":[]}]}` + cmp, err := insurance.DecodeComparison(strings.NewReader(body), "org", insTestPlan, time.Unix(1, 0)) + require.NoError(t, err) + return items, cmp +} + +func fetchRig(t *testing.T) (*Handler, *recClient, *recProvider, *archera.RecommendationIdentity) { + t.Helper() + items, cmp := recVendor(t) + c := &recClient{items: items, cmp: cmp} + p := &recProvider{client: c} + row := recRecord() + rec, err := archera.ProjectRecommendation(&row) + require.NoError(t, err) + return &Handler{insurance: p}, c, p, rec +} + +func TestFetchRecommendationComparisonScopesToSelectedItemWithAllPayments(t *testing.T) { + h, c, _, rec := fetchRig(t) + cmp, items, err := h.FetchRecommendationComparison(context.Background(), rec) + require.NoError(t, err) + require.NotNil(t, cmp) + require.Len(t, items, 1) + require.Equal(t, []string{recItem}, c.request.LineItemIDs) + require.Equal(t, insTestPlan, c.request.PlanID) + require.ElementsMatch(t, []insurance.PaymentOption{insurance.PaymentNoUpfront, insurance.PaymentPartialUpfront, insurance.PaymentAllUpfront}, c.request.PaymentOptions) +} + +func TestFetchRecommendationComparisonNoneOrAmbiguousSkipsComparison(t *testing.T) { + for name, edit := range map[string]func(*recClient){ + "none": func(c *recClient) { c.items = nil }, + "ambiguous": func(c *recClient) { + twin := c.items[0] + twin.ID = "33333333-3333-4333-8333-333333333333" + c.items = append(c.items, twin) + }, + } { + t.Run(name, func(t *testing.T) { + h, c, _, rec := fetchRig(t) + edit(c) + cmp, _, err := h.FetchRecommendationComparison(context.Background(), rec) + require.NoError(t, err) + require.NotNil(t, cmp) + require.Empty(t, cmp.Rows) + require.Zero(t, c.cmpCalls) + }) + } +} + +func TestFetchRecommendationComparisonFailuresReturnNoPartialResult(t *testing.T) { + for name, tc := range map[string]struct { + setup func(*recClient, *recProvider) + code int + retry string + }{ + "line_items": {func(c *recClient, _ *recProvider) { c.itemsErr = errors.New(recSpace) }, 502, ""}, + "comparison": {func(c *recClient, _ *recProvider) { c.cmpErr = errors.New(recSpace) }, 502, ""}, + "rate_limited": {func(c *recClient, _ *recProvider) { + c.itemsErr = &insurance.HTTPError{StatusCode: 429, RetryAfter: 3 * time.Second} + }, 429, "3"}, + "unsupported": {func(c *recClient, p *recProvider) { p.client = quoteOnly{c} }, 503, ""}, + "deadline": {func(c *recClient, _ *recProvider) { c.hang = true }, 504, ""}, + } { + t.Run(name, func(t *testing.T) { + prev := insuranceRecommendationTimeout + insuranceRecommendationTimeout = 50 * time.Millisecond + t.Cleanup(func() { insuranceRecommendationTimeout = prev }) + h, c, p, rec := fetchRig(t) + tc.setup(c, p) + cmp, items, err := h.FetchRecommendationComparison(context.Background(), rec) + require.Nil(t, cmp) + require.Nil(t, items) + ce, ok := IsClientError(err) + require.True(t, ok, "got %v", err) + require.Equal(t, tc.code, ce.code) + require.NotContains(t, ce.message, recSpace) + if tc.retry != "" { + require.Equal(t, tc.retry, ce.headers["Retry-After"]) + } + }) + } +} From 112eaa98b1fad59b92ffd793491eda3d11d3571c Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 17 Oct 2026 21:01:13 +0200 Subject: [PATCH 2/2] fix(insurance): return the checked client error from the recommendation fetch Co-Authored-By: Claude Opus 5.5 --- internal/api/handler_insurance_recommendation_fetch.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/handler_insurance_recommendation_fetch.go b/internal/api/handler_insurance_recommendation_fetch.go index f88380900..2dc31cb2f 100644 --- a/internal/api/handler_insurance_recommendation_fetch.go +++ b/internal/api/handler_insurance_recommendation_fetch.go @@ -32,8 +32,8 @@ func (h *Handler) FetchRecommendationComparison(ctx context.Context, rec *archer if cctx.Err() != nil || errors.Is(err, context.DeadlineExceeded) { return nil, nil, NewClientError(504, "Archera comparison timed out") } - if _, ok := IsClientError(err); ok { - return nil, nil, err + if ce, ok := IsClientError(err); ok { + return nil, nil, ce } return nil, nil, mapInsuranceError(err) }