diff --git a/internal/archera/recommendation_alternatives.go b/internal/archera/recommendation_alternatives.go new file mode 100644 index 000000000..0a4cfee6c --- /dev/null +++ b/internal/archera/recommendation_alternatives.go @@ -0,0 +1,20 @@ +package archera + +import "math/big" + +type CandidateIdentity struct { + OfferID, Provider, Type, Payment string + SelectedAmount *big.Rat +} + +type CandidateAssociation struct { + OfferID, Status, Reason, NativeIdentityStatus string +} + +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", "" + } + return out +} diff --git a/internal/archera/recommendation_alternatives_test.go b/internal/archera/recommendation_alternatives_test.go new file mode 100644 index 000000000..4f8a41554 --- /dev/null +++ b/internal/archera/recommendation_alternatives_test.go @@ -0,0 +1,82 @@ +package archera + +import ( + "encoding/json" + "math/big" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func candidateFixture() CandidateIdentity { + return CandidateIdentity{OfferID: "faadf00d-ffff-4aba-8ada-abcdefabcdef", Provider: "aws", + Type: "savings_plan", Payment: "all_upfront", SelectedAmount: big.NewRat(1, 2)} +} + +func TestMatchRecommendationPreservesVendorAlternativesWithoutNativeClaims(t *testing.T) { + r, rows, items := matchFixture() + candidate := candidateFixture() + rows[0].Candidates = []CandidateIdentity{candidate, candidate, candidate} + rows[0].Candidates[1].Payment = "partial_upfront" + rows[0].Candidates[2].OfferID = strings.ToUpper(candidate.OfferID) + rows[0].Candidates[2].Payment = "no_upfront" + before, err := json.Marshal([]any{r, rows, items}) + require.NoError(t, err) + got, err := MatchRecommendation(matchPlan, r, rows, items) + require.NoError(t, err) + require.Equal(t, ResourceAssociated, got.Status) + 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.Empty(t, association.Reason) + require.Equal(t, "unavailable", association.NativeIdentityStatus) + } + require.Equal(t, "unavailable_source_precision", got.EconomicStatus) + after, err := json.Marshal([]any{r, rows, items}) + require.NoError(t, err) + require.Equal(t, before, after) +} + +func TestMatchRecommendationDoesNotBorrowCandidateBasis(t *testing.T) { + tests := []struct { + name string + change func(*CandidateIdentity) + }{ + {"id", func(c *CandidateIdentity) { c.OfferID = "invalid" }}, + {"invalid36", func(c *CandidateIdentity) { c.OfferID = "zzzzzzzz-zzzz-4zzz-8zzz-zzzzzzzzzzzz" }}, + {"type", func(c *CandidateIdentity) { c.Type = "ri" }}, + {"amount", func(c *CandidateIdentity) { c.SelectedAmount = big.NewRat(2, 1) }}, + {"missing_amount", func(c *CandidateIdentity) { c.SelectedAmount = nil }}, + {"zero_amount", func(c *CandidateIdentity) { c.SelectedAmount = new(big.Rat) }}, + {"negative_amount", func(c *CandidateIdentity) { c.SelectedAmount = big.NewRat(-1, 2) }}, + {"provider", func(c *CandidateIdentity) { c.Provider = "azure" }}, + {"payment", func(c *CandidateIdentity) { c.Payment = "" }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r, rows, items := matchFixture() + c := candidateFixture() + tt.change(&c) + rows[0].Candidates = []CandidateIdentity{c} + got, err := MatchRecommendation(matchPlan, r, rows, items) + 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.NotEmpty(t, got.Candidates[0].Reason) + require.Equal(t, "unavailable", got.Candidates[0].NativeIdentityStatus) + }) + } +} + +func TestMatchRecommendationOmitsCandidatesWithoutCurrentAssociation(t *testing.T) { + r, rows, items := matchFixture() + rows[0].Candidates = []CandidateIdentity{candidateFixture()} + items[0].SourceAccountID = "another-source" + got, err := MatchRecommendation(matchPlan, r, rows, items) + require.NoError(t, err) + require.Equal(t, MatchUnavailable, got.Status) + require.Empty(t, got.Candidates) +} diff --git a/internal/archera/recommendation_match.go b/internal/archera/recommendation_match.go index d94b554e0..882e719cb 100644 --- a/internal/archera/recommendation_match.go +++ b/internal/archera/recommendation_match.go @@ -32,6 +32,7 @@ type ComparisonIdentity struct { IsCurrent bool LineItemID, CurrentOfferID, Provider, CurrentType, CurrentPayment string CurrentSelectedAmount *big.Rat + Candidates []CandidateIdentity } type MatchStatus string @@ -44,6 +45,7 @@ const ( type RecommendationMatch struct { Status MatchStatus LineItemID, Reason, AmountProvenance, EconomicStatus string + Candidates []CandidateAssociation } func MatchRecommendation(planID string, rec *RecommendationIdentity, rows []ComparisonIdentity, items []LineItemIdentity) (RecommendationMatch, error) { @@ -81,6 +83,9 @@ func MatchRecommendation(planID string, rec *RecommendationIdentity, rows []Comp Status: ResourceAssociated, LineItemID: row.LineItemID, AmountProvenance: "stored_decimal_only", EconomicStatus: "unavailable_source_precision", } + for i := range row.Candidates { + result.Candidates = append(result.Candidates, associateCandidate(rec, &row.Candidates[i])) + } return result, nil } @@ -94,7 +99,7 @@ func validRecommendation(r *RecommendationIdentity) bool { if r.NativeDurationSeconds != awsSPYearSeconds && r.NativeDurationSeconds != awsSPThreeYearSeconds { return false } - return validDimensions(r) && validPayment(r.Payment) && sameAmount(r.HourlyCommitment, r.HourlyCommitment) + return validDimensions(r) && validPayment(r.Payment) } func validDimensions(r *RecommendationIdentity) bool { @@ -183,7 +188,7 @@ func validMatchUUID(id string) bool { } func validLineReference(planID string, item *LineItemIdentity) bool { - return validMatchUUID(item.ID) && validMatchUUID(item.OfferID) && validMatchUUID(item.PlanID) && strings.EqualFold(planID, item.PlanID) + return validMatchUUID(item.ID) && validMatchUUID(item.PlanID) && strings.EqualFold(planID, item.PlanID) } func validComparisonReference(row *ComparisonIdentity, items map[string]*LineItemIdentity) bool { diff --git a/internal/archera/recommendation_match_test.go b/internal/archera/recommendation_match_test.go index 310a295e0..6da769d74 100644 --- a/internal/archera/recommendation_match_test.go +++ b/internal/archera/recommendation_match_test.go @@ -135,6 +135,19 @@ func TestMatchRecommendationRefusesUnprovenAssociation(t *testing.T) { c.Region = &i.InstanceFamily }}, {"missing_current_region", func(_ *RecommendationIdentity, c *ComparisonIdentity, _ *LineItemIdentity) { c.Region = nil }}, + {"line_item_region", func(_ *RecommendationIdentity, _ *ComparisonIdentity, i *LineItemIdentity) { i.Region = "eu-west-1" }}, + {"current_payment", func(_ *RecommendationIdentity, c *ComparisonIdentity, _ *LineItemIdentity) { + c.CurrentPayment = "all_upfront" + }}, + {"other_plan", func(_ *RecommendationIdentity, _ *ComparisonIdentity, i *LineItemIdentity) { + i.PlanID = "faadf00d-ffff-4aba-8ada-abcdefabcdef" + }}, + {"non_aws_recommendation", func(r *RecommendationIdentity, c *ComparisonIdentity, i *LineItemIdentity) { + r.Provider, c.Provider, i.Provider = "gcp", "gcp", "gcp" + }}, + {"invalid_payment", func(r *RecommendationIdentity, c *ComparisonIdentity, i *LineItemIdentity) { + r.Payment, c.CurrentPayment, i.Payment = "monthly", "monthly", "monthly" + }}, {"current_offer", func(_ *RecommendationIdentity, c *ComparisonIdentity, _ *LineItemIdentity) { c.CurrentOfferID = "faadf00d-ffff-4aba-8ada-abcdefabcdef" }},