diff --git a/internal/archera/recommendation_alternatives.go b/internal/archera/recommendation_alternatives.go index 0a4cfee6c..b1da28b38 100644 --- a/internal/archera/recommendation_alternatives.go +++ b/internal/archera/recommendation_alternatives.go @@ -7,14 +7,25 @@ type CandidateIdentity struct { SelectedAmount *big.Rat } +// CandidateStatus classifies a vendor candidate against the stored recommendation. +type CandidateStatus string + +const ( + CandidateVendorAlternative CandidateStatus = "vendor_alternative" + CandidateUnavailable CandidateStatus = "unavailable" +) + type CandidateAssociation struct { - OfferID, Status, Reason, NativeIdentityStatus string + OfferID string + Status CandidateStatus + Reason Reason + NativeIdentityStatus CandidateStatus } func associateCandidate(r *RecommendationIdentity, c *CandidateIdentity) CandidateAssociation { - out := CandidateAssociation{OfferID: c.OfferID, Status: "unavailable", Reason: "candidate_basis_unproven", NativeIdentityStatus: "unavailable"} - if validMatchUUID(c.OfferID) && c.Provider == r.Provider && c.Type == "savings_plan" && validPayment(c.Payment) && sameAmount(r.HourlyCommitment, c.SelectedAmount) { - out.Status, out.Reason = "vendor_alternative", "" + out := CandidateAssociation{OfferID: c.OfferID, Status: CandidateUnavailable, Reason: ReasonCandidateBasisUnproven, NativeIdentityStatus: CandidateUnavailable} + if validMatchUUID(c.OfferID) && c.Provider == r.Provider && c.Type == commitmentSavingsPlan && validPayment(c.Payment) && sameAmount(r.HourlyCommitment, c.SelectedAmount) { + out.Status, out.Reason = CandidateVendorAlternative, "" } return out } diff --git a/internal/archera/recommendation_alternatives_test.go b/internal/archera/recommendation_alternatives_test.go index 4f8a41554..cb1b991ca 100644 --- a/internal/archera/recommendation_alternatives_test.go +++ b/internal/archera/recommendation_alternatives_test.go @@ -29,11 +29,11 @@ func TestMatchRecommendationPreservesVendorAlternativesWithoutNativeClaims(t *te require.Len(t, got.Candidates, 3) for i, association := range got.Candidates { require.Equal(t, rows[0].Candidates[i].OfferID, association.OfferID) - require.Equal(t, "vendor_alternative", association.Status) + require.Equal(t, CandidateVendorAlternative, association.Status) require.Empty(t, association.Reason) - require.Equal(t, "unavailable", association.NativeIdentityStatus) + require.Equal(t, CandidateUnavailable, association.NativeIdentityStatus) } - require.Equal(t, "unavailable_source_precision", got.EconomicStatus) + require.Equal(t, ReasonUnavailableSourcePrecision, got.EconomicStatus) after, err := json.Marshal([]any{r, rows, items}) require.NoError(t, err) require.Equal(t, before, after) @@ -64,9 +64,9 @@ func TestMatchRecommendationDoesNotBorrowCandidateBasis(t *testing.T) { require.NoError(t, err) require.Equal(t, ResourceAssociated, got.Status) require.Len(t, got.Candidates, 1) - require.Equal(t, "unavailable", got.Candidates[0].Status) + require.Equal(t, CandidateUnavailable, got.Candidates[0].Status) require.NotEmpty(t, got.Candidates[0].Reason) - require.Equal(t, "unavailable", got.Candidates[0].NativeIdentityStatus) + require.Equal(t, CandidateUnavailable, got.Candidates[0].NativeIdentityStatus) }) } } diff --git a/internal/archera/recommendation_contract.go b/internal/archera/recommendation_contract.go new file mode 100644 index 000000000..0549f7cc7 --- /dev/null +++ b/internal/archera/recommendation_contract.go @@ -0,0 +1,69 @@ +package archera + +import ( + "errors" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" +) + +// ProjectContract projects the published comparison and line items into the identities MatchRecommendation joins. +func ProjectContract(c *insurance.Comparison, items []insurance.LineItem) ([]ComparisonIdentity, []LineItemIdentity, error) { + if c == nil { + return nil, nil, errors.New("archera comparison identity is unavailable") + } + rows := make([]ComparisonIdentity, 0, len(c.Rows)) + for i := range c.Rows { + r := &c.Rows[i] + current := contractCandidate(&r.Current) + rows = append(rows, ComparisonIdentity{Region: r.Current.Region, IsCurrent: r.Current.IsCurrent, + LineItemID: r.LineItemID, CurrentOfferID: current.OfferID, Provider: current.Provider, + CurrentType: current.Type, CurrentPayment: current.Payment, CurrentSelectedAmount: current.SelectedAmount, + Candidates: vendorAlternatives(r.Candidates)}) + } + identities := make([]LineItemIdentity, 0, len(items)) + for i := range items { + identities = append(identities, contractLineItem(&items[i])) + } + 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 + for i := range entries { + if !entries[i].IsCurrent { + out = append(out, contractCandidate(&entries[i])) + } + } + return out +} + +func contractLineItem(li *insurance.LineItem) LineItemIdentity { + offer := &li.Offer + kind := offer.Type + if !li.IsSelected || (offer.PaymentOption != nil && *offer.PaymentOption != li.PaymentOption) { + kind = "" + } + return LineItemIdentity{ + ID: li.ID, PlanID: li.PlanID, OfferID: li.OfferID, Provider: string(offer.Provider), + SourceAccountID: identityText(li.AccountID), ProviderOfferingID: offer.ProviderOfferingID, Type: kind, + PlanType: identityText(offer.PlanType), Region: identityText(offer.Region), InstanceFamily: identityText(offer.InstanceFamily), + Payment: string(li.PaymentOption), DurationSeconds: offer.DurationSeconds, SelectedCommitment: li.SelectedCommitment, + } +} + +func contractCandidate(offer *insurance.OfferEntry) CandidateIdentity { + out := CandidateIdentity{OfferID: offer.OfferID, Provider: string(offer.Provider), + Type: offer.CommitmentType, SelectedAmount: offer.SelectedAmount} + if offer.PaymentOption != nil { + out.Payment = string(*offer.PaymentOption) + } + return out +} + +func identityText(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/internal/archera/recommendation_contract_test.go b/internal/archera/recommendation_contract_test.go new file mode 100644 index 000000000..679ff15c0 --- /dev/null +++ b/internal/archera/recommendation_contract_test.go @@ -0,0 +1,155 @@ +package archera + +import ( + "math/big" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/stretchr/testify/require" +) + +func contractMatchFixture(t *testing.T) (*insurance.Comparison, []insurance.LineItem) { + t.Helper() + item := `{"id":"` + matchItem + `","plan_id":"` + matchPlan + `","offer_id":"` + matchOffer + `","account_id":"Source-account-a","selected_quantity":7,"selected_commitment":0.50000000000000000001,"is_selected":true,"contract_term":"one_year_gris","payment_option":"No Upfront","contract_spec":{"commitment_type":"opaque-contract-type"},"offer":{"id":"` + matchOffer + `","type":"savings_plan","provider":"aws","provider_offering_id":"provider-offer-a","offering_id":"distinct-catalog-offer","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+"]"), matchPlan) + require.NoError(t, err) + current := `{"is_current":true,"offer_id":"` + matchOffer + `","selected_amount":0.50000000000000000001,"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}}` + candidate := strings.ReplaceAll(strings.Replace(current, `"is_current":true`, `"is_current":false`, 1), matchOffer, "faadf00d-ffff-4aba-8ada-abcdefabcdef") + candidate = strings.Replace(candidate, "no_upfront", "all_upfront", 1) + body := `{"current_totals":{"commitment_financials_monthly_rate":{},"commitment_upfront_cost":0},"hypothetical_totals":[],"data":[{"line_item_id":"` + matchItem + `","current":` + current + `,"candidates":[` + candidate + "," + candidate + `]}]}` + comparison, err := insurance.DecodeComparison(strings.NewReader(body), "fixture-org", matchPlan, time.Unix(1, 0)) + require.NoError(t, err) + return comparison, items +} + +func TestProjectContractJoinsActualDecodedIdentity(t *testing.T) { + comparison, items := contractMatchFixture(t) + record := storedMatchFixture() + rec, err := ProjectRecommendation(&record) + require.NoError(t, err) + rows, identities, err := ProjectContract(comparison, items) + require.NoError(t, err) + require.Len(t, rows[0].Candidates, 2) + require.Equal(t, "all_upfront", rows[0].Candidates[0].Payment) + require.Equal(t, rows[0].Candidates[0].OfferID, rows[0].Candidates[1].OfferID) + require.Equal(t, "provider-offer-a", identities[0].ProviderOfferingID) + require.Equal(t, "opaque-contract-type", items[0].ContractSpec.CommitmentType) + require.Equal(t, "one_year_gris", items[0].ContractTerm) + got, err := MatchRecommendation(matchPlan, rec, rows, identities) + require.NoError(t, err) + require.Equal(t, ResourceAssociated, got.Status) + require.Len(t, got.Candidates, 2) + require.Equal(t, CandidateVendorAlternative, got.Candidates[0].Status) + require.Equal(t, ReasonStoredDecimalOnly, got.AmountProvenance) + require.Equal(t, ReasonUnavailableSourcePrecision, got.EconomicStatus) + _, _, err = ProjectContract(nil, items) + require.Error(t, err) +} + +func TestProjectContractPreservesMissingOrContradictoryEvidence(t *testing.T) { + tests := []struct { + name string + change func(*insurance.Comparison, *insurance.LineItem) + }{ + {"account", func(_ *insurance.Comparison, i *insurance.LineItem) { i.AccountID = nil }}, + {"catalog_is_not_provider_offering", func(_ *insurance.Comparison, i *insurance.LineItem) { i.Offer.ProviderOfferingID = "" }}, + {"duration", func(_ *insurance.Comparison, i *insurance.LineItem) { i.Offer.DurationSeconds = nil }}, + {"region", func(_ *insurance.Comparison, i *insurance.LineItem) { i.Offer.Region = nil }}, + {"family", func(_ *insurance.Comparison, i *insurance.LineItem) { i.Offer.InstanceFamily = nil }}, + {"plan_type", func(_ *insurance.Comparison, i *insurance.LineItem) { i.Offer.PlanType = nil }}, + {"unselected", func(_ *insurance.Comparison, i *insurance.LineItem) { i.IsSelected = false }}, + {"payment", func(_ *insurance.Comparison, i *insurance.LineItem) { + p := insurance.PaymentAllUpfront + i.Offer.PaymentOption = &p + }}, + {"current_flag", func(c *insurance.Comparison, _ *insurance.LineItem) { c.Rows[0].Current.IsCurrent = false }}, + {"current_region", func(c *insurance.Comparison, _ *insurance.LineItem) { c.Rows[0].Current.Region = nil }}, + {"current_type", func(c *insurance.Comparison, _ *insurance.LineItem) { c.Rows[0].Current.CommitmentType = "ri" }}, + {"amount", func(c *insurance.Comparison, _ *insurance.LineItem) { + c.Rows[0].Current.SelectedAmount = big.NewRat(1, 2) + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c, items := contractMatchFixture(t) + tt.change(c, &items[0]) + record := storedMatchFixture() + rec, err := ProjectRecommendation(&record) + require.NoError(t, err) + rows, identities, err := ProjectContract(c, items) + require.NoError(t, err) + got, err := MatchRecommendation(matchPlan, rec, rows, identities) + require.NoError(t, err) + require.Equal(t, MatchUnavailable, got.Status) + }) + } +} + +func TestProjectContractExcludesTheCurrentOfferFromAlternatives(t *testing.T) { + comparison, items := contractMatchFixture(t) + row := &comparison.Rows[0] + current := row.Current + current.IsCurrent = true + row.Candidates = []insurance.OfferEntry{row.Candidates[0], current, row.Candidates[1]} + rows, _, err := ProjectContract(comparison, items) + require.NoError(t, err) + require.Len(t, rows[0].Candidates, 2) + for _, candidate := range rows[0].Candidates { + require.NotEqual(t, current.OfferID, candidate.OfferID) + } + record := storedMatchFixture() + rec, err := ProjectRecommendation(&record) + require.NoError(t, err) + _, identities, err := ProjectContract(comparison, items) + require.NoError(t, err) + got, err := MatchRecommendation(matchPlan, rec, rows, identities) + require.NoError(t, err) + require.Len(t, got.Candidates, 2) + require.Empty(t, vendorAlternatives(nil)) +} + +func TestVendorAlternativesMapsEveryField(t *testing.T) { + payment := insurance.PaymentPartialUpfront + entries := []insurance.OfferEntry{ + {IsCurrent: true, OfferID: "current"}, + {OfferID: "offer-a", Provider: "aws", CommitmentType: "savings_plan", PaymentOption: &payment, SelectedAmount: big.NewRat(3, 4)}, + {OfferID: "offer-b", Provider: "azure", CommitmentType: "ri"}, + } + got := vendorAlternatives(entries) + require.Equal(t, []CandidateIdentity{ + {OfferID: "offer-a", Provider: "aws", Type: "savings_plan", Payment: "partial_upfront", SelectedAmount: big.NewRat(3, 4)}, + {OfferID: "offer-b", Provider: "azure", Type: "ri"}, + }, got) +} + +func TestProjectContractCarriesPaymentOptions(t *testing.T) { + comparison, items := contractMatchFixture(t) + upfront, partial := insurance.PaymentAllUpfront, insurance.PaymentPartialUpfront + items[0].PaymentOption, items[0].Offer.PaymentOption = upfront, &upfront + comparison.Rows[0].Current.PaymentOption = &partial + rows, identities, err := ProjectContract(comparison, items) + require.NoError(t, err) + require.Equal(t, "all_upfront", identities[0].Payment) + require.Equal(t, "partial_upfront", rows[0].CurrentPayment) +} + +func TestProjectContractUnselectedTwinDoesNotCreateAmbiguity(t *testing.T) { + comparison, items := contractMatchFixture(t) + twin := items[0] + twin.ID = "eaadf00d-ffff-4aba-8ada-abcdefabcdef" + twin.IsSelected = false + items = append(items, twin) + record := storedMatchFixture() + rec, err := ProjectRecommendation(&record) + require.NoError(t, err) + _, identities, err := ProjectContract(comparison, items) + require.NoError(t, err) + require.Empty(t, identities[1].Type) + id, status := SelectLineItem(rec, identities) + require.Equal(t, SelectOne, status) + require.Equal(t, matchItem, id) + _, status = SelectLineItem(rec, identities[1:]) + require.Equal(t, SelectNone, status) +} diff --git a/internal/archera/recommendation_match.go b/internal/archera/recommendation_match.go index a8d06e6ae..948fe02c1 100644 --- a/internal/archera/recommendation_match.go +++ b/internal/archera/recommendation_match.go @@ -6,6 +6,7 @@ import ( "strings" "unicode" + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" "github.com/google/uuid" ) @@ -43,14 +44,29 @@ const ( ResourceAssociated MatchStatus = "resource_associated" ) +// Reason names why a match, candidate, amount or economic status holds. +type Reason string + +const ( + ReasonIdentityUnproven Reason = "identity_unproven" + ReasonAmbiguousLineItems Reason = "ambiguous_line_items" + ReasonComparisonBasisUnproven Reason = "comparison_basis_unproven" + ReasonCandidateBasisUnproven Reason = "candidate_basis_unproven" + ReasonStoredDecimalOnly Reason = "stored_decimal_only" + ReasonUnavailableSourcePrecision Reason = "unavailable_source_precision" +) + +const commitmentSavingsPlan = "savings_plan" + type RecommendationMatch struct { - Status MatchStatus - LineItemID, Reason, AmountProvenance, EconomicStatus string - Candidates []CandidateAssociation + Status MatchStatus + LineItemID string + Reason, AmountProvenance, EconomicStatus Reason + Candidates []CandidateAssociation } func MatchRecommendation(planID string, rec *RecommendationIdentity, rows []ComparisonIdentity, items []LineItemIdentity) (RecommendationMatch, error) { - unavailable := RecommendationMatch{Status: MatchUnavailable, Reason: "identity_unproven"} + unavailable := RecommendationMatch{Status: MatchUnavailable, Reason: ReasonIdentityUnproven} if !validRecommendation(rec) { return unavailable, nil } @@ -61,28 +77,22 @@ func MatchRecommendation(planID string, rec *RecommendationIdentity, rows []Comp if index == nil { return unavailable, nil } - var selected *LineItemIdentity - for i := range items { - if !sameResource(rec, &items[i]) { - continue - } - if selected != nil { - unavailable.Reason = "ambiguous_line_items" - return unavailable, nil - } - selected = &items[i] - } - if selected == nil { + id, status := SelectLineItem(rec, items) + switch status { + case SelectAmbiguous: + unavailable.Reason = ReasonAmbiguousLineItems + return unavailable, nil + case SelectNone: return unavailable, nil } - row := index[strings.ToLower(selected.ID)] + row := index[strings.ToLower(id)] if !sameCurrentBasis(rec, row) { - unavailable.Reason = "comparison_basis_unproven" + unavailable.Reason = ReasonComparisonBasisUnproven return unavailable, nil } result := RecommendationMatch{ Status: ResourceAssociated, LineItemID: row.LineItemID, - AmountProvenance: "stored_decimal_only", EconomicStatus: "unavailable_source_precision", + AmountProvenance: ReasonStoredDecimalOnly, EconomicStatus: ReasonUnavailableSourcePrecision, } for i := range row.Candidates { result.Candidates = append(result.Candidates, associateCandidate(rec, &row.Candidates[i])) @@ -90,6 +100,37 @@ func MatchRecommendation(planID string, rec *RecommendationIdentity, rows []Comp return result, nil } +// SelectStatus says how many line items share the recommendation's resource identity. +type SelectStatus string + +const ( + SelectOne SelectStatus = "selected" + SelectNone SelectStatus = "none" + SelectAmbiguous SelectStatus = "ambiguous" +) + +// SelectLineItem returns the ID of the single line item that is the recommendation's resource, +// or an empty ID with SelectNone (no match, or an unsupported recommendation) or SelectAmbiguous. +func SelectLineItem(rec *RecommendationIdentity, items []LineItemIdentity) (string, SelectStatus) { + if !validRecommendation(rec) { + return "", SelectNone + } + id, found := "", false + for i := range items { + if !sameResource(rec, &items[i]) { + continue + } + if found { + return "", SelectAmbiguous + } + id, found = items[i].ID, true + } + if !found { + return "", SelectNone + } + return id, SelectOne +} + func validRecommendation(r *RecommendationIdentity) bool { if r == nil { return false @@ -113,8 +154,8 @@ func validDimensions(r *RecommendationIdentity) bool { } func validPayment(payment string) bool { - switch payment { - case "no_upfront", "partial_upfront", "all_upfront": + switch insurance.PaymentOption(payment) { + case insurance.PaymentNoUpfront, insurance.PaymentPartialUpfront, insurance.PaymentAllUpfront: return true default: return false @@ -132,13 +173,13 @@ type resourceIdentity struct { func sameResource(r *RecommendationIdentity, item *LineItemIdentity) bool { want := resourceIdentity{r.Provider, r.SourceAccountID, r.OfferingID, r.PlanType, r.Region, r.InstanceFamily, r.Payment} got := resourceIdentity{item.Provider, item.SourceAccountID, item.ProviderOfferingID, item.PlanType, item.Region, item.InstanceFamily, item.Payment} - return want == got && item.Type == "savings_plan" && item.DurationSeconds != nil && + return want == got && item.Type == commitmentSavingsPlan && item.DurationSeconds != nil && item.DurationSeconds.Cmp(new(big.Rat).SetInt64(r.NativeDurationSeconds)) == 0 && sameAmount(r.HourlyCommitment, item.SelectedCommitment) } func sameCurrentBasis(r *RecommendationIdentity, row *ComparisonIdentity) bool { - return row != nil && row.IsCurrent && row.Region != nil && *row.Region == r.Region && row.Provider == r.Provider && row.CurrentType == "savings_plan" && + return row != nil && row.IsCurrent && row.Region != nil && *row.Region == r.Region && row.Provider == r.Provider && row.CurrentType == commitmentSavingsPlan && row.CurrentPayment == r.Payment && sameAmount(r.HourlyCommitment, row.CurrentSelectedAmount) } diff --git a/internal/archera/recommendation_match_test.go b/internal/archera/recommendation_match_test.go index 6da769d74..48a01c217 100644 --- a/internal/archera/recommendation_match_test.go +++ b/internal/archera/recommendation_match_test.go @@ -47,8 +47,8 @@ func TestMatchRecommendationAssociatesOnlyProvenResourceIdentity(t *testing.T) { require.NoError(t, err) require.Equal(t, ResourceAssociated, result.Status) require.Equal(t, matchItem, result.LineItemID) - require.Equal(t, "stored_decimal_only", result.AmountProvenance) - require.Equal(t, "unavailable_source_precision", result.EconomicStatus) + require.Equal(t, ReasonStoredDecimalOnly, result.AmountProvenance) + require.Equal(t, ReasonUnavailableSourcePrecision, result.EconomicStatus) require.Equal(t, "1/2", rec.HourlyCommitment.RatString()) require.Equal(t, "1/2", items[0].SelectedCommitment.RatString()) after, marshalErr := json.Marshal([]any{rec, rows, items}) @@ -201,3 +201,80 @@ func TestMatchRecommendationRejectsEqualMalformedUUIDReferences(t *testing.T) { } } } + +func TestMatchRecommendationUnavailableForNilOrZeroAmounts(t *testing.T) { + zero := func() *big.Rat { return new(big.Rat) } + tests := []struct { + name string + change func(*RecommendationIdentity, *ComparisonIdentity, *LineItemIdentity) + reason Reason + }{ + {"recommendation_nil", func(r *RecommendationIdentity, _ *ComparisonIdentity, _ *LineItemIdentity) { r.HourlyCommitment = nil }, ReasonIdentityUnproven}, + {"recommendation_zero", func(r *RecommendationIdentity, _ *ComparisonIdentity, _ *LineItemIdentity) { + r.HourlyCommitment = zero() + }, ReasonIdentityUnproven}, + {"item_nil", func(_ *RecommendationIdentity, _ *ComparisonIdentity, i *LineItemIdentity) { + i.SelectedCommitment = nil + }, ReasonIdentityUnproven}, + {"item_zero", func(_ *RecommendationIdentity, _ *ComparisonIdentity, i *LineItemIdentity) { + i.SelectedCommitment = zero() + }, ReasonIdentityUnproven}, + {"current_nil", func(_ *RecommendationIdentity, c *ComparisonIdentity, _ *LineItemIdentity) { + c.CurrentSelectedAmount = nil + }, ReasonComparisonBasisUnproven}, + {"current_zero", func(_ *RecommendationIdentity, c *ComparisonIdentity, _ *LineItemIdentity) { + c.CurrentSelectedAmount = zero() + }, ReasonComparisonBasisUnproven}, + {"all_zero", func(r *RecommendationIdentity, c *ComparisonIdentity, i *LineItemIdentity) { + r.HourlyCommitment, c.CurrentSelectedAmount, i.SelectedCommitment = zero(), zero(), zero() + }, ReasonIdentityUnproven}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, rows, items := matchFixture() + tt.change(r, &rows[0], &items[0]) + got, err := MatchRecommendation(matchPlan, r, rows, items) + require.NoError(t, err) + require.Equal(t, MatchUnavailable, got.Status) + require.Equal(t, tt.reason, got.Reason) + require.Empty(t, got.LineItemID) + require.Empty(t, got.Candidates) + }) + } +} + +func TestSelectLineItem(t *testing.T) { + r, _, items := matchFixture() + id, status := SelectLineItem(r, items) + require.Equal(t, SelectOne, status) + require.Equal(t, matchItem, id) + + other := items[0] + other.ID = "eaadf00d-ffff-4aba-8ada-abcdefabcdef" + other.Region = "eu-west-1" + id, status = SelectLineItem(r, []LineItemIdentity{other, items[0]}) + require.Equal(t, SelectOne, status) + require.Equal(t, matchItem, id) + + id, status = SelectLineItem(r, []LineItemIdentity{other}) + require.Equal(t, SelectNone, status) + require.Empty(t, id) + id, status = SelectLineItem(r, nil) + require.Equal(t, SelectNone, status) + require.Empty(t, id) + + twin := items[0] + twin.ID = other.ID + id, status = SelectLineItem(r, []LineItemIdentity{items[0], twin}) + require.Equal(t, SelectAmbiguous, status) + require.Empty(t, id) + + bad := *r + bad.HourlyCommitment = nil + id, status = SelectLineItem(&bad, items) + require.Equal(t, SelectNone, status) + require.Empty(t, id) + id, status = SelectLineItem(nil, items) + require.Equal(t, SelectNone, status) + require.Empty(t, id) +}