diff --git a/internal/api/handler_insurance_comparison.go b/internal/api/handler_insurance_comparison.go index 7c5d227d..4d94d91c 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 00000000..2dc31cb2 --- /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 ce, ok := IsClientError(err); ok { + return nil, nil, ce + } + 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 00000000..458cef09 --- /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"]) + } + }) + } +} diff --git a/internal/archera/recommendation_contract.go b/internal/archera/recommendation_contract.go index 0549f7cc..4bc96b03 100644 --- a/internal/archera/recommendation_contract.go +++ b/internal/archera/recommendation_contract.go @@ -27,17 +27,26 @@ func ProjectContract(c *insurance.Comparison, items []insurance.LineItem) ([]Com return rows, identities, nil } -// vendorAlternatives drops the entry the vendor flags as the current offer, which is never an alternative. -func vendorAlternatives(entries []insurance.OfferEntry) []CandidateIdentity { - var out []CandidateIdentity +// vendorEntries drops the entry the vendor flags as the current offer, which is never an alternative. +func vendorEntries(entries []insurance.OfferEntry) []*insurance.OfferEntry { + out := make([]*insurance.OfferEntry, 0, len(entries)) for i := range entries { if !entries[i].IsCurrent { - out = append(out, contractCandidate(&entries[i])) + out = append(out, &entries[i]) } } return out } +func vendorAlternatives(entries []insurance.OfferEntry) []CandidateIdentity { + vendor := vendorEntries(entries) + out := make([]CandidateIdentity, 0, len(vendor)) + for _, e := range vendor { + out = append(out, contractCandidate(e)) + } + return out +} + func contractLineItem(li *insurance.LineItem) LineItemIdentity { offer := &li.Offer kind := offer.Type diff --git a/internal/archera/recommendation_dto.go b/internal/archera/recommendation_dto.go new file mode 100644 index 00000000..b5bc99e6 --- /dev/null +++ b/internal/archera/recommendation_dto.go @@ -0,0 +1,138 @@ +package archera + +import ( + "errors" + "strings" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" +) + +// errRecommendationRowMissing is returned when an associated match has no vendor row to render. +var errRecommendationRowMissing = errors.New("archera recommendation row is unavailable") + +// RecommendationComparisonDTO is the recommendation-only response: one current offer and its +// vendor-linked candidates. It carries no plan totals, deltas, guarantees, source account or org ID. +type RecommendationComparisonDTO struct { + RecommendationID string `json:"recommendation_id"` + Status MatchStatus `json:"status"` + Reason Reason `json:"reason"` + AmountProvenance Reason `json:"amount_provenance"` + EconomicStatus Reason `json:"economic_status"` + FetchedAt string `json:"fetched_at"` + Currency *string `json:"currency"` + CurrencyNote string `json:"currency_note"` + ComparableDelta *string `json:"comparable_delta"` + PremiumIncluded bool `json:"premium_included"` + Current *RecommendationOfferDTO `json:"current"` + Candidates []RecommendationAlternativeDTO `json:"candidates"` + NonGatingDisclosure string `json:"non_gating_disclosure"` + SponsorshipDisclosure string `json:"sponsorship_disclosure"` +} + +// RecommendationOfferDTO names the vendor's units explicitly. The native_* fields come from the +// line item and stay null on candidates, which the vendor does not give a native identity. +type RecommendationOfferDTO struct { + OfferID string `json:"offer_id"` + Provider string `json:"provider"` + CommitmentType string `json:"commitment_type"` + HourlySelectedCommitment *string `json:"hourly_selected_commitment"` + NativeDurationSeconds *string `json:"native_duration_seconds"` + NativePlanType *string `json:"native_plan_type"` + NativeInstanceFamily *string `json:"native_instance_family"` + EffectiveContractTerm *string `json:"effective_contract_term"` + PaymentOption *string `json:"payment_option"` + LeaseAttached bool `json:"lease_attached"` + Monthly FinancialsDTO `json:"monthly"` + UpfrontCost *string `json:"upfront_one_time_cost"` +} + +type RecommendationAlternativeDTO struct { + OfferID string `json:"offer_id"` + Status CandidateStatus `json:"status"` + Reason Reason `json:"reason"` + NativeIdentityStatus CandidateStatus `json:"native_identity_status"` + Figures *RecommendationOfferDTO `json:"figures"` +} + +// BuildRecommendationComparison matches the stored recommendation against the vendor line items +// and renders only the matched row. An unmatched result carries no figures. It returns +// ErrUnrepresentable when a rendered number has no exact decimal form. +func BuildRecommendationComparison(id, planID string, rec *RecommendationIdentity, c *insurance.Comparison, items []insurance.LineItem) (*RecommendationComparisonDTO, error) { + rows, identities, err := ProjectContract(c, items) + if err != nil { + return nil, err + } + match, err := MatchRecommendation(planID, rec, rows, identities) + if err != nil { + return nil, err + } + out := &RecommendationComparisonDTO{ + RecommendationID: id, Status: match.Status, Reason: match.Reason, + AmountProvenance: match.AmountProvenance, EconomicStatus: ReasonIdentityUnproven, + FetchedAt: c.FetchedAt.UTC().Format(time.RFC3339), CurrencyNote: currencyNote, + PremiumIncluded: true, Candidates: []RecommendationAlternativeDTO{}, + NonGatingDisclosure: common.ArcheraNonGatingDisclosure, SponsorshipDisclosure: common.ArcheraSponsorshipDisclosure, + } + if match.Status != ResourceAssociated { + return out, nil + } + out.EconomicStatus = match.EconomicStatus + m := &mapper{} + if err := m.fillMatched(out, &match, c, items); err != nil { + return nil, err + } + return out, nil +} + +func (m *mapper) fillMatched(out *RecommendationComparisonDTO, match *RecommendationMatch, c *insurance.Comparison, items []insurance.LineItem) error { + row := findRow(c.Rows, match.LineItemID) + if row == nil { + return errRecommendationRowMissing + } + out.Current = m.recommendationOffer(&row.Current) + out.Candidates = m.recommendationAlternatives(vendorEntries(row.Candidates), match.Candidates) + for i := range items { + if strings.EqualFold(items[i].ID, match.LineItemID) { + offer := &items[i].Offer + out.Current.NativeDurationSeconds = m.dec(offer.DurationSeconds) + out.Current.NativePlanType = cleanPtr(offer.PlanType) + out.Current.NativeInstanceFamily = cleanPtr(offer.InstanceFamily) + } + } + return m.err +} + +func findRow(rows []insurance.ComparisonRow, lineItemID string) *insurance.ComparisonRow { + for i := range rows { + if strings.EqualFold(rows[i].LineItemID, lineItemID) { + return &rows[i] + } + } + return nil +} + +func (m *mapper) recommendationOffer(e *insurance.OfferEntry) *RecommendationOfferDTO { + return &RecommendationOfferDTO{ + OfferID: CleanString(e.OfferID), Provider: CleanString(string(e.Provider)), CommitmentType: CleanString(e.CommitmentType), + HourlySelectedCommitment: m.dec(e.SelectedAmount), EffectiveContractTerm: cleanPtr(e.ContractTerm), + PaymentOption: payment(e.PaymentOption), LeaseAttached: e.LeaseBacked(), + Monthly: m.financials(e.Monthly), UpfrontCost: m.dec(e.UpfrontCost), + } +} + +// recommendationAlternatives pairs the vendor alternatives with their associations by index. +func (m *mapper) recommendationAlternatives(entries []*insurance.OfferEntry, associations []CandidateAssociation) []RecommendationAlternativeDTO { + out := make([]RecommendationAlternativeDTO, 0, len(associations)) + for i := range associations { + a := &associations[i] + alt := RecommendationAlternativeDTO{OfferID: CleanString(a.OfferID), Status: a.Status, + Reason: a.Reason, NativeIdentityStatus: a.NativeIdentityStatus} + if a.Status == CandidateVendorAlternative { + alt.Figures = m.recommendationOffer(entries[i]) + } + out = append(out, alt) + } + return out +} diff --git a/internal/archera/recommendation_dto_test.go b/internal/archera/recommendation_dto_test.go new file mode 100644 index 00000000..bc8f38da --- /dev/null +++ b/internal/archera/recommendation_dto_test.go @@ -0,0 +1,247 @@ +package archera + +import ( + "encoding/json" + "math/big" + "strings" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/stretchr/testify/require" +) + +func buildFixture(t *testing.T, c *insurance.Comparison, items []insurance.LineItem) (*RecommendationComparisonDTO, error) { + t.Helper() + record := storedMatchFixture() + rec, err := ProjectRecommendation(&record) + require.NoError(t, err) + return BuildRecommendationComparison("requested-row", matchPlan, rec, c, items) +} + +func mustMarshal(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return b +} + +func TestRecommendationDTOMapsEveryFieldFromDistinctValues(t *testing.T) { + c, items := contractMatchFixture(t) + cur := &c.Rows[0].Current + cur.SelectedAmount = mustRat(t, "0.50000000000000000001") + cur.Monthly = insurance.Financials{CommitmentCostTotal: big.NewRat(101, 1), CloudProviderCost: big.NewRat(102, 1), + Premium: big.NewRat(103, 1), GrossSavings: big.NewRat(104, 1), NetSavings: big.NewRat(105, 1), CoveredOnDemandCost: big.NewRat(106, 1)} + cur.UpfrontCost = big.NewRat(107, 1) + cand := &c.Rows[0].Candidates[0] + cand.Monthly = insurance.Financials{CommitmentCostTotal: big.NewRat(201, 1), CloudProviderCost: big.NewRat(202, 1), + Premium: big.NewRat(203, 1), GrossSavings: big.NewRat(204, 1), NetSavings: big.NewRat(205, 1), CoveredOnDemandCost: big.NewRat(206, 1)} + cand.UpfrontCost = big.NewRat(207, 1) + lease := "synthetic-lease" + cand.LeaseMenuItemID = &lease + c.Current.UpfrontCost = big.NewRat(987654321, 1) + beforeC, beforeItems := mustMarshal(t, c), mustMarshal(t, items) + + got, err := buildFixture(t, c, items) + require.NoError(t, err) + require.Equal(t, "requested-row", got.RecommendationID) + require.Equal(t, ResourceAssociated, got.Status) + require.Empty(t, got.Reason) + require.Equal(t, ReasonStoredDecimalOnly, got.AmountProvenance) + require.Equal(t, ReasonUnavailableSourcePrecision, got.EconomicStatus) + require.Equal(t, "1970-01-01T00:00:01Z", got.FetchedAt) + require.Nil(t, got.Currency) + require.Nil(t, got.ComparableDelta) + require.Equal(t, currencyNote, got.CurrencyNote) + require.True(t, got.PremiumIncluded) + require.NotEmpty(t, got.NonGatingDisclosure) + require.NotEmpty(t, got.SponsorshipDisclosure) + + want := RecommendationOfferDTO{OfferID: matchOffer, Provider: "aws", CommitmentType: "savings_plan", + HourlySelectedCommitment: ptr("0.50000000000000000001"), NativeDurationSeconds: ptr("31536000"), + NativePlanType: ptr("EC2Instance"), NativeInstanceFamily: ptr("m5"), EffectiveContractTerm: ptr("one_year_gris"), + PaymentOption: ptr("no_upfront"), LeaseAttached: false, + Monthly: FinancialsDTO{CommitmentCostTotal: ptr("101"), CloudProviderCost: ptr("102"), Premium: ptr("103"), + GrossSavings: ptr("104"), NetSavings: ptr("105"), CoveredOnDemandCost: ptr("106")}, + UpfrontCost: ptr("107")} + require.Equal(t, want, *got.Current) + + require.Len(t, got.Candidates, 2) + alt := got.Candidates[0] + require.Equal(t, CandidateVendorAlternative, alt.Status) + require.Equal(t, CandidateUnavailable, alt.NativeIdentityStatus) + wantAlt := RecommendationOfferDTO{OfferID: "faadf00d-ffff-4aba-8ada-abcdefabcdef", Provider: "aws", CommitmentType: "savings_plan", + HourlySelectedCommitment: ptr("0.50000000000000000001"), EffectiveContractTerm: ptr("one_year_gris"), + PaymentOption: ptr("all_upfront"), LeaseAttached: true, + Monthly: FinancialsDTO{CommitmentCostTotal: ptr("201"), CloudProviderCost: ptr("202"), Premium: ptr("203"), + GrossSavings: ptr("204"), NetSavings: ptr("205"), CoveredOnDemandCost: ptr("206")}, + UpfrontCost: ptr("207")} + require.Equal(t, wantAlt, *alt.Figures) + require.Nil(t, alt.Figures.NativeDurationSeconds) + require.Nil(t, alt.Figures.NativePlanType) + require.Nil(t, alt.Figures.NativeInstanceFamily) + + wire := string(mustMarshal(t, got)) + for _, leak := range []string{"Source-account-a", "fixture-org", "987654321", "hypotheticals", "delta_vs_current", "provider-offer-a", "guarantee"} { + require.NotContains(t, wire, leak) + } + require.Equal(t, beforeC, mustMarshal(t, c)) + require.Equal(t, beforeItems, mustMarshal(t, items)) +} + +func TestRecommendationDTOKeepsZeroDistinctFromNull(t *testing.T) { + c, items := contractMatchFixture(t) + c.Rows[0].Current.UpfrontCost = new(big.Rat) + c.Rows[0].Current.Monthly = insurance.Financials{Premium: new(big.Rat)} + got, err := buildFixture(t, c, items) + require.NoError(t, err) + require.Equal(t, "0", *got.Current.UpfrontCost) + require.Equal(t, "0", *got.Current.Monthly.Premium) + require.Nil(t, got.Current.Monthly.NetSavings) +} + +func TestRecommendationDTOKeepsVeryPreciseDecimalsExact(t *testing.T) { + c, items := contractMatchFixture(t) + precise := mustRat(t, "123456789012345678.123456789012345678901234567890") + c.Rows[0].Current.UpfrontCost = precise + c.Rows[0].Current.Monthly.NetSavings = mustRat(t, "-0.000000000000000000000001") + got, err := buildFixture(t, c, items) + require.NoError(t, err) + require.Equal(t, "123456789012345678.12345678901234567890123456789", *got.Current.UpfrontCost) + require.Equal(t, "-0.000000000000000000000001", *got.Current.Monthly.NetSavings) +} + +func TestRecommendationDTOUnmatchedCarriesNoFigures(t *testing.T) { + for name, change := range map[string]func(*RecommendationIdentity, *insurance.Comparison, *[]insurance.LineItem){ + "source": func(r *RecommendationIdentity, _ *insurance.Comparison, _ *[]insurance.LineItem) { + r.SourceAccountID = "other" + }, + "ambiguous": func(_ *RecommendationIdentity, c *insurance.Comparison, i *[]insurance.LineItem) { + dup := (*i)[0] + dup.ID = "22222222-2222-4222-8222-222222222222" + *i = append(*i, dup) + row := c.Rows[0] + row.LineItemID = dup.ID + c.Rows = append(c.Rows, row) + }, + } { + t.Run(name, func(t *testing.T) { + c, items := contractMatchFixture(t) + c.Rows[0].Current.UpfrontCost = big.NewRat(1, 3) + record := storedMatchFixture() + rec, err := ProjectRecommendation(&record) + require.NoError(t, err) + change(rec, c, &items) + got, err := BuildRecommendationComparison("requested-row", matchPlan, rec, c, items) + require.NoError(t, err) + require.Equal(t, MatchUnavailable, got.Status) + require.NotEmpty(t, got.Reason) + require.Empty(t, got.AmountProvenance) + require.Equal(t, ReasonIdentityUnproven, got.EconomicStatus) + require.Nil(t, got.Current) + require.Empty(t, got.Candidates) + require.Nil(t, got.Currency) + require.Nil(t, got.ComparableDelta) + require.NotContains(t, string(mustMarshal(t, got)), "faadf00d") + }) + } +} + +func TestRecommendationDTOCandidateMismatchAndUnrepresentable(t *testing.T) { + c, items := contractMatchFixture(t) + c.Rows[0].Candidates[0].SelectedAmount = big.NewRat(1, 2) + c.Rows[0].Candidates[0].UpfrontCost = big.NewRat(1, 3) + got, err := buildFixture(t, c, items) + require.NoError(t, err) + require.Equal(t, ResourceAssociated, got.Status) + require.Nil(t, got.Candidates[0].Figures) + require.Equal(t, CandidateUnavailable, got.Candidates[0].Status) + require.Equal(t, ReasonCandidateBasisUnproven, got.Candidates[0].Reason) + require.NotNil(t, got.Candidates[1].Figures) + + for name, fn := range map[string]func(*insurance.Comparison){ + "current": func(c *insurance.Comparison) { c.Rows[0].Current.UpfrontCost = big.NewRat(1, 3) }, + "candidate": func(c *insurance.Comparison) { c.Rows[0].Candidates[1].Monthly.Premium = big.NewRat(1, 3) }, + } { + t.Run(name, func(t *testing.T) { + sub, subItems := contractMatchFixture(t) + fn(sub) + subGot, subErr := buildFixture(t, sub, subItems) + require.ErrorIs(t, subErr, ErrUnrepresentable) + require.Nil(t, subGot) + }) + } + _, err = buildFixture(t, nil, items) + require.Error(t, err) +} + +func TestRecommendationDTOMissingRowIsAnError(t *testing.T) { + c, items := contractMatchFixture(t) + record := storedMatchFixture() + rec, err := ProjectRecommendation(&record) + require.NoError(t, err) + rows, ids, err := ProjectContract(c, items) + require.NoError(t, err) + match, err := MatchRecommendation(matchPlan, rec, rows, ids) + require.NoError(t, err) + c.Rows = nil + m := &mapper{} + require.ErrorIs(t, m.fillMatched(&RecommendationComparisonDTO{}, &match, c, items), errRecommendationRowMissing) +} + +func TestRecommendationDTONeverListsCurrentOfferAsAlternative(t *testing.T) { + c, items := contractMatchFixture(t) + row := &c.Rows[0] + currentCopy := row.Current + currentCopy.UpfrontCost = big.NewRat(99, 1) + row.Candidates[0].UpfrontCost = big.NewRat(31, 1) + row.Candidates[1].SelectedAmount = big.NewRat(1, 2) + row.Candidates = append([]insurance.OfferEntry{currentCopy}, row.Candidates...) + got, err := buildFixture(t, c, items) + require.NoError(t, err) + require.Len(t, got.Candidates, 2) + require.Equal(t, "31", *got.Candidates[0].Figures.UpfrontCost) + require.Nil(t, got.Candidates[1].Figures) + require.Equal(t, ReasonCandidateBasisUnproven, got.Candidates[1].Reason) +} + +func TestRecommendationDTOIgnoresUnrelatedRows(t *testing.T) { + c, items := contractMatchFixture(t) + unrelated := c.Rows[0] + unrelated.LineItemID = "22222222-2222-4222-8222-222222222222" + unrelated.Current.UpfrontCost = big.NewRat(1, 3) + c.Rows = append(c.Rows, unrelated) + other := items[0] + other.ID = unrelated.LineItemID + src := "another-source" + other.AccountID = &src + items = append(items, other) + got, err := buildFixture(t, c, items) + require.NoError(t, err) + require.Equal(t, ResourceAssociated, got.Status) + require.Len(t, got.Candidates, 2) + require.NotContains(t, string(mustMarshal(t, got)), "another-source") +} + +func TestRecommendationDTOSanitizesVendorStrings(t *testing.T) { + c, items := contractMatchFixture(t) + hostile := "one_year\u202e\n