diff --git a/.gitallowed b/.gitallowed index 1ea968622..5b99a6993 100644 --- a/.gitallowed +++ b/.gitallowed @@ -78,14 +78,14 @@ ^cmd/multi_service(_helpers)?\.go:[0-9]+:[[:space:]]*AppLogger\.Printf\("(\\n)?(━)+\\n"\)$ ^cmd/configure_gcp_test\.go:[0-9]+:// --- addMemberToPolicyBinding -+$ ^cmd/configure_test\.go:[0-9]+:[[:space:]]*assert\.Equal\(t, "-----BEGIN PRIVAT[E] KEY-----\\n\.\.\.", creds\.PrivateKey\)$ -^cmd/configure_test\.go:[0-9]+:[[:space:]]*"private_key": "-----BEGIN PRIVAT[E] KEY-----\\nMIIEvQIBADANBg\.\.\.\\n-----END PRIVATE KEY-----\\n",$ +^cmd/configure_gcp_store_test\.go:[0-9]+:[[:space:]]*"private_key": "-----BEGIN PRIVAT[E] KEY-----\\nMIIEvQIBADANBg\.\.\.\\n-----END PRIVATE KEY-----\\n",$ ^cmd/configure_gcp_test\.go:[0-9]+:[[:space:]]*keyMaterial := \[\]byte\(`\{"type":"service_account","project_id":"proj","client_email":"sa@proj\.iam\.gserviceaccount\.com","private_key":"-----BEGIN PRIVAT[E] KEY-----\\nx\\n-----END PRIVATE KEY-----\\n"\}`\)$ # Synthetic 12-digit account-ID fixtures (sequential or repeated-digit # blocks, none a real account) that the AWS account-ID detector flags in Go # test tables. Each entry pins the path, the placeholder digits and the # whole line, so anything else on the line is still scanned. -^cmd/(helpers_test|multi_service_filters_test|multi_service_test)\.go:[0-9]+:[[:space:]]*(accountID|Account):[[:space:]]*"(123456789012|999888777666|111222333444)",$ -^cmd/multi_service_helpers_test\.go:[0-9]+:[[:space:]]*\{Account: "(123456789012|210987654321|111222333444|555666777888)", AccountName: ""\},( // Same account - should use cache)?$ +^cmd/(multi_service_filters_test|multi_service_csv_report_test|helpers_account_alias_test|multi_service_should_include_test)\.go:[0-9]+:[[:space:]]*(accountID|Account):[[:space:]]*"(123456789012|999888777666|111222333444)",$ +^cmd/multi_service_account_names_test\.go:[0-9]+:[[:space:]]*\{Account: "(123456789012|210987654321|111222333444|555666777888)", AccountName: ""\},( // Same account - should use cache)?$ ^cmd/savings_plan_purchase_test\.go:[0-9]+:[[:space:]]*(Service: common\.ServiceSavingsPlansCompute, )?(Count: 1, )?Account: "11111111111[1]", Term: "1yr", PaymentOption: "no-upfront",$ ^cmd/savings_plan_purchase_test\.go:[0-9]+:[[:space:]]*"account": func\(r \*common\.Recommendation\) \{ r\.Account = "22222222222[2]" \},$ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 87dd11eab..fcf88f27e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,7 +48,7 @@ repos: - id: detect-private-key name: Detect private keys # Only these test fixtures embed PEM-shaped dummy keys; any other file is scanned. - exclude: '^(cmd/configure_test\.go|cmd/configure_gcp_test\.go)$' + exclude: '^(cmd/configure_test\.go|cmd/configure_gcp_test\.go|cmd/configure_gcp_store_test\.go)$' - id: check-case-conflict name: Check for case conflicts diff --git a/cmd/archera_comparison_masking_test.go b/cmd/archera_comparison_masking_test.go new file mode 100644 index 000000000..59bf63e9c --- /dev/null +++ b/cmd/archera_comparison_masking_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "log/slog" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestArcheraErrorsMaskObfuscatedKey(t *testing.T) { + k := archeraTestKey + split := func(sep string) string { return k[:5] + sep + k[5:] } + pad := strings.Repeat("p", archeraMaxErrorSize-len("archera API returned HTTP 403: ")-len(k)/2) + cases := map[string]string{ + "zero-width split": "bad key " + split("\u200b"), + "control split": "bad key " + split("\u0001"), + "bidi split": "bad key " + split("\u202e"), + "echoed twice": k + " and " + split("\u200b"), + "straddles the cap": pad + k, + "bidi isolate+RLO": "x\u202e\u2066y\u200b" + k, + } + for name, msg := range cases { + t.Run("http "+name, func(t *testing.T) { + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(403) + body, _ := json.Marshal(map[string]string{"message": msg}) + _, _ = w.Write(body) + }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut+err.Error(), k) + assertArcheraNoControlChars(t, err.Error()) + assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize) + assert.Contains(t, err.Error(), "retry after 30s") + assert.NotContains(t, err.Error(), "\u202e") + }) + } + t.Run("key as enum value in a decode error", func(t *testing.T) { + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":"`+split("\u200b")+`"`, 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut+err.Error(), k) + // pkg/insurance no longer echoes the rejected value at all. + assert.Contains(t, err.Error(), "not shown") + }) + t.Run("plain key as enum value", func(t *testing.T) { + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":"`+k+`"`, 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + _, _, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, err.Error(), k) + }) +} + +// archeraKeyHidingSeparators split the key with characters that survive the +// strip, so an exact-match mask misses a key the terminal still displays. +var archeraKeyHidingSeparators = map[string]string{ + "variation selector 16": "\ufe0f", + "variation selector 17": "\U000e0100", + "combining grapheme join": "\u034f", + "combining acute": "\u0301", + "enclosing circle": "\u20dd", + "hangul choseong filler": "\u115f", + "hangul jungseong filler": "\u1160", + "hangul filler": "\u3164", + "halfwidth hangul filler": "\uffa0", + "space": " ", + "invalid utf-8": "\xff", +} + +func archeraSplitEverywhere(k, sep string) string { + return strings.Join(strings.Split(k, ""), sep) +} + +type archeraFailingTransport struct{ err error } + +func (f archeraFailingTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, f.err } + +func TestArcheraErrorsWithholdKeyHiddenBehindSurvivingCharacters(t *testing.T) { + k := archeraTestKey + checkMsg := func(t *testing.T, got string) { + t.Helper() + assert.NotContains(t, got, k) + assert.Contains(t, got, archeraWithheld) + assertArcheraNoControlChars(t, got) + assert.LessOrEqual(t, len(got), archeraMaxErrorSize) + } + for name, sep := range archeraKeyHidingSeparators { + for shape, echo := range map[string]string{ + "split everywhere": archeraSplitEverywhere(k, sep), + "split once": k[:5] + sep + k[5:], + } { + msg := "bad key " + echo + " was rejected" + t.Run("http "+name+" "+shape, func(t *testing.T) { + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "30") + w.WriteHeader(403) + body, _ := json.Marshal(map[string]string{"message": msg}) + _, _ = w.Write(body) + }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut, k) + checkMsg(t, err.Error()) + assert.Contains(t, err.Error(), "HTTP 403") + assert.Contains(t, err.Error(), "retry after 30s") + }) + t.Run("decode "+name+" "+shape, func(t *testing.T) { + body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":`+mustJSON(t, echo), 1) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut, k) + // pkg/insurance no longer echoes the rejected value, so there is + // nothing to withhold; the key must still not appear. + assert.NotContains(t, err.Error(), k) + assertArcheraNoControlChars(t, err.Error()) + assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize) + }) + for kind, wrapped := range map[string]error{ + "transport": errors.New(msg), + "deadline": fmt.Errorf("%s: %w", msg, context.DeadlineExceeded), + "canceled": fmt.Errorf("%s: %w", msg, context.Canceled), + } { + t.Run(kind+" "+name+" "+shape, func(t *testing.T) { + setupArchera(t, okHandler) + archeraHTTPClient = &http.Client{Transport: archeraFailingTransport{err: wrapped}} + out, errOut, err := runArchera(t, fullEnv()) + require.Error(t, err) + assert.NotContains(t, out+errOut, k) + checkMsg(t, err.Error()) + }) + } + } + } +} + +func mustJSON(t *testing.T, v string) string { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return string(b) +} + +// The mask must run before the cap: a key cut by the cap boundary would +// otherwise survive as a readable prefix. Sweep the boundary and assert no +// 4+ byte prefix of the key remains, for a plain and a strip-split key. +func TestArcheraErrorsLeaveNoKeyPrefixAtCap(t *testing.T) { + k := archeraTestKey + for _, echo := range []string{k, k[:5] + "\u200b" + k[5:], archeraSplitEverywhere(k, "\u200b")} { + for padLen := archeraMaxErrorSize - len(k) - 8; padLen <= archeraMaxErrorSize+2; padLen++ { + err := archeraSafeError(errors.New(strings.Repeat("p", padLen)+echo), k) + got := err.Error() + for n := 4; n <= len(k); n++ { + for start := 0; start+n <= len(k); start++ { + require.NotContains(t, got, k[start:start+n], "pad %d, fragment %q", padLen, k[start:start+n]) + } + } + } + } +} + +// The withhold check must see the full text: a cut through a split key would +// hide it from a check run after the cap while leaving a readable prefix. +func TestArcheraErrorsWithholdSplitKeyAtAnyCapOffset(t *testing.T) { + k := archeraTestKey + for name, sep := range map[string]string{"variation selector": "\ufe0f", "invalid utf-8": "\xff", "combining acute": "\u0301"} { + for padLen := archeraMaxErrorSize - 3*len(k); padLen <= archeraMaxErrorSize+2; padLen++ { + got := archeraSafeError(errors.New(strings.Repeat("p", padLen)+archeraSplitEverywhere(k, sep)), k).Error() + require.Equal(t, archeraWithheld, got, "%s pad %d", name, padLen) + } + } +} + +func TestArcheraErrorsTrimKeyBeforeMasking(t *testing.T) { + got := archeraSafeError(errors.New("bad key "+archeraTestKey[:5]+"\u200b"+archeraTestKey[5:]), " "+archeraTestKey+"\n").Error() + assert.Equal(t, "bad key [redacted]", got) +} + +// A log or slog write of the key would bypass os.Stderr captured per test, so +// route both default loggers into the capture for the whole run. +func TestArcheraRunDoesNotLogKey(t *testing.T) { + var logs bytes.Buffer + savedLog, savedFlags := log.Writer(), log.Flags() + log.SetOutput(&logs) + savedSlog := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { log.SetOutput(savedLog); log.SetFlags(savedFlags); slog.SetDefault(savedSlog) }) + setupArchera(t, okHandler) + _, _, err := runArchera(t, fullEnv()) + require.NoError(t, err) + setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(403) }) + _, _, _ = runArchera(t, fullEnv()) + assert.NotContains(t, logs.String(), archeraTestKey) +} diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go index 58d6c8e70..628b8dcc2 100644 --- a/cmd/archera_comparison_test.go +++ b/cmd/archera_comparison_test.go @@ -4,11 +4,8 @@ import ( "bytes" "context" "encoding/json" - "errors" "fmt" "io" - "log" - "log/slog" "math/big" "net" "net/http" @@ -485,192 +482,3 @@ func TestArcheraDecodeErrorsAreBoundedAndClean(t *testing.T) { }) } } - -func TestArcheraErrorsMaskObfuscatedKey(t *testing.T) { - k := archeraTestKey - split := func(sep string) string { return k[:5] + sep + k[5:] } - pad := strings.Repeat("p", archeraMaxErrorSize-len("archera API returned HTTP 403: ")-len(k)/2) - cases := map[string]string{ - "zero-width split": "bad key " + split("\u200b"), - "control split": "bad key " + split("\u0001"), - "bidi split": "bad key " + split("\u202e"), - "echoed twice": k + " and " + split("\u200b"), - "straddles the cap": pad + k, - "bidi isolate+RLO": "x\u202e\u2066y\u200b" + k, - } - for name, msg := range cases { - t.Run("http "+name, func(t *testing.T) { - setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Retry-After", "30") - w.WriteHeader(403) - body, _ := json.Marshal(map[string]string{"message": msg}) - _, _ = w.Write(body) - }) - out, errOut, err := runArchera(t, fullEnv()) - require.Error(t, err) - assert.NotContains(t, out+errOut+err.Error(), k) - assertArcheraNoControlChars(t, err.Error()) - assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize) - assert.Contains(t, err.Error(), "retry after 30s") - assert.NotContains(t, err.Error(), "\u202e") - }) - } - t.Run("key as enum value in a decode error", func(t *testing.T) { - body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":"`+split("\u200b")+`"`, 1) - setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) - out, errOut, err := runArchera(t, fullEnv()) - require.Error(t, err) - assert.NotContains(t, out+errOut+err.Error(), k) - // pkg/insurance no longer echoes the rejected value at all. - assert.Contains(t, err.Error(), "not shown") - }) - t.Run("plain key as enum value", func(t *testing.T) { - body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":"`+k+`"`, 1) - setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) - _, _, err := runArchera(t, fullEnv()) - require.Error(t, err) - assert.NotContains(t, err.Error(), k) - }) -} - -// archeraKeyHidingSeparators split the key with characters that survive the -// strip, so an exact-match mask misses a key the terminal still displays. -var archeraKeyHidingSeparators = map[string]string{ - "variation selector 16": "\ufe0f", - "variation selector 17": "\U000e0100", - "combining grapheme join": "\u034f", - "combining acute": "\u0301", - "enclosing circle": "\u20dd", - "hangul choseong filler": "\u115f", - "hangul jungseong filler": "\u1160", - "hangul filler": "\u3164", - "halfwidth hangul filler": "\uffa0", - "space": " ", - "invalid utf-8": "\xff", -} - -func archeraSplitEverywhere(k, sep string) string { - return strings.Join(strings.Split(k, ""), sep) -} - -type archeraFailingTransport struct{ err error } - -func (f archeraFailingTransport) RoundTrip(*http.Request) (*http.Response, error) { return nil, f.err } - -func TestArcheraErrorsWithholdKeyHiddenBehindSurvivingCharacters(t *testing.T) { - k := archeraTestKey - checkMsg := func(t *testing.T, got string) { - t.Helper() - assert.NotContains(t, got, k) - assert.Contains(t, got, archeraWithheld) - assertArcheraNoControlChars(t, got) - assert.LessOrEqual(t, len(got), archeraMaxErrorSize) - } - for name, sep := range archeraKeyHidingSeparators { - for shape, echo := range map[string]string{ - "split everywhere": archeraSplitEverywhere(k, sep), - "split once": k[:5] + sep + k[5:], - } { - msg := "bad key " + echo + " was rejected" - t.Run("http "+name+" "+shape, func(t *testing.T) { - setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Retry-After", "30") - w.WriteHeader(403) - body, _ := json.Marshal(map[string]string{"message": msg}) - _, _ = w.Write(body) - }) - out, errOut, err := runArchera(t, fullEnv()) - require.Error(t, err) - assert.NotContains(t, out+errOut, k) - checkMsg(t, err.Error()) - assert.Contains(t, err.Error(), "HTTP 403") - assert.Contains(t, err.Error(), "retry after 30s") - }) - t.Run("decode "+name+" "+shape, func(t *testing.T) { - body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":`+mustJSON(t, echo), 1) - setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) - out, errOut, err := runArchera(t, fullEnv()) - require.Error(t, err) - assert.NotContains(t, out+errOut, k) - // pkg/insurance no longer echoes the rejected value, so there is - // nothing to withhold; the key must still not appear. - assert.NotContains(t, err.Error(), k) - assertArcheraNoControlChars(t, err.Error()) - assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize) - }) - for kind, wrapped := range map[string]error{ - "transport": errors.New(msg), - "deadline": fmt.Errorf("%s: %w", msg, context.DeadlineExceeded), - "canceled": fmt.Errorf("%s: %w", msg, context.Canceled), - } { - t.Run(kind+" "+name+" "+shape, func(t *testing.T) { - setupArchera(t, okHandler) - archeraHTTPClient = &http.Client{Transport: archeraFailingTransport{err: wrapped}} - out, errOut, err := runArchera(t, fullEnv()) - require.Error(t, err) - assert.NotContains(t, out+errOut, k) - checkMsg(t, err.Error()) - }) - } - } - } -} - -func mustJSON(t *testing.T, v string) string { - t.Helper() - b, err := json.Marshal(v) - require.NoError(t, err) - return string(b) -} - -// The mask must run before the cap: a key cut by the cap boundary would -// otherwise survive as a readable prefix. Sweep the boundary and assert no -// 4+ byte prefix of the key remains, for a plain and a strip-split key. -func TestArcheraErrorsLeaveNoKeyPrefixAtCap(t *testing.T) { - k := archeraTestKey - for _, echo := range []string{k, k[:5] + "\u200b" + k[5:], archeraSplitEverywhere(k, "\u200b")} { - for padLen := archeraMaxErrorSize - len(k) - 8; padLen <= archeraMaxErrorSize+2; padLen++ { - err := archeraSafeError(errors.New(strings.Repeat("p", padLen)+echo), k) - got := err.Error() - for n := 4; n <= len(k); n++ { - for start := 0; start+n <= len(k); start++ { - require.NotContains(t, got, k[start:start+n], "pad %d, fragment %q", padLen, k[start:start+n]) - } - } - } - } -} - -// The withhold check must see the full text: a cut through a split key would -// hide it from a check run after the cap while leaving a readable prefix. -func TestArcheraErrorsWithholdSplitKeyAtAnyCapOffset(t *testing.T) { - k := archeraTestKey - for name, sep := range map[string]string{"variation selector": "\ufe0f", "invalid utf-8": "\xff", "combining acute": "\u0301"} { - for padLen := archeraMaxErrorSize - 3*len(k); padLen <= archeraMaxErrorSize+2; padLen++ { - got := archeraSafeError(errors.New(strings.Repeat("p", padLen)+archeraSplitEverywhere(k, sep)), k).Error() - require.Equal(t, archeraWithheld, got, "%s pad %d", name, padLen) - } - } -} - -func TestArcheraErrorsTrimKeyBeforeMasking(t *testing.T) { - got := archeraSafeError(errors.New("bad key "+archeraTestKey[:5]+"\u200b"+archeraTestKey[5:]), " "+archeraTestKey+"\n").Error() - assert.Equal(t, "bad key [redacted]", got) -} - -// A log or slog write of the key would bypass os.Stderr captured per test, so -// route both default loggers into the capture for the whole run. -func TestArcheraRunDoesNotLogKey(t *testing.T) { - var logs bytes.Buffer - savedLog, savedFlags := log.Writer(), log.Flags() - log.SetOutput(&logs) - savedSlog := slog.Default() - slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) - t.Cleanup(func() { log.SetOutput(savedLog); log.SetFlags(savedFlags); slog.SetDefault(savedSlog) }) - setupArchera(t, okHandler) - _, _, err := runArchera(t, fullEnv()) - require.NoError(t, err) - setupArchera(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(403) }) - _, _, _ = runArchera(t, fullEnv()) - assert.NotContains(t, logs.String(), archeraTestKey) -} diff --git a/cmd/configure_azure_store_test.go b/cmd/configure_azure_store_test.go new file mode 100644 index 000000000..5546b7fa5 --- /dev/null +++ b/cmd/configure_azure_store_test.go @@ -0,0 +1,177 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Tests for storeAzureCredentials function. +func TestStoreAzureCredentials(t *testing.T) { + tests := []struct { + mockSetup func(*MockSecretsStore) + validateStore func(*testing.T, *MockSecretsStore) + creds AzureCredentials + name string + stackName string + wantErrMsg string + wantErr bool + }{ + { + name: "Successfully store valid credentials", + stackName: "my-stack", + creds: AzureCredentials{ + TenantID: "12345678-1234-1234-1234-123456789abc", + ClientID: "87654321-4321-4321-4321-fedcba987654", + ClientSecret: "my-secret", + SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", + }, + mockSetup: func(m *MockSecretsStore) { + m.listSecretsFunc = func(ctx context.Context, filter string) ([]string, error) { + return []string{"arn:aws:secretsmanager:us-east-1:123456789012:secret:my-stack-AzureCredentials-abc123"}, nil + } + }, + wantErr: false, + validateStore: func(t *testing.T, m *MockSecretsStore) { + assert.Equal(t, "my-stack-AzureCredentials", m.listSecretsFilter) + assert.Len(t, m.updatedSecrets, 1) + + secretID := "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-stack-AzureCredentials-abc123" + secretValue, ok := m.updatedSecrets[secretID] + assert.True(t, ok, "Secret should be stored") + + var storedCreds AzureCredentials + err := json.Unmarshal([]byte(secretValue), &storedCreds) + require.NoError(t, err) + assert.Equal(t, "12345678-1234-1234-1234-123456789abc", storedCreds.TenantID) + assert.Equal(t, "87654321-4321-4321-4321-fedcba987654", storedCreds.ClientID) + assert.Equal(t, "my-secret", storedCreds.ClientSecret) + assert.Equal(t, "abcdef12-3456-7890-abcd-ef1234567890", storedCreds.SubscriptionID) + }, + }, + { + name: "Store credentials when secret ARN not found", + stackName: "my-stack", + creds: AzureCredentials{ + TenantID: "12345678-1234-1234-1234-123456789abc", + ClientID: "87654321-4321-4321-4321-fedcba987654", + ClientSecret: "my-secret", + SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", + }, + mockSetup: func(m *MockSecretsStore) { + m.listSecretsFunc = func(ctx context.Context, filter string) ([]string, error) { + return []string{}, nil + } + }, + wantErr: false, + validateStore: func(t *testing.T, m *MockSecretsStore) { + assert.Equal(t, "my-stack-AzureCredentials", m.listSecretsFilter) + assert.Len(t, m.updatedSecrets, 1) + + secretValue, ok := m.updatedSecrets["my-stack-AzureCredentials"] + assert.True(t, ok, "Secret should be stored with name") + + var storedCreds AzureCredentials + err := json.Unmarshal([]byte(secretValue), &storedCreds) + require.NoError(t, err) + assert.Equal(t, "12345678-1234-1234-1234-123456789abc", storedCreds.TenantID) + }, + }, + { + // No mockSetup needed for error-path cases: credential validation runs + // before any store operation, so UpdateSecret is never called. + name: "Error when tenant ID is missing", + stackName: "my-stack", + creds: AzureCredentials{ + TenantID: "", + ClientID: "87654321-4321-4321-4321-fedcba987654", + ClientSecret: "my-secret", + SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", + }, + wantErr: true, + wantErrMsg: "all credentials are required", + }, + { + name: "Error when client ID is missing", + stackName: "my-stack", + creds: AzureCredentials{ + TenantID: "12345678-1234-1234-1234-123456789abc", + ClientID: "", + ClientSecret: "my-secret", + SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", + }, + wantErr: true, + wantErrMsg: "all credentials are required", + }, + { + name: "Error when client secret is missing", + stackName: "my-stack", + creds: AzureCredentials{ + TenantID: "12345678-1234-1234-1234-123456789abc", + ClientID: "87654321-4321-4321-4321-fedcba987654", + ClientSecret: "", + SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", + }, + wantErr: true, + wantErrMsg: "all credentials are required", + }, + { + name: "Error when subscription ID is missing", + stackName: "my-stack", + creds: AzureCredentials{ + TenantID: "12345678-1234-1234-1234-123456789abc", + ClientID: "87654321-4321-4321-4321-fedcba987654", + ClientSecret: "my-secret", + SubscriptionID: "", + }, + wantErr: true, + wantErrMsg: "all credentials are required", + }, + { + name: "Error when UpdateSecret fails", + stackName: "my-stack", + creds: AzureCredentials{ + TenantID: "12345678-1234-1234-1234-123456789abc", + ClientID: "87654321-4321-4321-4321-fedcba987654", + ClientSecret: "my-secret", + SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", + }, + mockSetup: func(m *MockSecretsStore) { + m.updateSecretFunc = func(ctx context.Context, secretID string, secretValue string) error { + return errors.New("failed to update secret") + } + }, + wantErr: true, + wantErrMsg: "failed to store credentials in Secrets Manager", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + mockStore := NewMockSecretsStore() + + if tt.mockSetup != nil { + tt.mockSetup(mockStore) + } + + err := storeAzureCredentials(ctx, mockStore, tt.stackName, tt.creds) + + if tt.wantErr { + assert.Error(t, err) + if tt.wantErrMsg != "" { + assert.Contains(t, err.Error(), tt.wantErrMsg) + } + } else { + assert.NoError(t, err) + if tt.validateStore != nil { + tt.validateStore(t, mockStore) + } + } + }) + } +} diff --git a/cmd/configure_gcp_step_test.go b/cmd/configure_gcp_step_test.go new file mode 100644 index 000000000..cac04cdd1 --- /dev/null +++ b/cmd/configure_gcp_step_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "bufio" + "context" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGCPStepCreateServiceAccount_SkipUsesOperatorEmail(t *testing.T) { + const existing = "existing-sa@my-proj.iam.gserviceaccount.com" + reader := bufio.NewReader(strings.NewReader("s\n " + existing + " \n")) + + email, err := gcpStepCreateServiceAccount(context.Background(), reader, "my-proj") + + require.NoError(t, err) + assert.Equal(t, existing, email) + assert.NotEqual(t, "cudly-service-account@my-proj.iam.gserviceaccount.com", email) +} + +func TestGCPStepCreateServiceAccount_UnknownOptionPromptsForEmail(t *testing.T) { + const existing = "other@my-proj.iam.gserviceaccount.com" + reader := bufio.NewReader(strings.NewReader("x\n" + existing + "\n")) + + email, err := gcpStepCreateServiceAccount(context.Background(), reader, "my-proj") + + require.NoError(t, err) + assert.Equal(t, existing, email) +} + +func TestGCPStepCreateServiceAccount_SkipRejectsInvalidEmail(t *testing.T) { + for _, in := range []string{"s\n\n", "s\nnot-an-email\n", "s\nuser@example.com\n", "s\n@x.gserviceaccount.com\n", "s\na@b@c.gserviceaccount.com\n", "s\na@.gserviceaccount.com\n", "s\n ", "s\na\x00@x.gserviceaccount.com\n", "s\n"} { + email, err := gcpStepCreateServiceAccount(context.Background(), bufio.NewReader(strings.NewReader(in)), "my-proj") + require.Error(t, err, "input %q", in) + assert.Empty(t, email) + } +} + +func TestGCPStepCreateServiceAccount_SkipAcceptsUppercaseEmail(t *testing.T) { + const existing = "Existing-SA@My-Proj.IAM.GServiceAccount.com" + email, err := gcpStepCreateServiceAccount(context.Background(), bufio.NewReader(strings.NewReader("s\n"+existing+"\n")), "my-proj") + require.NoError(t, err) + assert.Equal(t, strings.ToLower(existing), email) +} diff --git a/cmd/configure_gcp_store_test.go b/cmd/configure_gcp_store_test.go new file mode 100644 index 000000000..3449229a3 --- /dev/null +++ b/cmd/configure_gcp_store_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Tests for storeGCPCredentials function. +func TestStoreGCPCredentials(t *testing.T) { + // private_key validation is presence-only; the key content is not parsed or + // validated as a real PEM block by storeGCPCredentials. + validGCPJSON := `{ + "type": "service_account", + "project_id": "my-project", + "private_key_id": "key123", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----\n", + "client_email": "cudly@my-project.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + }` + + tests := []struct { + mockSetup func(*MockSecretsStore) + validateStore func(*testing.T, *MockSecretsStore) + name string + stackName string + credsJSON string + wantErrMsg string + wantErr bool + }{ + { + name: "Successfully store valid GCP credentials", + stackName: "my-stack", + credsJSON: validGCPJSON, + mockSetup: func(m *MockSecretsStore) { + m.listSecretsFunc = func(ctx context.Context, filter string) ([]string, error) { + return []string{"arn:aws:secretsmanager:us-east-1:123456789012:secret:my-stack-GCPCredentials-xyz789"}, nil + } + }, + wantErr: false, + validateStore: func(t *testing.T, m *MockSecretsStore) { + assert.Equal(t, "my-stack-GCPCredentials", m.listSecretsFilter) + assert.Len(t, m.updatedSecrets, 1) + + secretID := "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-stack-GCPCredentials-xyz789" + secretValue, ok := m.updatedSecrets[secretID] + assert.True(t, ok, "Secret should be stored") + + var storedCreds GCPCredentials + err := json.Unmarshal([]byte(secretValue), &storedCreds) + require.NoError(t, err) + assert.Equal(t, "service_account", storedCreds.Type) + assert.Equal(t, "my-project", storedCreds.ProjectID) + assert.Equal(t, "cudly@my-project.iam.gserviceaccount.com", storedCreds.ClientEmail) + }, + }, + { + name: "Store credentials when secret ARN not found", + stackName: "my-stack", + credsJSON: validGCPJSON, + mockSetup: func(m *MockSecretsStore) { + m.listSecretsFunc = func(ctx context.Context, filter string) ([]string, error) { + return []string{}, nil + } + }, + wantErr: false, + validateStore: func(t *testing.T, m *MockSecretsStore) { + assert.Equal(t, "my-stack-GCPCredentials", m.listSecretsFilter) + secretValue, ok := m.updatedSecrets["my-stack-GCPCredentials"] + assert.True(t, ok, "Secret should be stored with name") + + var storedCreds GCPCredentials + err := json.Unmarshal([]byte(secretValue), &storedCreds) + require.NoError(t, err) + assert.Equal(t, "my-project", storedCreds.ProjectID) + }, + }, + { + name: "Error when JSON is invalid", + stackName: "my-stack", + credsJSON: `{invalid json`, + wantErr: true, + wantErrMsg: "failed to parse credentials", + }, + { + name: "Error when type is not service_account", + stackName: "my-stack", + credsJSON: `{ + "type": "user_account", + "project_id": "my-project", + "private_key": "key", + "client_email": "test@example.com" + }`, + wantErr: true, + wantErrMsg: "expected type 'service_account'", + }, + { + name: "Error when project_id is missing", + stackName: "my-stack", + credsJSON: `{ + "type": "service_account", + "private_key": "key", + "client_email": "test@example.com" + }`, + wantErr: true, + wantErrMsg: "missing project_id", + }, + { + name: "Error when client_email is missing", + stackName: "my-stack", + credsJSON: `{ + "type": "service_account", + "project_id": "my-project", + "private_key": "key" + }`, + wantErr: true, + wantErrMsg: "missing client_email", + }, + { + name: "Error when private_key is missing", + stackName: "my-stack", + credsJSON: `{ + "type": "service_account", + "project_id": "my-project", + "client_email": "test@example.com" + }`, + wantErr: true, + wantErrMsg: "missing private_key", + }, + { + name: "Error when UpdateSecret fails", + stackName: "my-stack", + credsJSON: validGCPJSON, + mockSetup: func(m *MockSecretsStore) { + m.updateSecretFunc = func(ctx context.Context, secretID string, secretValue string) error { + return errors.New("failed to update secret") + } + }, + wantErr: true, + wantErrMsg: "failed to store credentials in Secrets Manager", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + mockStore := NewMockSecretsStore() + + if tt.mockSetup != nil { + tt.mockSetup(mockStore) + } + + err := storeGCPCredentials(ctx, mockStore, tt.stackName, tt.credsJSON) + + if tt.wantErr { + assert.Error(t, err) + if tt.wantErrMsg != "" { + assert.Contains(t, err.Error(), tt.wantErrMsg) + } + } else { + assert.NoError(t, err) + if tt.validateStore != nil { + tt.validateStore(t, mockStore) + } + } + }) + } +} diff --git a/cmd/configure_gcp_test.go b/cmd/configure_gcp_test.go index d65bdbea1..a22b5dbdd 100644 --- a/cmd/configure_gcp_test.go +++ b/cmd/configure_gcp_test.go @@ -1,13 +1,11 @@ package main import ( - "bufio" "context" "encoding/base64" "errors" "os" "path/filepath" - "strings" "testing" "github.com/stretchr/testify/assert" @@ -465,39 +463,3 @@ func TestGcloudDeleteKeyCommand(t *testing.T) { } // --- gcpStepCreateServiceAccount --- - -func TestGCPStepCreateServiceAccount_SkipUsesOperatorEmail(t *testing.T) { - const existing = "existing-sa@my-proj.iam.gserviceaccount.com" - reader := bufio.NewReader(strings.NewReader("s\n " + existing + " \n")) - - email, err := gcpStepCreateServiceAccount(context.Background(), reader, "my-proj") - - require.NoError(t, err) - assert.Equal(t, existing, email) - assert.NotEqual(t, "cudly-service-account@my-proj.iam.gserviceaccount.com", email) -} - -func TestGCPStepCreateServiceAccount_UnknownOptionPromptsForEmail(t *testing.T) { - const existing = "other@my-proj.iam.gserviceaccount.com" - reader := bufio.NewReader(strings.NewReader("x\n" + existing + "\n")) - - email, err := gcpStepCreateServiceAccount(context.Background(), reader, "my-proj") - - require.NoError(t, err) - assert.Equal(t, existing, email) -} - -func TestGCPStepCreateServiceAccount_SkipRejectsInvalidEmail(t *testing.T) { - for _, in := range []string{"s\n\n", "s\nnot-an-email\n", "s\nuser@example.com\n", "s\n@x.gserviceaccount.com\n", "s\na@b@c.gserviceaccount.com\n", "s\na@.gserviceaccount.com\n", "s\n ", "s\na\x00@x.gserviceaccount.com\n", "s\n"} { - email, err := gcpStepCreateServiceAccount(context.Background(), bufio.NewReader(strings.NewReader(in)), "my-proj") - require.Error(t, err, "input %q", in) - assert.Empty(t, email) - } -} - -func TestGCPStepCreateServiceAccount_SkipAcceptsUppercaseEmail(t *testing.T) { - const existing = "Existing-SA@My-Proj.IAM.GServiceAccount.com" - email, err := gcpStepCreateServiceAccount(context.Background(), bufio.NewReader(strings.NewReader("s\n"+existing+"\n")), "my-proj") - require.NoError(t, err) - assert.Equal(t, strings.ToLower(existing), email) -} diff --git a/cmd/configure_test.go b/cmd/configure_test.go index 662b5a88a..f03969b07 100644 --- a/cmd/configure_test.go +++ b/cmd/configure_test.go @@ -2,12 +2,9 @@ package main import ( "context" - "encoding/json" - "errors" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) // MockSecretsStore is a mock implementation of SecretsStore for testing. @@ -382,332 +379,3 @@ func TestValidateGCPProjectID(t *testing.T) { }) } } - -// Tests for storeAzureCredentials function. -func TestStoreAzureCredentials(t *testing.T) { - tests := []struct { - mockSetup func(*MockSecretsStore) - validateStore func(*testing.T, *MockSecretsStore) - creds AzureCredentials - name string - stackName string - wantErrMsg string - wantErr bool - }{ - { - name: "Successfully store valid credentials", - stackName: "my-stack", - creds: AzureCredentials{ - TenantID: "12345678-1234-1234-1234-123456789abc", - ClientID: "87654321-4321-4321-4321-fedcba987654", - ClientSecret: "my-secret", - SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", - }, - mockSetup: func(m *MockSecretsStore) { - m.listSecretsFunc = func(ctx context.Context, filter string) ([]string, error) { - return []string{"arn:aws:secretsmanager:us-east-1:123456789012:secret:my-stack-AzureCredentials-abc123"}, nil - } - }, - wantErr: false, - validateStore: func(t *testing.T, m *MockSecretsStore) { - assert.Equal(t, "my-stack-AzureCredentials", m.listSecretsFilter) - assert.Len(t, m.updatedSecrets, 1) - - secretID := "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-stack-AzureCredentials-abc123" - secretValue, ok := m.updatedSecrets[secretID] - assert.True(t, ok, "Secret should be stored") - - var storedCreds AzureCredentials - err := json.Unmarshal([]byte(secretValue), &storedCreds) - require.NoError(t, err) - assert.Equal(t, "12345678-1234-1234-1234-123456789abc", storedCreds.TenantID) - assert.Equal(t, "87654321-4321-4321-4321-fedcba987654", storedCreds.ClientID) - assert.Equal(t, "my-secret", storedCreds.ClientSecret) - assert.Equal(t, "abcdef12-3456-7890-abcd-ef1234567890", storedCreds.SubscriptionID) - }, - }, - { - name: "Store credentials when secret ARN not found", - stackName: "my-stack", - creds: AzureCredentials{ - TenantID: "12345678-1234-1234-1234-123456789abc", - ClientID: "87654321-4321-4321-4321-fedcba987654", - ClientSecret: "my-secret", - SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", - }, - mockSetup: func(m *MockSecretsStore) { - m.listSecretsFunc = func(ctx context.Context, filter string) ([]string, error) { - return []string{}, nil - } - }, - wantErr: false, - validateStore: func(t *testing.T, m *MockSecretsStore) { - assert.Equal(t, "my-stack-AzureCredentials", m.listSecretsFilter) - assert.Len(t, m.updatedSecrets, 1) - - secretValue, ok := m.updatedSecrets["my-stack-AzureCredentials"] - assert.True(t, ok, "Secret should be stored with name") - - var storedCreds AzureCredentials - err := json.Unmarshal([]byte(secretValue), &storedCreds) - require.NoError(t, err) - assert.Equal(t, "12345678-1234-1234-1234-123456789abc", storedCreds.TenantID) - }, - }, - { - // No mockSetup needed for error-path cases: credential validation runs - // before any store operation, so UpdateSecret is never called. - name: "Error when tenant ID is missing", - stackName: "my-stack", - creds: AzureCredentials{ - TenantID: "", - ClientID: "87654321-4321-4321-4321-fedcba987654", - ClientSecret: "my-secret", - SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", - }, - wantErr: true, - wantErrMsg: "all credentials are required", - }, - { - name: "Error when client ID is missing", - stackName: "my-stack", - creds: AzureCredentials{ - TenantID: "12345678-1234-1234-1234-123456789abc", - ClientID: "", - ClientSecret: "my-secret", - SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", - }, - wantErr: true, - wantErrMsg: "all credentials are required", - }, - { - name: "Error when client secret is missing", - stackName: "my-stack", - creds: AzureCredentials{ - TenantID: "12345678-1234-1234-1234-123456789abc", - ClientID: "87654321-4321-4321-4321-fedcba987654", - ClientSecret: "", - SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", - }, - wantErr: true, - wantErrMsg: "all credentials are required", - }, - { - name: "Error when subscription ID is missing", - stackName: "my-stack", - creds: AzureCredentials{ - TenantID: "12345678-1234-1234-1234-123456789abc", - ClientID: "87654321-4321-4321-4321-fedcba987654", - ClientSecret: "my-secret", - SubscriptionID: "", - }, - wantErr: true, - wantErrMsg: "all credentials are required", - }, - { - name: "Error when UpdateSecret fails", - stackName: "my-stack", - creds: AzureCredentials{ - TenantID: "12345678-1234-1234-1234-123456789abc", - ClientID: "87654321-4321-4321-4321-fedcba987654", - ClientSecret: "my-secret", - SubscriptionID: "abcdef12-3456-7890-abcd-ef1234567890", - }, - mockSetup: func(m *MockSecretsStore) { - m.updateSecretFunc = func(ctx context.Context, secretID string, secretValue string) error { - return errors.New("failed to update secret") - } - }, - wantErr: true, - wantErrMsg: "failed to store credentials in Secrets Manager", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - mockStore := NewMockSecretsStore() - - if tt.mockSetup != nil { - tt.mockSetup(mockStore) - } - - err := storeAzureCredentials(ctx, mockStore, tt.stackName, tt.creds) - - if tt.wantErr { - assert.Error(t, err) - if tt.wantErrMsg != "" { - assert.Contains(t, err.Error(), tt.wantErrMsg) - } - } else { - assert.NoError(t, err) - if tt.validateStore != nil { - tt.validateStore(t, mockStore) - } - } - }) - } -} - -// Tests for storeGCPCredentials function. -func TestStoreGCPCredentials(t *testing.T) { - // private_key validation is presence-only; the key content is not parsed or - // validated as a real PEM block by storeGCPCredentials. - validGCPJSON := `{ - "type": "service_account", - "project_id": "my-project", - "private_key_id": "key123", - "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----\n", - "client_email": "cudly@my-project.iam.gserviceaccount.com", - "client_id": "123456789", - "auth_uri": "https://accounts.google.com/o/oauth2/auth", - "token_uri": "https://oauth2.googleapis.com/token" - }` - - tests := []struct { - mockSetup func(*MockSecretsStore) - validateStore func(*testing.T, *MockSecretsStore) - name string - stackName string - credsJSON string - wantErrMsg string - wantErr bool - }{ - { - name: "Successfully store valid GCP credentials", - stackName: "my-stack", - credsJSON: validGCPJSON, - mockSetup: func(m *MockSecretsStore) { - m.listSecretsFunc = func(ctx context.Context, filter string) ([]string, error) { - return []string{"arn:aws:secretsmanager:us-east-1:123456789012:secret:my-stack-GCPCredentials-xyz789"}, nil - } - }, - wantErr: false, - validateStore: func(t *testing.T, m *MockSecretsStore) { - assert.Equal(t, "my-stack-GCPCredentials", m.listSecretsFilter) - assert.Len(t, m.updatedSecrets, 1) - - secretID := "arn:aws:secretsmanager:us-east-1:123456789012:secret:my-stack-GCPCredentials-xyz789" - secretValue, ok := m.updatedSecrets[secretID] - assert.True(t, ok, "Secret should be stored") - - var storedCreds GCPCredentials - err := json.Unmarshal([]byte(secretValue), &storedCreds) - require.NoError(t, err) - assert.Equal(t, "service_account", storedCreds.Type) - assert.Equal(t, "my-project", storedCreds.ProjectID) - assert.Equal(t, "cudly@my-project.iam.gserviceaccount.com", storedCreds.ClientEmail) - }, - }, - { - name: "Store credentials when secret ARN not found", - stackName: "my-stack", - credsJSON: validGCPJSON, - mockSetup: func(m *MockSecretsStore) { - m.listSecretsFunc = func(ctx context.Context, filter string) ([]string, error) { - return []string{}, nil - } - }, - wantErr: false, - validateStore: func(t *testing.T, m *MockSecretsStore) { - assert.Equal(t, "my-stack-GCPCredentials", m.listSecretsFilter) - secretValue, ok := m.updatedSecrets["my-stack-GCPCredentials"] - assert.True(t, ok, "Secret should be stored with name") - - var storedCreds GCPCredentials - err := json.Unmarshal([]byte(secretValue), &storedCreds) - require.NoError(t, err) - assert.Equal(t, "my-project", storedCreds.ProjectID) - }, - }, - { - name: "Error when JSON is invalid", - stackName: "my-stack", - credsJSON: `{invalid json`, - wantErr: true, - wantErrMsg: "failed to parse credentials", - }, - { - name: "Error when type is not service_account", - stackName: "my-stack", - credsJSON: `{ - "type": "user_account", - "project_id": "my-project", - "private_key": "key", - "client_email": "test@example.com" - }`, - wantErr: true, - wantErrMsg: "expected type 'service_account'", - }, - { - name: "Error when project_id is missing", - stackName: "my-stack", - credsJSON: `{ - "type": "service_account", - "private_key": "key", - "client_email": "test@example.com" - }`, - wantErr: true, - wantErrMsg: "missing project_id", - }, - { - name: "Error when client_email is missing", - stackName: "my-stack", - credsJSON: `{ - "type": "service_account", - "project_id": "my-project", - "private_key": "key" - }`, - wantErr: true, - wantErrMsg: "missing client_email", - }, - { - name: "Error when private_key is missing", - stackName: "my-stack", - credsJSON: `{ - "type": "service_account", - "project_id": "my-project", - "client_email": "test@example.com" - }`, - wantErr: true, - wantErrMsg: "missing private_key", - }, - { - name: "Error when UpdateSecret fails", - stackName: "my-stack", - credsJSON: validGCPJSON, - mockSetup: func(m *MockSecretsStore) { - m.updateSecretFunc = func(ctx context.Context, secretID string, secretValue string) error { - return errors.New("failed to update secret") - } - }, - wantErr: true, - wantErrMsg: "failed to store credentials in Secrets Manager", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - mockStore := NewMockSecretsStore() - - if tt.mockSetup != nil { - tt.mockSetup(mockStore) - } - - err := storeGCPCredentials(ctx, mockStore, tt.stackName, tt.credsJSON) - - if tt.wantErr { - assert.Error(t, err) - if tt.wantErrMsg != "" { - assert.Contains(t, err.Error(), tt.wantErrMsg) - } - } else { - assert.NoError(t, err) - if tt.validateStore != nil { - tt.validateStore(t, mockStore) - } - } - }) - } -} diff --git a/cmd/helpers_account_alias_test.go b/cmd/helpers_account_alias_test.go new file mode 100644 index 000000000..6a5e36a82 --- /dev/null +++ b/cmd/helpers_account_alias_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/organizations" + "github.com/aws/aws-sdk-go-v2/service/organizations/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestNewAccountAliasCacheWithClient(t *testing.T) { + mockOrg := &MockOrganizationsClient{} + cache := NewAccountAliasCacheWithClient(mockOrg) + + assert.NotNil(t, cache) + assert.NotNil(t, cache.cache) + assert.Equal(t, mockOrg, cache.orgClient) + assert.Equal(t, 0, len(cache.cache)) +} + +func TestGetAccountAlias(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + accountID string + mockSetup func(m *MockOrganizationsClient) + expected string + shouldCache bool + }{ + { + name: "Empty account ID returns empty", + accountID: "", + mockSetup: func(m *MockOrganizationsClient) { + // No calls expected + }, + expected: "", + shouldCache: false, + }, + { + name: "Successful account lookup", + accountID: "123456789012", + mockSetup: func(m *MockOrganizationsClient) { + m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ + AccountId: aws.String("123456789012"), + }).Return(&organizations.DescribeAccountOutput{ + Account: &types.Account{ + Name: aws.String("Production Account"), + }, + }, nil).Once() + }, + expected: "Production Account", + shouldCache: true, + }, + { + name: "Lookup error - GetAccountAlias shows the ID, uncached", + accountID: "999888777666", + mockSetup: func(m *MockOrganizationsClient) { + m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ + AccountId: aws.String("999888777666"), + }).Return(nil, errors.New("account not found")).Once() + }, + expected: "999888777666", + shouldCache: false, // a failed lookup is not remembered + }, + { + name: "Account with nil name - uses ID as fallback", + accountID: "111222333444", + mockSetup: func(m *MockOrganizationsClient) { + m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ + AccountId: aws.String("111222333444"), + }).Return(&organizations.DescribeAccountOutput{ + Account: &types.Account{ + Name: nil, + }, + }, nil).Once() + }, + expected: "111222333444", + shouldCache: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockOrg := &MockOrganizationsClient{} + if tt.mockSetup != nil { + tt.mockSetup(mockOrg) + } + + cache := NewAccountAliasCacheWithClient(mockOrg) + + result := cache.GetAccountAlias(ctx, tt.accountID) + assert.Equal(t, tt.expected, result) + + if tt.shouldCache && tt.accountID != "" { + // Verify caching - second call should not hit the API + result2 := cache.GetAccountAlias(ctx, tt.accountID) + assert.Equal(t, tt.expected, result2) + } + + mockOrg.AssertExpectations(t) + }) + } +} + +func TestGetAccountAliasConcurrency(t *testing.T) { + // This test relies on AccountAliasCache.GetAccountAlias using double-checked locking: + // first acquire a read lock to check the cache, then acquire a write lock and re-check + // before calling the API. Without this pattern, multiple goroutines could pass the + // read-lock cache-miss check concurrently and issue multiple API calls. + // Run with -race to surface any data races in the cache map. + ctx := context.Background() + mockOrg := &MockOrganizationsClient{} + + // Setup mock to return account name + mockOrg.On("DescribeAccount", ctx, mock.AnythingOfType("*organizations.DescribeAccountInput")). + Return(&organizations.DescribeAccountOutput{ + Account: &types.Account{ + Name: aws.String("Test Account"), + }, + }, nil).Once() + + cache := NewAccountAliasCacheWithClient(mockOrg) + + // Test concurrent access to ensure proper locking. Worker goroutines must + // not call assert/require directly; funnel results back and assert on the + // test goroutine after wg.Wait(). + const numGoroutines = 10 + var wg sync.WaitGroup + wg.Add(numGoroutines) + results := make(chan string, numGoroutines) + for i := 0; i < numGoroutines; i++ { + go func() { + defer wg.Done() + results <- cache.GetAccountAlias(ctx, "123456789012") + }() + } + + // Wait for all goroutines to complete, then assert on the test goroutine. + wg.Wait() + close(results) + for result := range results { + assert.Equal(t, "Test Account", result) + } + + // Mock should only be called once due to double-checked locking in GetAccountAlias + mockOrg.AssertExpectations(t) +} + +func TestGetAccountAliasRealFunction(t *testing.T) { + // Skip integration test that requires real AWS API + t.Skip("Skipping integration test - GetAccountAlias tested via mock tests") + + // This test would validate GetAccountAlias with real AWS API + // but the functionality is already tested via the mock tests above +} diff --git a/cmd/helpers_adjust_existing_test.go b/cmd/helpers_adjust_existing_test.go new file mode 100644 index 000000000..61b0e0078 --- /dev/null +++ b/cmd/helpers_adjust_existing_test.go @@ -0,0 +1,271 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAdjustRecommendationsForExisting(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + inputRecs []common.Recommendation + existingRIs []common.Commitment + expectedCounts []int + expectedLen int + }{ + { + name: "No existing RIs - all recommendations kept", + inputRecs: []common.Recommendation{ + {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5}, + {ResourceType: "db.t3.medium", Region: "us-west-2", Count: 3}, + }, + existingRIs: []common.Commitment{}, + expectedLen: 2, + expectedCounts: []int{5, 3}, + }, + { + name: "Recent RI - partial adjustment", + inputRecs: []common.Recommendation{ + {ResourceType: "db.t3.small", Region: "us-east-1", Count: 10, Details: &common.DatabaseDetails{Engine: "mysql"}}, + }, + existingRIs: []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 3, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, + }, + expectedLen: 1, + expectedCounts: []int{7}, // 10 - 3 + }, + { + name: "Recent RI - complete coverage", + inputRecs: []common.Recommendation{ + {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "postgresql"}}, + }, + existingRIs: []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "postgresql", Count: 10, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, + }, + expectedLen: 0, // All covered + expectedCounts: []int{}, + }, + { + name: "Old RI - not recent, no adjustment", + inputRecs: []common.Recommendation{ + {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "mysql"}}, + }, + existingRIs: []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 10, State: "active", StartDate: time.Now().Add(-48 * time.Hour)}, + }, + expectedLen: 1, + expectedCounts: []int{5}, // No adjustment - RI is too old + }, + { + // Boundary: just inside the 24-hour window — should be treated as recent + name: "RI just inside lookback threshold - adjusted", + inputRecs: []common.Recommendation{ + {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "mysql"}}, + }, + existingRIs: []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 10, State: "active", StartDate: time.Now().Add(-23*time.Hour - 59*time.Minute)}, + }, + expectedLen: 0, // Recent RI fully covers the recommendation + expectedCounts: []int{}, + }, + { + // Boundary: just outside the 24-hour window — should not be treated as recent + name: "RI just outside lookback threshold - not adjusted", + inputRecs: []common.Recommendation{ + {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "mysql"}}, + }, + existingRIs: []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 10, State: "active", StartDate: time.Now().Add(-24*time.Hour - 1*time.Minute)}, + }, + expectedLen: 1, // RI is outside lookback window, no adjustment + expectedCounts: []int{5}, + }, + { + name: "Different engine - no adjustment", + inputRecs: []common.Recommendation{ + {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "postgresql"}}, + }, + existingRIs: []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 10, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, + }, + expectedLen: 1, + expectedCounts: []int{5}, // Different engine + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &MockServiceClient{} + mockClient.On("GetExistingCommitments", ctx).Return(tt.existingRIs, nil) + + checker := NewDuplicateChecker(0) + result, _, err := checker.AdjustRecommendationsForExisting(ctx, tt.inputRecs, mockClient) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedLen, len(result)) + for i := range result { + if i < len(tt.expectedCounts) { + assert.Equal(t, tt.expectedCounts[i], result[i].Count) + } + } + + mockClient.AssertExpectations(t) + }) + } +} + +func TestAdjustRecommendationsForExistingRIsEdgeCases(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + inputRecs []common.Recommendation + existingRIs []common.Commitment + expectedCounts []int + expectedLen int + }{ + { + name: "Multiple RIs same instance type different regions", + inputRecs: []common.Recommendation{ + {ResourceType: "db.t3.small", Region: "us-east-1", Count: 10, Details: &common.DatabaseDetails{Engine: "mysql"}}, + {ResourceType: "db.t3.small", Region: "eu-west-1", Count: 8, Details: &common.DatabaseDetails{Engine: "mysql"}}, + }, + existingRIs: []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 3, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, + {ResourceType: "db.t3.small", Region: "eu-west-1", Engine: "mysql", Count: 2, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, + }, + expectedLen: 2, // Both regions should have adjusted counts + expectedCounts: []int{7, 6}, + }, + { + name: "Retired RI should not affect recommendations", + inputRecs: []common.Recommendation{ + {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "mysql"}}, + }, + existingRIs: []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 10, State: "retired", StartDate: time.Now().Add(-1 * time.Hour)}, + }, + expectedLen: 1, // Retired RI should not affect + expectedCounts: []int{5}, + }, + { + name: "Payment pending RI should adjust", + inputRecs: []common.Recommendation{ + {ResourceType: "db.t3.small", Region: "us-east-1", Count: 10, Details: &common.DatabaseDetails{Engine: "mysql"}}, + }, + existingRIs: []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 4, State: "payment-pending", StartDate: time.Now().Add(-1 * time.Hour)}, + }, + expectedLen: 1, + expectedCounts: []int{6}, // 10 - 4 = 6 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &MockServiceClient{} + mockClient.On("GetExistingCommitments", ctx).Return(tt.existingRIs, nil) + + checker := NewDuplicateChecker(0) + result, _, err := checker.AdjustRecommendationsForExisting(ctx, tt.inputRecs, mockClient) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedLen, len(result)) + for i := range result { + if i < len(tt.expectedCounts) { + assert.Equal(t, tt.expectedCounts[i], result[i].Count) + } + } + + mockClient.AssertExpectations(t) + }) + } +} + +func TestNewDuplicateChecker_CustomWindow(t *testing.T) { + checker := NewDuplicateChecker(48) + assert.Equal(t, 48, checker.LookbackHours) +} + +func TestNewDuplicateChecker_ZeroUsesDefault(t *testing.T) { + checker := NewDuplicateChecker(0) + assert.Equal(t, DefaultDuplicateCheckLookbackHours, checker.LookbackHours) +} + +func TestAdjustRecommendationsForExisting_WithinWindow(t *testing.T) { + ctx := context.Background() + rec := common.Recommendation{ + ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, + Details: &common.DatabaseDetails{Engine: "mysql"}, + } + existing := []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", + Count: 5, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, + } + + mockClient := &MockServiceClient{} + mockClient.On("GetExistingCommitments", ctx).Return(existing, nil) + + checker := NewDuplicateChecker(24) + passed, filtered, err := checker.AdjustRecommendationsForExisting(ctx, []common.Recommendation{rec}, mockClient) + + require.NoError(t, err) + assert.Empty(t, passed) + assert.Len(t, filtered, 1) + mockClient.AssertExpectations(t) +} + +func TestAdjustRecommendationsForExisting_OutsideWindow(t *testing.T) { + ctx := context.Background() + rec := common.Recommendation{ + ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, + Details: &common.DatabaseDetails{Engine: "mysql"}, + } + existing := []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", + Count: 5, State: "active", StartDate: time.Now().Add(-30 * 24 * time.Hour)}, + } + + mockClient := &MockServiceClient{} + mockClient.On("GetExistingCommitments", ctx).Return(existing, nil) + + checker := NewDuplicateChecker(24) + passed, filtered, err := checker.AdjustRecommendationsForExisting(ctx, []common.Recommendation{rec}, mockClient) + + require.NoError(t, err) + assert.Len(t, passed, 1) + assert.Equal(t, 5, passed[0].Count) + assert.Empty(t, filtered) + mockClient.AssertExpectations(t) +} + +func TestAdjustRecommendationsForExisting_PartialCoverage(t *testing.T) { + ctx := context.Background() + rec := common.Recommendation{ + ResourceType: "db.t3.small", Region: "us-east-1", Count: 10, + Details: &common.DatabaseDetails{Engine: "mysql"}, + } + existing := []common.Commitment{ + {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", + Count: 3, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, + } + + mockClient := &MockServiceClient{} + mockClient.On("GetExistingCommitments", ctx).Return(existing, nil) + + checker := NewDuplicateChecker(24) + passed, filtered, err := checker.AdjustRecommendationsForExisting(ctx, []common.Recommendation{rec}, mockClient) + + require.NoError(t, err) + require.Len(t, passed, 1) + assert.Equal(t, 7, passed[0].Count) // 10 - 3 = 7 + assert.Empty(t, filtered) // partial coverage stays in passed, not filtered + mockClient.AssertExpectations(t) +} diff --git a/cmd/helpers_instance_limit_test.go b/cmd/helpers_instance_limit_test.go new file mode 100644 index 000000000..9c194f0b6 --- /dev/null +++ b/cmd/helpers_instance_limit_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "math" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestApplyInstanceLimit(t *testing.T) { + tests := []struct { + name string + recs []common.Recommendation + expectedCounts []int + expectedLen int + maxInstances int32 + }{ + { + name: "No limit - all recommendations kept", + recs: []common.Recommendation{ + {Count: 5, ResourceType: "db.t3.small"}, + {Count: 3, ResourceType: "db.t3.medium"}, + }, + maxInstances: 0, + expectedLen: 2, + expectedCounts: []int{5, 3}, + }, + { + name: "Limit exceeds total - all kept", + recs: []common.Recommendation{ + {Count: 5, ResourceType: "db.t3.small"}, + {Count: 3, ResourceType: "db.t3.medium"}, + }, + maxInstances: 20, + expectedLen: 2, + expectedCounts: []int{5, 3}, + }, + { + name: "Limit applies to first recommendation", + recs: []common.Recommendation{ + {Count: 10, ResourceType: "db.t3.small"}, + {Count: 5, ResourceType: "db.t3.medium"}, + }, + maxInstances: 7, + expectedLen: 1, + expectedCounts: []int{7}, + }, + { + name: "Limit applies across recommendations", + recs: []common.Recommendation{ + {Count: 5, ResourceType: "db.t3.small"}, + {Count: 5, ResourceType: "db.t3.medium"}, + {Count: 5, ResourceType: "db.t3.large"}, + }, + maxInstances: 12, + expectedLen: 3, + expectedCounts: []int{5, 5, 2}, + }, + { + name: "Negative limit - all kept", + recs: []common.Recommendation{ + {Count: 5, ResourceType: "db.t3.small"}, + }, + maxInstances: -1, + expectedLen: 1, + expectedCounts: []int{5}, + }, + { + // math.MinInt32 should be treated the same as any negative value: no limit applied + name: "math.MinInt32 limit - all kept (no int32 wrap)", + recs: []common.Recommendation{ + {Count: 5, ResourceType: "db.t3.small"}, + }, + maxInstances: math.MinInt32, + expectedLen: 1, + expectedCounts: []int{5}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ApplyInstanceLimit(tt.recs, tt.maxInstances) + assert.Equal(t, tt.expectedLen, len(result)) + for i := range result { + if i < len(tt.expectedCounts) { + assert.Equal(t, tt.expectedCounts[i], result[i].Count) + } + } + }) + } +} diff --git a/cmd/helpers_target_coverage_guards_test.go b/cmd/helpers_target_coverage_guards_test.go new file mode 100644 index 000000000..58159d6d9 --- /dev/null +++ b/cmd/helpers_target_coverage_guards_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestApplySizing checks the routing helper picks the right sizer based +// on cfg.TargetCoverage being >0 vs ==0. +func TestApplySizing(t *testing.T) { + ri := common.Recommendation{ + Service: common.ServiceEC2, + Count: 10, + CommitmentType: common.CommitmentReservedInstance, + ExistingCoverageKnown: true, + AverageInstancesUsedPerHour: 8, + } + + t.Run("TargetCoverage > 0 → ApplyTargetCoverage", func(t *testing.T) { + cfg := Config{TargetCoverage: 80, Coverage: 100} + out := applySizing([]common.Recommendation{ri}, cfg, cfg.Coverage, nil) + require.Len(t, out, 1) + // avg=8, target=80%, existing=0%. gap=80. + // n = floor(8 * 80/100) = floor(6.4) = 6. ProjUtil = 8/6 = 133% → 100. + assert.Equal(t, 6, out[0].Count) + assert.Equal(t, 100.0, out[0].ProjectedUtilization) + }) + + t.Run("TargetCoverage == 0 → ApplyCoverage", func(t *testing.T) { + cfg := Config{TargetCoverage: 0, Coverage: 50} + out := applySizing([]common.Recommendation{ri}, cfg, cfg.Coverage, nil) + require.Len(t, out, 1) + // ApplyCoverage(50) on count=10 → 5. ProjectedUtilization NOT set + // (zero) because we took the coverage branch. + assert.Equal(t, 5, out[0].Count) + assert.Equal(t, 0.0, out[0].ProjectedUtilization) + }) +} + +// TestApplyTargetCoverage_RI_Target100 covers the target == 100 boundary. +// With the coverage-anchored formula, target=100 (existing=0) yields +// n = floor(avg * 100/100) = floor(avg) — operators get a buy sized to +// the pool's average concurrent demand, not AWS's rec.Count (which may +// be sized to peak/ROI-curated). Pools where avg<1 drop; --min-pool-size +// is the intended upstream filter. +func TestApplyTargetCoverage_RI_Target100(t *testing.T) { + mkRI := func(count int, avg float64) common.Recommendation { + return common.Recommendation{ + Service: common.ServiceEC2, + Region: "us-east-1", + ResourceType: "t3.medium", + Count: count, + CommitmentType: common.CommitmentReservedInstance, + ExistingCoverageKnown: true, + AverageInstancesUsedPerHour: avg, + } + } + + tests := []struct { + name string + rec common.Recommendation + wantDropped bool + wantCount int + }{ + // avg=0.999 → floor(0.999)=0 → drop. + {name: "target 100, avg=0.999 → drop (avg<1)", rec: mkRI(5, 0.999), wantDropped: true}, + // avg=1.0 → buy 1 (matches avg demand). + {name: "target 100, avg=1 → buy 1", rec: mkRI(5, 1.0), wantCount: 1}, + // avg=8.7 → floor(8.7) = 8. + {name: "target 100, avg=8.7 → buy floor(avg)=8", rec: mkRI(10, 8.7), wantCount: 8}, + // avg=10 → buy 10 (avg=10 means 10 concurrent instances on average). + {name: "target 100, avg=10 → buy 10", rec: mkRI(10, 10.0), wantCount: 10}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := ApplyTargetCoverage([]common.Recommendation{tt.rec}, 100, nil) + if tt.wantDropped { + assert.Len(t, out, 0, "expected drop at target=100 for avg=%.3f", tt.rec.AverageInstancesUsedPerHour) + return + } + require.Len(t, out, 1) + assert.Equal(t, tt.wantCount, out[0].Count) + }) + } +} + +// TestApplyTargetCoverage_SP_NoSignalGuards covers the two SP no-signal +// branches: RecommendedUtilization <= 0 (already covered by other tests) and +// the new HourlyCommitment <= 0 guard (CE occasionally returns $0 +// placeholder recs). +func TestApplyTargetCoverage_SP_NoSignalGuards(t *testing.T) { + t.Run("HourlyCommitment=0 with positive RecommendedUtilization → pass through unscaled", func(t *testing.T) { + rec := common.Recommendation{ + Service: common.ServiceSavingsPlansCompute, + CommitmentType: common.CommitmentSavingsPlan, + EstimatedSavings: 1500, + RecommendedUtilization: 50, + Details: &common.SavingsPlanDetails{HourlyCommitment: 0}, + } + out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, nil) + require.Len(t, out, 1, "$0 SP rec should still be in output (pass-through)") + // Pass-through — projection fields must NOT be set, savings unchanged. + assert.Equal(t, 0.0, out[0].ProjectedUtilization, "ProjectedUtilization must NOT be set for $0-commitment pass-through") + assert.Equal(t, 1500.0, out[0].EstimatedSavings, "EstimatedSavings unchanged on pass-through") + assert.Equal(t, 0.0, out[0].Details.(*common.SavingsPlanDetails).HourlyCommitment, "HourlyCommitment unchanged") + }) + + t.Run("Details is wrong type → pass through unscaled, no projection metric set", func(t *testing.T) { + // Defensive case: SP rec with non-SP Details (a parser bug). The + // scaling can't proceed, and we MUST NOT set ProjectedUtilization + // to target% because the underlying cost fields aren't scaled — + // that would mislead the operator into thinking the rec was sized + // to the target when in fact it's the original unscaled commitment. + rec := common.Recommendation{ + Service: common.ServiceSavingsPlansCompute, + CommitmentType: common.CommitmentSavingsPlan, + EstimatedSavings: 1500, + RecommendedUtilization: 50, + Details: common.ComputeDetails{Platform: "Linux/UNIX"}, // wrong type + } + out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, nil) + require.Len(t, out, 1) + assert.Equal(t, 0.0, out[0].ProjectedUtilization, "must NOT set projection when scaling failed") + assert.Equal(t, 1500.0, out[0].EstimatedSavings, "EstimatedSavings must remain unscaled when scaling failed") + }) +} + +// TestApplyTargetCoverage_DropTargetAlreadyMet verifies that when existing +// coverage already meets or exceeds the target, the recommendation is +// dropped and the drop is recorded in a non-nil DropSummary under the +// DropTargetAlreadyMet category. If the drops.Add call for that branch +// were removed, d.Total() would stay at 0 and the assertion below would fail. +func TestApplyTargetCoverage_DropTargetAlreadyMet(t *testing.T) { + // ExistingCoveragePct=90 >= target=80: gapPct=80-90=-10 <= 0 -> drop. + rec := common.Recommendation{ + Service: common.ServiceEC2, + Region: "us-east-1", + ResourceType: "t3.medium", + Count: 5, + CommitmentType: common.CommitmentReservedInstance, + ExistingCoverageKnown: true, + AverageInstancesUsedPerHour: 8.0, + ExistingCoveragePct: 90.0, + } + + d := common.NewDropSummary() + out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, d) + + assert.Empty(t, out, "already-covered rec should be dropped") + assert.Equal(t, 1, d.Total(), "drop summary should record 1 drop") + assert.Contains(t, d.FormatOneLine(), common.DropTargetAlreadyMet, + "drop summary should name the target-already-met category") +} + +// TestApplyTargetCoverage_DropTargetSizedToZero verifies that when the +// floor(avg * gapPct / 100) formula produces 0, the recommendation is +// dropped and the drop is recorded in a non-nil DropSummary under the +// DropTargetSizedToZero category. If the drops.Add call for that branch +// were removed, d.Total() would stay at 0 and the assertion below would fail. +func TestApplyTargetCoverage_DropTargetSizedToZero(t *testing.T) { + // avg=0.4, target=80%, existing=0%: floor(0.4 * 80/100) = floor(0.32) = 0 -> drop. + rec := common.Recommendation{ + Service: common.ServiceEC2, + Region: "us-east-1", + ResourceType: "t3.micro", + Count: 1, + CommitmentType: common.CommitmentReservedInstance, + ExistingCoverageKnown: true, + AverageInstancesUsedPerHour: 0.4, + ExistingCoveragePct: 0.0, + } + + d := common.NewDropSummary() + out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, d) + + assert.Empty(t, out, "floor-to-zero rec should be dropped") + assert.Equal(t, 1, d.Total(), "drop summary should record 1 drop") + assert.Contains(t, d.FormatOneLine(), common.DropTargetSizedToZero, + "drop summary should name the target-sized-to-zero category") +} diff --git a/cmd/helpers_target_coverage_test.go b/cmd/helpers_target_coverage_test.go new file mode 100644 index 000000000..fb771d870 --- /dev/null +++ b/cmd/helpers_target_coverage_test.go @@ -0,0 +1,344 @@ +package main + +import ( + "math" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestApplyTargetCoverage covers the RI sizing branch of issue #338's +// --target-coverage flag, now under-buy semantics: n = floor(avg*target). +// Confirms: floor (not ceil) selection so coverage stays at-most target, +// drop-when-target-too-low (avg*target < 1), no-signal pass-through, and +// projected utilization (typically 100% since we under-buy) / coverage +// (tracks target%) outputs. +func TestApplyTargetCoverage_RI(t *testing.T) { + mkRI := func(count int, avg, recUtil float64) common.Recommendation { + return common.Recommendation{ + Service: common.ServiceEC2, + Region: "us-east-1", + ResourceType: "t3.medium", + Count: count, + CommitmentType: common.CommitmentReservedInstance, + CommitmentCost: 1000, + OnDemandCost: 2000, + EstimatedSavings: 500, + ExistingCoverageKnown: true, + AverageInstancesUsedPerHour: avg, + RecommendedUtilization: recUtil, + } + } + + tests := []struct { + name string + rec common.Recommendation + target float64 + wantDropped bool + wantCount int + wantProjUtil float64 // 0 means "don't assert" + wantProjCovGTE float64 // we assert coverage >= this (handles the float clamping) + }{ + { + // avg=8.5, target=95%, existing=0%. + // gap=95. n = floor(8.5 * 95/100) = floor(8.075) = 8. + // Projected util = 8.5/8 = 106.25 → clamped to 100. + // Projected cov = 0 + 8/8.5*100 = 94.117…% + name: "RI: target 95 buys 8 (floor of avg*0.95)", + rec: mkRI(10, 8.5, 0), + target: 95, + wantCount: 8, + wantProjUtil: 100, + wantProjCovGTE: 94.0, + }, + { + // avg=10, target=50%, existing=0%. n=floor(10*0.5)=5. + // Projected cov = 5/10*100 = 50.0%. + name: "RI: target 50 buys half of avg demand", + rec: mkRI(10, 10, 0), + target: 50, + wantCount: 5, + wantProjUtil: 100, // 10/5 clamped + wantProjCovGTE: 50.0, + }, + { + // avg=0.4, target=50%, existing=0%. + // n = floor(0.4 * 50/100) = floor(0.2) = 0 → DROPPED. + // Tiny pools where avg×gap%<100 produce 0 RIs under the + // coverage-anchored formula. --min-pool-size upstream is the + // intended filter for these; the drop here is the fallback + // when the upstream filter wasn't applied. + name: "RI: tiny avg below 1-RI threshold drops", + rec: mkRI(5, 0.4, 0), + target: 50, + wantDropped: true, + }, + { + // avg=0 (no signal) → passed through unchanged, counted in skip summary. + // Projection metrics never set on the pass-through path. + name: "RI: no signal → passed through unmodified", + rec: mkRI(5, 0, 0), + target: 80, + wantCount: 5, + wantProjUtil: 0, // never set in pass-through + }, + { + // avg=4, target=80%, existing=0%. n=floor(4*0.8)=3. + // Projected util = 4/3 = 133% clamped to 100. + // Projected cov = 3/4*100 = 75.0%. + name: "RI: target 80 buys floor(avg*0.8)", + rec: mkRI(5, 4, 0), + target: 80, + wantCount: 3, + wantProjUtil: 100, + wantProjCovGTE: 75.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + recs := []common.Recommendation{tt.rec} + out := ApplyTargetCoverage(recs, tt.target, nil) + if tt.wantDropped { + if len(out) != 0 { + t.Fatalf("expected drop; got %d recs", len(out)) + } + return + } + if len(out) != 1 { + t.Fatalf("expected 1 rec; got %d", len(out)) + } + if out[0].Count != tt.wantCount { + t.Errorf("Count: got %d, want %d", out[0].Count, tt.wantCount) + } + if tt.wantProjUtil > 0 { + if math.Abs(out[0].ProjectedUtilization-tt.wantProjUtil) > 0.01 { + t.Errorf("ProjectedUtilization: got %.4f, want %.4f", + out[0].ProjectedUtilization, tt.wantProjUtil) + } + } + // Zero means "don't assert" (matches the wantProjUtil convention) + // since the pass-through path leaves ProjectedCoverage at zero. + if tt.wantProjCovGTE > 0 { + if out[0].ProjectedCoverage < tt.wantProjCovGTE-0.01 { + t.Errorf("ProjectedCoverage: got %.4f, want >= %.4f", + out[0].ProjectedCoverage, tt.wantProjCovGTE) + } + } + }) + } +} + +// TestApplyTargetCoverage_RI_CostScaling verifies RI cost-bearing fields +// scale by the sized-to-original count ratio. SavingsPercentage is invariant. +// The scaled values let downstream consumers (CSV writer, reporter, audit +// log) trust rec.CommitmentCost / rec.EstimatedSavings as the sized purchase +// rather than AWS's pre-sized proposal. +func TestApplyTargetCoverage_RI_CostScaling(t *testing.T) { + rec := common.Recommendation{ + Service: common.ServiceEC2, + Count: 10, + CommitmentType: common.CommitmentReservedInstance, + CommitmentCost: 1000, + OnDemandCost: 2000, + EstimatedSavings: 500, + SavingsPercentage: 25, + ExistingCoverageKnown: true, + AverageInstancesUsedPerHour: 8, + } + // target=80, existing=0, avg=8 → gap=80. + // n = floor(8 * 80/100) = 6. Ratio = 6/10 = 0.6 (cost scaling still + // uses rec.Count to convert AWS's quoted cost-for-rec.Count into + // cost-for-nTarget). + out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, nil) + require.Len(t, out, 1) + assert.Equal(t, 6, out[0].Count) + assert.InDelta(t, 600.0, out[0].CommitmentCost, 0.001, "CommitmentCost scales by nTarget/rec.Count") + assert.InDelta(t, 1200.0, out[0].OnDemandCost, 0.001, "OnDemandCost scales by nTarget/rec.Count") + assert.InDelta(t, 300.0, out[0].EstimatedSavings, 0.001, "EstimatedSavings scales by nTarget/rec.Count") + assert.Equal(t, 25.0, out[0].SavingsPercentage, "SavingsPercentage is invariant under count scaling") + + t.Run("RecurringMonthlyCost scales by ratio when populated", func(t *testing.T) { + // AWS populated RecurringStandardMonthlyCost for partial/no-upfront + // recs; sized purchase must scale this monthly fee by the same + // nTarget/rec.Count ratio so total cost (upfront + monthly × term) + // reflects what the user actually buys. + monthly := 50.0 + recWithMonthly := rec + recWithMonthly.RecurringMonthlyCost = &monthly + out := ApplyTargetCoverage([]common.Recommendation{recWithMonthly}, 80, nil) + require.Len(t, out, 1) + require.NotNil(t, out[0].RecurringMonthlyCost, "scaled pointer should be non-nil") + assert.InDelta(t, 30.0, *out[0].RecurringMonthlyCost, 0.001, "monthly cost scales by 6/10") + // Original pointer untouched (we allocated a new one). + assert.Equal(t, 50.0, monthly, "original RecurringMonthlyCost target should not be mutated") + }) + + t.Run("RecurringMonthlyCost stays nil when not populated", func(t *testing.T) { + // AWS API didn't return RecurringStandardMonthlyCost (all-upfront, + // or field missing). The sized rec should also have nil so + // downstream renders "unknown" rather than zero. + out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, nil) + require.Len(t, out, 1) + assert.Nil(t, out[0].RecurringMonthlyCost, "nil input → nil output") + }) +} + +// TestApplyTargetCoverage_RI_ExistingCoverage covers the under-buy formula's +// existing-commitment branch: gap = (target - existing_cov)/100, then +// n_target = floor(avg * gap). Matches the worked example from the #338 design +// thread (20 instances, 10 existing RIs at 50% coverage, target 80% → buy 6). +func TestApplyTargetCoverage_RI_ExistingCoverage(t *testing.T) { + mkRI := func(count int, avg, existingCov float64) common.Recommendation { + return common.Recommendation{ + Service: common.ServiceEC2, + Region: "us-east-1", + ResourceType: "t3.medium", + Count: count, + RecommendedCount: count, + CommitmentType: common.CommitmentReservedInstance, + CommitmentCost: 1000, + OnDemandCost: 2000, + EstimatedSavings: 500, + ExistingCoverageKnown: true, + AverageInstancesUsedPerHour: avg, + ExistingCoveragePct: existingCov, + } + } + + tests := []struct { + name string + rec common.Recommendation + target float64 + wantDropped bool + wantCount int + wantTotalCov float64 // ProjectedCoverage = existing + new contribution + }{ + { + // User's worked example: avg=20, existing=50%, target=80%. + // gap=30. n=ceil(20*0.30)=6. Total cov = 50 + 6/20*100 = 80. + name: "User example: 50% existing, 80% target on avg=20 → buy 6", + rec: mkRI(10, 20, 50), + target: 80, + wantCount: 6, + wantTotalCov: 80, + }, + { + // existing=0%, target=70%, avg=10. gap=70. n=ceil(7)=7. + name: "Zero existing: ceil(avg*target/100)", + rec: mkRI(10, 10, 0), + target: 70, + wantCount: 7, + wantTotalCov: 70, + }, + { + // existing=80% meets target=80% → drop. + name: "Existing meets target exactly: drop", + rec: mkRI(10, 10, 80), + target: 80, + wantDropped: true, + }, + { + // existing=95% exceeds target=80% → drop. + name: "Existing exceeds target: drop", + rec: mkRI(10, 10, 95), + target: 80, + wantDropped: true, + }, + { + // avg=2, existing=70%, target=80%. gap=10. + // n = floor(2 * 10/100) = floor(0.2) = 0 → DROPPED. + // Small pool + thin gap: no integer buy can approximate the + // target. --min-pool-size upstream is the intended filter. + name: "Small gap on tiny avg drops", + rec: mkRI(5, 2, 70), + target: 80, + wantDropped: true, + }, + { + // avg=10, existing=60%, target=70%. gap=10. + // n = floor(10 * 10/100) = 1. Total cov = 60 + 1/10*100 = 70. + // Coverage-anchored: 1 RI exactly closes the 10-point gap + // because avg=10 and each RI is worth 10% of avg demand. + name: "Small top-up: 1 RI exactly closes 10-pt gap on avg=10", + rec: mkRI(10, 10, 60), + target: 70, + wantCount: 1, + wantTotalCov: 70, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + out := ApplyTargetCoverage([]common.Recommendation{tt.rec}, tt.target, nil) + if tt.wantDropped { + assert.Len(t, out, 0, "expected drop") + return + } + require.Len(t, out, 1) + assert.Equal(t, tt.wantCount, out[0].Count, "Count") + assert.InDelta(t, tt.wantTotalCov, out[0].ProjectedCoverage, 0.01, "ProjectedCoverage is TOTAL (existing + new)") + }) + } +} + +// TestApplyTargetCoverage_SP covers the SP sizing branch under under-buy +// semantics: HourlyCommitment and EstimatedSavings scale by targetPct/100 +// regardless of AWS's projected utilization. CommitmentCost / OnDemandCost / +// SavingsPercentage must NOT change. +func TestApplyTargetCoverage_SP(t *testing.T) { + mkSP := func(recUtil float64) common.Recommendation { + return common.Recommendation{ + Service: common.ServiceSavingsPlansCompute, + CommitmentType: common.CommitmentSavingsPlan, + CommitmentCost: 1000, + OnDemandCost: 5000, + EstimatedSavings: 1500, + SavingsPercentage: 30, + RecommendedUtilization: recUtil, + Details: &common.SavingsPlanDetails{HourlyCommitment: 2.0}, + } + } + + t.Run("AWS above target — still scales by target (under-buy)", func(t *testing.T) { + // RecUtil=95, target=80. Even though AWS projects above target, the + // flag's intent is "leave 20% headroom", so the commitment shrinks + // to 80% of AWS rec. All cost-bearing fields scale by 0.8. + // Projected util = 95/0.80 = 118.75 clamped to 100. + out := ApplyTargetCoverage([]common.Recommendation{mkSP(95)}, 80, nil) + require.Len(t, out, 1) + assert.InDelta(t, 1.6, out[0].Details.(*common.SavingsPlanDetails).HourlyCommitment, 0.001) + assert.InDelta(t, 800.0, out[0].CommitmentCost, 0.001, "CommitmentCost scales by target/100") + assert.InDelta(t, 4000.0, out[0].OnDemandCost, 0.001, "OnDemandCost scales by target/100") + assert.InDelta(t, 1200.0, out[0].EstimatedSavings, 0.001) + assert.Equal(t, 30.0, out[0].SavingsPercentage, "SavingsPercentage is invariant") + assert.InDelta(t, 100.0, out[0].ProjectedUtilization, 0.001, "RecUtil/ratio = 95/0.80 = 118.75 clamps to 100") + assert.Equal(t, 0.0, out[0].ProjectedCoverage, "SPs intentionally leave ProjectedCoverage at zero") + }) + + t.Run("AWS below target — scale down by target (under-buy)", func(t *testing.T) { + // RecUtil=50, target=80. All cost-bearing fields shrink to 80%. + // Projected util = 50/0.80 = 62.5 (no clamp needed). + out := ApplyTargetCoverage([]common.Recommendation{mkSP(50)}, 80, nil) + require.Len(t, out, 1) + details := out[0].Details.(*common.SavingsPlanDetails) + assert.InDelta(t, 1.6, details.HourlyCommitment, 0.001) + assert.InDelta(t, 800.0, out[0].CommitmentCost, 0.001, "CommitmentCost scales by target/100") + assert.InDelta(t, 4000.0, out[0].OnDemandCost, 0.001, "OnDemandCost scales by target/100") + assert.InDelta(t, 1200.0, out[0].EstimatedSavings, 0.001) + assert.Equal(t, 30.0, out[0].SavingsPercentage, "SavingsPercentage is invariant") + assert.InDelta(t, 62.5, out[0].ProjectedUtilization, 0.001, "RecUtil/ratio = 50/0.80 = 62.5") + assert.Equal(t, 0.0, out[0].ProjectedCoverage) + }) + + t.Run("no signal → passed through unchanged", func(t *testing.T) { + out := ApplyTargetCoverage([]common.Recommendation{mkSP(0)}, 80, nil) + require.Len(t, out, 1) + // Original recommendation values intact. + assert.Equal(t, 2.0, out[0].Details.(*common.SavingsPlanDetails).HourlyCommitment) + assert.Equal(t, 1500.0, out[0].EstimatedSavings) + assert.Equal(t, 0.0, out[0].ProjectedUtilization) + }) +} diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index 3f395c74c..3dffee484 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -3,17 +3,12 @@ package main import ( "context" "errors" - "math" - "sync" "testing" - "time" "github.com/LeanerCloud/cloud-commitments-go/pkg/common" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/organizations" - "github.com/aws/aws-sdk-go-v2/service/organizations/types" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -54,153 +49,6 @@ func TestCalculateTotalInstances(t *testing.T) { } } -func TestNewAccountAliasCacheWithClient(t *testing.T) { - mockOrg := &MockOrganizationsClient{} - cache := NewAccountAliasCacheWithClient(mockOrg) - - assert.NotNil(t, cache) - assert.NotNil(t, cache.cache) - assert.Equal(t, mockOrg, cache.orgClient) - assert.Equal(t, 0, len(cache.cache)) -} - -func TestGetAccountAlias(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - accountID string - mockSetup func(m *MockOrganizationsClient) - expected string - shouldCache bool - }{ - { - name: "Empty account ID returns empty", - accountID: "", - mockSetup: func(m *MockOrganizationsClient) { - // No calls expected - }, - expected: "", - shouldCache: false, - }, - { - name: "Successful account lookup", - accountID: "123456789012", - mockSetup: func(m *MockOrganizationsClient) { - m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ - AccountId: aws.String("123456789012"), - }).Return(&organizations.DescribeAccountOutput{ - Account: &types.Account{ - Name: aws.String("Production Account"), - }, - }, nil).Once() - }, - expected: "Production Account", - shouldCache: true, - }, - { - name: "Lookup error - GetAccountAlias shows the ID, uncached", - accountID: "999888777666", - mockSetup: func(m *MockOrganizationsClient) { - m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ - AccountId: aws.String("999888777666"), - }).Return(nil, errors.New("account not found")).Once() - }, - expected: "999888777666", - shouldCache: false, // a failed lookup is not remembered - }, - { - name: "Account with nil name - uses ID as fallback", - accountID: "111222333444", - mockSetup: func(m *MockOrganizationsClient) { - m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ - AccountId: aws.String("111222333444"), - }).Return(&organizations.DescribeAccountOutput{ - Account: &types.Account{ - Name: nil, - }, - }, nil).Once() - }, - expected: "111222333444", - shouldCache: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockOrg := &MockOrganizationsClient{} - if tt.mockSetup != nil { - tt.mockSetup(mockOrg) - } - - cache := NewAccountAliasCacheWithClient(mockOrg) - - result := cache.GetAccountAlias(ctx, tt.accountID) - assert.Equal(t, tt.expected, result) - - if tt.shouldCache && tt.accountID != "" { - // Verify caching - second call should not hit the API - result2 := cache.GetAccountAlias(ctx, tt.accountID) - assert.Equal(t, tt.expected, result2) - } - - mockOrg.AssertExpectations(t) - }) - } -} - -func TestGetAccountAliasConcurrency(t *testing.T) { - // This test relies on AccountAliasCache.GetAccountAlias using double-checked locking: - // first acquire a read lock to check the cache, then acquire a write lock and re-check - // before calling the API. Without this pattern, multiple goroutines could pass the - // read-lock cache-miss check concurrently and issue multiple API calls. - // Run with -race to surface any data races in the cache map. - ctx := context.Background() - mockOrg := &MockOrganizationsClient{} - - // Setup mock to return account name - mockOrg.On("DescribeAccount", ctx, mock.AnythingOfType("*organizations.DescribeAccountInput")). - Return(&organizations.DescribeAccountOutput{ - Account: &types.Account{ - Name: aws.String("Test Account"), - }, - }, nil).Once() - - cache := NewAccountAliasCacheWithClient(mockOrg) - - // Test concurrent access to ensure proper locking. Worker goroutines must - // not call assert/require directly; funnel results back and assert on the - // test goroutine after wg.Wait(). - const numGoroutines = 10 - var wg sync.WaitGroup - wg.Add(numGoroutines) - results := make(chan string, numGoroutines) - for i := 0; i < numGoroutines; i++ { - go func() { - defer wg.Done() - results <- cache.GetAccountAlias(ctx, "123456789012") - }() - } - - // Wait for all goroutines to complete, then assert on the test goroutine. - wg.Wait() - close(results) - for result := range results { - assert.Equal(t, "Test Account", result) - } - - // Mock should only be called once due to double-checked locking in GetAccountAlias - mockOrg.AssertExpectations(t) -} - -func TestGetAccountAliasRealFunction(t *testing.T) { - // Skip integration test that requires real AWS API - t.Skip("Skipping integration test - GetAccountAlias tested via mock tests") - - // This test would validate GetAccountAlias with real AWS API - // but the functionality is already tested via the mock tests above -} - func TestApplyCountOverride(t *testing.T) { tests := []struct { name string @@ -397,117 +245,6 @@ func TestApplySizing_LegacyCoverageRecordsCountsFlooredToZero(t *testing.T) { assert.Contains(t, drops.FormatOneLine(), common.DropCoverageSizedToZero) } -func TestAdjustRecommendationsForExisting(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - inputRecs []common.Recommendation - existingRIs []common.Commitment - expectedCounts []int - expectedLen int - }{ - { - name: "No existing RIs - all recommendations kept", - inputRecs: []common.Recommendation{ - {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5}, - {ResourceType: "db.t3.medium", Region: "us-west-2", Count: 3}, - }, - existingRIs: []common.Commitment{}, - expectedLen: 2, - expectedCounts: []int{5, 3}, - }, - { - name: "Recent RI - partial adjustment", - inputRecs: []common.Recommendation{ - {ResourceType: "db.t3.small", Region: "us-east-1", Count: 10, Details: &common.DatabaseDetails{Engine: "mysql"}}, - }, - existingRIs: []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 3, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, - }, - expectedLen: 1, - expectedCounts: []int{7}, // 10 - 3 - }, - { - name: "Recent RI - complete coverage", - inputRecs: []common.Recommendation{ - {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "postgresql"}}, - }, - existingRIs: []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "postgresql", Count: 10, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, - }, - expectedLen: 0, // All covered - expectedCounts: []int{}, - }, - { - name: "Old RI - not recent, no adjustment", - inputRecs: []common.Recommendation{ - {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "mysql"}}, - }, - existingRIs: []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 10, State: "active", StartDate: time.Now().Add(-48 * time.Hour)}, - }, - expectedLen: 1, - expectedCounts: []int{5}, // No adjustment - RI is too old - }, - { - // Boundary: just inside the 24-hour window — should be treated as recent - name: "RI just inside lookback threshold - adjusted", - inputRecs: []common.Recommendation{ - {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "mysql"}}, - }, - existingRIs: []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 10, State: "active", StartDate: time.Now().Add(-23*time.Hour - 59*time.Minute)}, - }, - expectedLen: 0, // Recent RI fully covers the recommendation - expectedCounts: []int{}, - }, - { - // Boundary: just outside the 24-hour window — should not be treated as recent - name: "RI just outside lookback threshold - not adjusted", - inputRecs: []common.Recommendation{ - {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "mysql"}}, - }, - existingRIs: []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 10, State: "active", StartDate: time.Now().Add(-24*time.Hour - 1*time.Minute)}, - }, - expectedLen: 1, // RI is outside lookback window, no adjustment - expectedCounts: []int{5}, - }, - { - name: "Different engine - no adjustment", - inputRecs: []common.Recommendation{ - {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "postgresql"}}, - }, - existingRIs: []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 10, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, - }, - expectedLen: 1, - expectedCounts: []int{5}, // Different engine - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockClient := &MockServiceClient{} - mockClient.On("GetExistingCommitments", ctx).Return(tt.existingRIs, nil) - - checker := NewDuplicateChecker(0) - result, _, err := checker.AdjustRecommendationsForExisting(ctx, tt.inputRecs, mockClient) - - assert.NoError(t, err) - assert.Equal(t, tt.expectedLen, len(result)) - for i := range result { - if i < len(tt.expectedCounts) { - assert.Equal(t, tt.expectedCounts[i], result[i].Count) - } - } - - mockClient.AssertExpectations(t) - }) - } -} - func TestGetRecommendationDescription(t *testing.T) { tests := []struct { name string @@ -663,747 +400,6 @@ func TestConfirmPurchaseRejectsNonterminalInput(t *testing.T) { } } -func TestAdjustRecommendationsForExistingRIsEdgeCases(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - inputRecs []common.Recommendation - existingRIs []common.Commitment - expectedCounts []int - expectedLen int - }{ - { - name: "Multiple RIs same instance type different regions", - inputRecs: []common.Recommendation{ - {ResourceType: "db.t3.small", Region: "us-east-1", Count: 10, Details: &common.DatabaseDetails{Engine: "mysql"}}, - {ResourceType: "db.t3.small", Region: "eu-west-1", Count: 8, Details: &common.DatabaseDetails{Engine: "mysql"}}, - }, - existingRIs: []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 3, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, - {ResourceType: "db.t3.small", Region: "eu-west-1", Engine: "mysql", Count: 2, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, - }, - expectedLen: 2, // Both regions should have adjusted counts - expectedCounts: []int{7, 6}, - }, - { - name: "Retired RI should not affect recommendations", - inputRecs: []common.Recommendation{ - {ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, Details: &common.DatabaseDetails{Engine: "mysql"}}, - }, - existingRIs: []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 10, State: "retired", StartDate: time.Now().Add(-1 * time.Hour)}, - }, - expectedLen: 1, // Retired RI should not affect - expectedCounts: []int{5}, - }, - { - name: "Payment pending RI should adjust", - inputRecs: []common.Recommendation{ - {ResourceType: "db.t3.small", Region: "us-east-1", Count: 10, Details: &common.DatabaseDetails{Engine: "mysql"}}, - }, - existingRIs: []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", Count: 4, State: "payment-pending", StartDate: time.Now().Add(-1 * time.Hour)}, - }, - expectedLen: 1, - expectedCounts: []int{6}, // 10 - 4 = 6 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockClient := &MockServiceClient{} - mockClient.On("GetExistingCommitments", ctx).Return(tt.existingRIs, nil) - - checker := NewDuplicateChecker(0) - result, _, err := checker.AdjustRecommendationsForExisting(ctx, tt.inputRecs, mockClient) - - assert.NoError(t, err) - assert.Equal(t, tt.expectedLen, len(result)) - for i := range result { - if i < len(tt.expectedCounts) { - assert.Equal(t, tt.expectedCounts[i], result[i].Count) - } - } - - mockClient.AssertExpectations(t) - }) - } -} - -func TestApplyInstanceLimit(t *testing.T) { - tests := []struct { - name string - recs []common.Recommendation - expectedCounts []int - expectedLen int - maxInstances int32 - }{ - { - name: "No limit - all recommendations kept", - recs: []common.Recommendation{ - {Count: 5, ResourceType: "db.t3.small"}, - {Count: 3, ResourceType: "db.t3.medium"}, - }, - maxInstances: 0, - expectedLen: 2, - expectedCounts: []int{5, 3}, - }, - { - name: "Limit exceeds total - all kept", - recs: []common.Recommendation{ - {Count: 5, ResourceType: "db.t3.small"}, - {Count: 3, ResourceType: "db.t3.medium"}, - }, - maxInstances: 20, - expectedLen: 2, - expectedCounts: []int{5, 3}, - }, - { - name: "Limit applies to first recommendation", - recs: []common.Recommendation{ - {Count: 10, ResourceType: "db.t3.small"}, - {Count: 5, ResourceType: "db.t3.medium"}, - }, - maxInstances: 7, - expectedLen: 1, - expectedCounts: []int{7}, - }, - { - name: "Limit applies across recommendations", - recs: []common.Recommendation{ - {Count: 5, ResourceType: "db.t3.small"}, - {Count: 5, ResourceType: "db.t3.medium"}, - {Count: 5, ResourceType: "db.t3.large"}, - }, - maxInstances: 12, - expectedLen: 3, - expectedCounts: []int{5, 5, 2}, - }, - { - name: "Negative limit - all kept", - recs: []common.Recommendation{ - {Count: 5, ResourceType: "db.t3.small"}, - }, - maxInstances: -1, - expectedLen: 1, - expectedCounts: []int{5}, - }, - { - // math.MinInt32 should be treated the same as any negative value: no limit applied - name: "math.MinInt32 limit - all kept (no int32 wrap)", - recs: []common.Recommendation{ - {Count: 5, ResourceType: "db.t3.small"}, - }, - maxInstances: math.MinInt32, - expectedLen: 1, - expectedCounts: []int{5}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ApplyInstanceLimit(tt.recs, tt.maxInstances) - assert.Equal(t, tt.expectedLen, len(result)) - for i := range result { - if i < len(tt.expectedCounts) { - assert.Equal(t, tt.expectedCounts[i], result[i].Count) - } - } - }) - } -} - -func TestNewDuplicateChecker_CustomWindow(t *testing.T) { - checker := NewDuplicateChecker(48) - assert.Equal(t, 48, checker.LookbackHours) -} - -func TestNewDuplicateChecker_ZeroUsesDefault(t *testing.T) { - checker := NewDuplicateChecker(0) - assert.Equal(t, DefaultDuplicateCheckLookbackHours, checker.LookbackHours) -} - -func TestAdjustRecommendationsForExisting_WithinWindow(t *testing.T) { - ctx := context.Background() - rec := common.Recommendation{ - ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, - Details: &common.DatabaseDetails{Engine: "mysql"}, - } - existing := []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", - Count: 5, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, - } - - mockClient := &MockServiceClient{} - mockClient.On("GetExistingCommitments", ctx).Return(existing, nil) - - checker := NewDuplicateChecker(24) - passed, filtered, err := checker.AdjustRecommendationsForExisting(ctx, []common.Recommendation{rec}, mockClient) - - require.NoError(t, err) - assert.Empty(t, passed) - assert.Len(t, filtered, 1) - mockClient.AssertExpectations(t) -} - -func TestAdjustRecommendationsForExisting_OutsideWindow(t *testing.T) { - ctx := context.Background() - rec := common.Recommendation{ - ResourceType: "db.t3.small", Region: "us-east-1", Count: 5, - Details: &common.DatabaseDetails{Engine: "mysql"}, - } - existing := []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", - Count: 5, State: "active", StartDate: time.Now().Add(-30 * 24 * time.Hour)}, - } - - mockClient := &MockServiceClient{} - mockClient.On("GetExistingCommitments", ctx).Return(existing, nil) - - checker := NewDuplicateChecker(24) - passed, filtered, err := checker.AdjustRecommendationsForExisting(ctx, []common.Recommendation{rec}, mockClient) - - require.NoError(t, err) - assert.Len(t, passed, 1) - assert.Equal(t, 5, passed[0].Count) - assert.Empty(t, filtered) - mockClient.AssertExpectations(t) -} - -func TestAdjustRecommendationsForExisting_PartialCoverage(t *testing.T) { - ctx := context.Background() - rec := common.Recommendation{ - ResourceType: "db.t3.small", Region: "us-east-1", Count: 10, - Details: &common.DatabaseDetails{Engine: "mysql"}, - } - existing := []common.Commitment{ - {ResourceType: "db.t3.small", Region: "us-east-1", Engine: "mysql", - Count: 3, State: "active", StartDate: time.Now().Add(-1 * time.Hour)}, - } - - mockClient := &MockServiceClient{} - mockClient.On("GetExistingCommitments", ctx).Return(existing, nil) - - checker := NewDuplicateChecker(24) - passed, filtered, err := checker.AdjustRecommendationsForExisting(ctx, []common.Recommendation{rec}, mockClient) - - require.NoError(t, err) - require.Len(t, passed, 1) - assert.Equal(t, 7, passed[0].Count) // 10 - 3 = 7 - assert.Empty(t, filtered) // partial coverage stays in passed, not filtered - mockClient.AssertExpectations(t) -} - -// TestApplyTargetCoverage covers the RI sizing branch of issue #338's -// --target-coverage flag, now under-buy semantics: n = floor(avg*target). -// Confirms: floor (not ceil) selection so coverage stays at-most target, -// drop-when-target-too-low (avg*target < 1), no-signal pass-through, and -// projected utilization (typically 100% since we under-buy) / coverage -// (tracks target%) outputs. -func TestApplyTargetCoverage_RI(t *testing.T) { - mkRI := func(count int, avg, recUtil float64) common.Recommendation { - return common.Recommendation{ - Service: common.ServiceEC2, - Region: "us-east-1", - ResourceType: "t3.medium", - Count: count, - CommitmentType: common.CommitmentReservedInstance, - CommitmentCost: 1000, - OnDemandCost: 2000, - EstimatedSavings: 500, - ExistingCoverageKnown: true, - AverageInstancesUsedPerHour: avg, - RecommendedUtilization: recUtil, - } - } - - tests := []struct { - name string - rec common.Recommendation - target float64 - wantDropped bool - wantCount int - wantProjUtil float64 // 0 means "don't assert" - wantProjCovGTE float64 // we assert coverage >= this (handles the float clamping) - }{ - { - // avg=8.5, target=95%, existing=0%. - // gap=95. n = floor(8.5 * 95/100) = floor(8.075) = 8. - // Projected util = 8.5/8 = 106.25 → clamped to 100. - // Projected cov = 0 + 8/8.5*100 = 94.117…% - name: "RI: target 95 buys 8 (floor of avg*0.95)", - rec: mkRI(10, 8.5, 0), - target: 95, - wantCount: 8, - wantProjUtil: 100, - wantProjCovGTE: 94.0, - }, - { - // avg=10, target=50%, existing=0%. n=floor(10*0.5)=5. - // Projected cov = 5/10*100 = 50.0%. - name: "RI: target 50 buys half of avg demand", - rec: mkRI(10, 10, 0), - target: 50, - wantCount: 5, - wantProjUtil: 100, // 10/5 clamped - wantProjCovGTE: 50.0, - }, - { - // avg=0.4, target=50%, existing=0%. - // n = floor(0.4 * 50/100) = floor(0.2) = 0 → DROPPED. - // Tiny pools where avg×gap%<100 produce 0 RIs under the - // coverage-anchored formula. --min-pool-size upstream is the - // intended filter for these; the drop here is the fallback - // when the upstream filter wasn't applied. - name: "RI: tiny avg below 1-RI threshold drops", - rec: mkRI(5, 0.4, 0), - target: 50, - wantDropped: true, - }, - { - // avg=0 (no signal) → passed through unchanged, counted in skip summary. - // Projection metrics never set on the pass-through path. - name: "RI: no signal → passed through unmodified", - rec: mkRI(5, 0, 0), - target: 80, - wantCount: 5, - wantProjUtil: 0, // never set in pass-through - }, - { - // avg=4, target=80%, existing=0%. n=floor(4*0.8)=3. - // Projected util = 4/3 = 133% clamped to 100. - // Projected cov = 3/4*100 = 75.0%. - name: "RI: target 80 buys floor(avg*0.8)", - rec: mkRI(5, 4, 0), - target: 80, - wantCount: 3, - wantProjUtil: 100, - wantProjCovGTE: 75.0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - recs := []common.Recommendation{tt.rec} - out := ApplyTargetCoverage(recs, tt.target, nil) - if tt.wantDropped { - if len(out) != 0 { - t.Fatalf("expected drop; got %d recs", len(out)) - } - return - } - if len(out) != 1 { - t.Fatalf("expected 1 rec; got %d", len(out)) - } - if out[0].Count != tt.wantCount { - t.Errorf("Count: got %d, want %d", out[0].Count, tt.wantCount) - } - if tt.wantProjUtil > 0 { - if math.Abs(out[0].ProjectedUtilization-tt.wantProjUtil) > 0.01 { - t.Errorf("ProjectedUtilization: got %.4f, want %.4f", - out[0].ProjectedUtilization, tt.wantProjUtil) - } - } - // Zero means "don't assert" (matches the wantProjUtil convention) - // since the pass-through path leaves ProjectedCoverage at zero. - if tt.wantProjCovGTE > 0 { - if out[0].ProjectedCoverage < tt.wantProjCovGTE-0.01 { - t.Errorf("ProjectedCoverage: got %.4f, want >= %.4f", - out[0].ProjectedCoverage, tt.wantProjCovGTE) - } - } - }) - } -} - -// TestApplyTargetCoverage_RI_CostScaling verifies RI cost-bearing fields -// scale by the sized-to-original count ratio. SavingsPercentage is invariant. -// The scaled values let downstream consumers (CSV writer, reporter, audit -// log) trust rec.CommitmentCost / rec.EstimatedSavings as the sized purchase -// rather than AWS's pre-sized proposal. -func TestApplyTargetCoverage_RI_CostScaling(t *testing.T) { - rec := common.Recommendation{ - Service: common.ServiceEC2, - Count: 10, - CommitmentType: common.CommitmentReservedInstance, - CommitmentCost: 1000, - OnDemandCost: 2000, - EstimatedSavings: 500, - SavingsPercentage: 25, - ExistingCoverageKnown: true, - AverageInstancesUsedPerHour: 8, - } - // target=80, existing=0, avg=8 → gap=80. - // n = floor(8 * 80/100) = 6. Ratio = 6/10 = 0.6 (cost scaling still - // uses rec.Count to convert AWS's quoted cost-for-rec.Count into - // cost-for-nTarget). - out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, nil) - require.Len(t, out, 1) - assert.Equal(t, 6, out[0].Count) - assert.InDelta(t, 600.0, out[0].CommitmentCost, 0.001, "CommitmentCost scales by nTarget/rec.Count") - assert.InDelta(t, 1200.0, out[0].OnDemandCost, 0.001, "OnDemandCost scales by nTarget/rec.Count") - assert.InDelta(t, 300.0, out[0].EstimatedSavings, 0.001, "EstimatedSavings scales by nTarget/rec.Count") - assert.Equal(t, 25.0, out[0].SavingsPercentage, "SavingsPercentage is invariant under count scaling") - - t.Run("RecurringMonthlyCost scales by ratio when populated", func(t *testing.T) { - // AWS populated RecurringStandardMonthlyCost for partial/no-upfront - // recs; sized purchase must scale this monthly fee by the same - // nTarget/rec.Count ratio so total cost (upfront + monthly × term) - // reflects what the user actually buys. - monthly := 50.0 - recWithMonthly := rec - recWithMonthly.RecurringMonthlyCost = &monthly - out := ApplyTargetCoverage([]common.Recommendation{recWithMonthly}, 80, nil) - require.Len(t, out, 1) - require.NotNil(t, out[0].RecurringMonthlyCost, "scaled pointer should be non-nil") - assert.InDelta(t, 30.0, *out[0].RecurringMonthlyCost, 0.001, "monthly cost scales by 6/10") - // Original pointer untouched (we allocated a new one). - assert.Equal(t, 50.0, monthly, "original RecurringMonthlyCost target should not be mutated") - }) - - t.Run("RecurringMonthlyCost stays nil when not populated", func(t *testing.T) { - // AWS API didn't return RecurringStandardMonthlyCost (all-upfront, - // or field missing). The sized rec should also have nil so - // downstream renders "unknown" rather than zero. - out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, nil) - require.Len(t, out, 1) - assert.Nil(t, out[0].RecurringMonthlyCost, "nil input → nil output") - }) -} - -// TestApplyTargetCoverage_RI_ExistingCoverage covers the under-buy formula's -// existing-commitment branch: gap = (target - existing_cov)/100, then -// n_target = floor(avg * gap). Matches the worked example from the #338 design -// thread (20 instances, 10 existing RIs at 50% coverage, target 80% → buy 6). -func TestApplyTargetCoverage_RI_ExistingCoverage(t *testing.T) { - mkRI := func(count int, avg, existingCov float64) common.Recommendation { - return common.Recommendation{ - Service: common.ServiceEC2, - Region: "us-east-1", - ResourceType: "t3.medium", - Count: count, - RecommendedCount: count, - CommitmentType: common.CommitmentReservedInstance, - CommitmentCost: 1000, - OnDemandCost: 2000, - EstimatedSavings: 500, - ExistingCoverageKnown: true, - AverageInstancesUsedPerHour: avg, - ExistingCoveragePct: existingCov, - } - } - - tests := []struct { - name string - rec common.Recommendation - target float64 - wantDropped bool - wantCount int - wantTotalCov float64 // ProjectedCoverage = existing + new contribution - }{ - { - // User's worked example: avg=20, existing=50%, target=80%. - // gap=30. n=ceil(20*0.30)=6. Total cov = 50 + 6/20*100 = 80. - name: "User example: 50% existing, 80% target on avg=20 → buy 6", - rec: mkRI(10, 20, 50), - target: 80, - wantCount: 6, - wantTotalCov: 80, - }, - { - // existing=0%, target=70%, avg=10. gap=70. n=ceil(7)=7. - name: "Zero existing: ceil(avg*target/100)", - rec: mkRI(10, 10, 0), - target: 70, - wantCount: 7, - wantTotalCov: 70, - }, - { - // existing=80% meets target=80% → drop. - name: "Existing meets target exactly: drop", - rec: mkRI(10, 10, 80), - target: 80, - wantDropped: true, - }, - { - // existing=95% exceeds target=80% → drop. - name: "Existing exceeds target: drop", - rec: mkRI(10, 10, 95), - target: 80, - wantDropped: true, - }, - { - // avg=2, existing=70%, target=80%. gap=10. - // n = floor(2 * 10/100) = floor(0.2) = 0 → DROPPED. - // Small pool + thin gap: no integer buy can approximate the - // target. --min-pool-size upstream is the intended filter. - name: "Small gap on tiny avg drops", - rec: mkRI(5, 2, 70), - target: 80, - wantDropped: true, - }, - { - // avg=10, existing=60%, target=70%. gap=10. - // n = floor(10 * 10/100) = 1. Total cov = 60 + 1/10*100 = 70. - // Coverage-anchored: 1 RI exactly closes the 10-point gap - // because avg=10 and each RI is worth 10% of avg demand. - name: "Small top-up: 1 RI exactly closes 10-pt gap on avg=10", - rec: mkRI(10, 10, 60), - target: 70, - wantCount: 1, - wantTotalCov: 70, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - out := ApplyTargetCoverage([]common.Recommendation{tt.rec}, tt.target, nil) - if tt.wantDropped { - assert.Len(t, out, 0, "expected drop") - return - } - require.Len(t, out, 1) - assert.Equal(t, tt.wantCount, out[0].Count, "Count") - assert.InDelta(t, tt.wantTotalCov, out[0].ProjectedCoverage, 0.01, "ProjectedCoverage is TOTAL (existing + new)") - }) - } -} - -// TestApplyTargetCoverage_SP covers the SP sizing branch under under-buy -// semantics: HourlyCommitment and EstimatedSavings scale by targetPct/100 -// regardless of AWS's projected utilization. CommitmentCost / OnDemandCost / -// SavingsPercentage must NOT change. -func TestApplyTargetCoverage_SP(t *testing.T) { - mkSP := func(recUtil float64) common.Recommendation { - return common.Recommendation{ - Service: common.ServiceSavingsPlansCompute, - CommitmentType: common.CommitmentSavingsPlan, - CommitmentCost: 1000, - OnDemandCost: 5000, - EstimatedSavings: 1500, - SavingsPercentage: 30, - RecommendedUtilization: recUtil, - Details: &common.SavingsPlanDetails{HourlyCommitment: 2.0}, - } - } - - t.Run("AWS above target — still scales by target (under-buy)", func(t *testing.T) { - // RecUtil=95, target=80. Even though AWS projects above target, the - // flag's intent is "leave 20% headroom", so the commitment shrinks - // to 80% of AWS rec. All cost-bearing fields scale by 0.8. - // Projected util = 95/0.80 = 118.75 clamped to 100. - out := ApplyTargetCoverage([]common.Recommendation{mkSP(95)}, 80, nil) - require.Len(t, out, 1) - assert.InDelta(t, 1.6, out[0].Details.(*common.SavingsPlanDetails).HourlyCommitment, 0.001) - assert.InDelta(t, 800.0, out[0].CommitmentCost, 0.001, "CommitmentCost scales by target/100") - assert.InDelta(t, 4000.0, out[0].OnDemandCost, 0.001, "OnDemandCost scales by target/100") - assert.InDelta(t, 1200.0, out[0].EstimatedSavings, 0.001) - assert.Equal(t, 30.0, out[0].SavingsPercentage, "SavingsPercentage is invariant") - assert.InDelta(t, 100.0, out[0].ProjectedUtilization, 0.001, "RecUtil/ratio = 95/0.80 = 118.75 clamps to 100") - assert.Equal(t, 0.0, out[0].ProjectedCoverage, "SPs intentionally leave ProjectedCoverage at zero") - }) - - t.Run("AWS below target — scale down by target (under-buy)", func(t *testing.T) { - // RecUtil=50, target=80. All cost-bearing fields shrink to 80%. - // Projected util = 50/0.80 = 62.5 (no clamp needed). - out := ApplyTargetCoverage([]common.Recommendation{mkSP(50)}, 80, nil) - require.Len(t, out, 1) - details := out[0].Details.(*common.SavingsPlanDetails) - assert.InDelta(t, 1.6, details.HourlyCommitment, 0.001) - assert.InDelta(t, 800.0, out[0].CommitmentCost, 0.001, "CommitmentCost scales by target/100") - assert.InDelta(t, 4000.0, out[0].OnDemandCost, 0.001, "OnDemandCost scales by target/100") - assert.InDelta(t, 1200.0, out[0].EstimatedSavings, 0.001) - assert.Equal(t, 30.0, out[0].SavingsPercentage, "SavingsPercentage is invariant") - assert.InDelta(t, 62.5, out[0].ProjectedUtilization, 0.001, "RecUtil/ratio = 50/0.80 = 62.5") - assert.Equal(t, 0.0, out[0].ProjectedCoverage) - }) - - t.Run("no signal → passed through unchanged", func(t *testing.T) { - out := ApplyTargetCoverage([]common.Recommendation{mkSP(0)}, 80, nil) - require.Len(t, out, 1) - // Original recommendation values intact. - assert.Equal(t, 2.0, out[0].Details.(*common.SavingsPlanDetails).HourlyCommitment) - assert.Equal(t, 1500.0, out[0].EstimatedSavings) - assert.Equal(t, 0.0, out[0].ProjectedUtilization) - }) -} - -// TestApplySizing checks the routing helper picks the right sizer based -// on cfg.TargetCoverage being >0 vs ==0. -func TestApplySizing(t *testing.T) { - ri := common.Recommendation{ - Service: common.ServiceEC2, - Count: 10, - CommitmentType: common.CommitmentReservedInstance, - ExistingCoverageKnown: true, - AverageInstancesUsedPerHour: 8, - } - - t.Run("TargetCoverage > 0 → ApplyTargetCoverage", func(t *testing.T) { - cfg := Config{TargetCoverage: 80, Coverage: 100} - out := applySizing([]common.Recommendation{ri}, cfg, cfg.Coverage, nil) - require.Len(t, out, 1) - // avg=8, target=80%, existing=0%. gap=80. - // n = floor(8 * 80/100) = floor(6.4) = 6. ProjUtil = 8/6 = 133% → 100. - assert.Equal(t, 6, out[0].Count) - assert.Equal(t, 100.0, out[0].ProjectedUtilization) - }) - - t.Run("TargetCoverage == 0 → ApplyCoverage", func(t *testing.T) { - cfg := Config{TargetCoverage: 0, Coverage: 50} - out := applySizing([]common.Recommendation{ri}, cfg, cfg.Coverage, nil) - require.Len(t, out, 1) - // ApplyCoverage(50) on count=10 → 5. ProjectedUtilization NOT set - // (zero) because we took the coverage branch. - assert.Equal(t, 5, out[0].Count) - assert.Equal(t, 0.0, out[0].ProjectedUtilization) - }) -} - -// TestApplyTargetCoverage_RI_Target100 covers the target == 100 boundary. -// With the coverage-anchored formula, target=100 (existing=0) yields -// n = floor(avg * 100/100) = floor(avg) — operators get a buy sized to -// the pool's average concurrent demand, not AWS's rec.Count (which may -// be sized to peak/ROI-curated). Pools where avg<1 drop; --min-pool-size -// is the intended upstream filter. -func TestApplyTargetCoverage_RI_Target100(t *testing.T) { - mkRI := func(count int, avg float64) common.Recommendation { - return common.Recommendation{ - Service: common.ServiceEC2, - Region: "us-east-1", - ResourceType: "t3.medium", - Count: count, - CommitmentType: common.CommitmentReservedInstance, - ExistingCoverageKnown: true, - AverageInstancesUsedPerHour: avg, - } - } - - tests := []struct { - name string - rec common.Recommendation - wantDropped bool - wantCount int - }{ - // avg=0.999 → floor(0.999)=0 → drop. - {name: "target 100, avg=0.999 → drop (avg<1)", rec: mkRI(5, 0.999), wantDropped: true}, - // avg=1.0 → buy 1 (matches avg demand). - {name: "target 100, avg=1 → buy 1", rec: mkRI(5, 1.0), wantCount: 1}, - // avg=8.7 → floor(8.7) = 8. - {name: "target 100, avg=8.7 → buy floor(avg)=8", rec: mkRI(10, 8.7), wantCount: 8}, - // avg=10 → buy 10 (avg=10 means 10 concurrent instances on average). - {name: "target 100, avg=10 → buy 10", rec: mkRI(10, 10.0), wantCount: 10}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - out := ApplyTargetCoverage([]common.Recommendation{tt.rec}, 100, nil) - if tt.wantDropped { - assert.Len(t, out, 0, "expected drop at target=100 for avg=%.3f", tt.rec.AverageInstancesUsedPerHour) - return - } - require.Len(t, out, 1) - assert.Equal(t, tt.wantCount, out[0].Count) - }) - } -} - -// TestApplyTargetCoverage_SP_NoSignalGuards covers the two SP no-signal -// branches: RecommendedUtilization <= 0 (already covered by other tests) and -// the new HourlyCommitment <= 0 guard (CE occasionally returns $0 -// placeholder recs). -func TestApplyTargetCoverage_SP_NoSignalGuards(t *testing.T) { - t.Run("HourlyCommitment=0 with positive RecommendedUtilization → pass through unscaled", func(t *testing.T) { - rec := common.Recommendation{ - Service: common.ServiceSavingsPlansCompute, - CommitmentType: common.CommitmentSavingsPlan, - EstimatedSavings: 1500, - RecommendedUtilization: 50, - Details: &common.SavingsPlanDetails{HourlyCommitment: 0}, - } - out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, nil) - require.Len(t, out, 1, "$0 SP rec should still be in output (pass-through)") - // Pass-through — projection fields must NOT be set, savings unchanged. - assert.Equal(t, 0.0, out[0].ProjectedUtilization, "ProjectedUtilization must NOT be set for $0-commitment pass-through") - assert.Equal(t, 1500.0, out[0].EstimatedSavings, "EstimatedSavings unchanged on pass-through") - assert.Equal(t, 0.0, out[0].Details.(*common.SavingsPlanDetails).HourlyCommitment, "HourlyCommitment unchanged") - }) - - t.Run("Details is wrong type → pass through unscaled, no projection metric set", func(t *testing.T) { - // Defensive case: SP rec with non-SP Details (a parser bug). The - // scaling can't proceed, and we MUST NOT set ProjectedUtilization - // to target% because the underlying cost fields aren't scaled — - // that would mislead the operator into thinking the rec was sized - // to the target when in fact it's the original unscaled commitment. - rec := common.Recommendation{ - Service: common.ServiceSavingsPlansCompute, - CommitmentType: common.CommitmentSavingsPlan, - EstimatedSavings: 1500, - RecommendedUtilization: 50, - Details: common.ComputeDetails{Platform: "Linux/UNIX"}, // wrong type - } - out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, nil) - require.Len(t, out, 1) - assert.Equal(t, 0.0, out[0].ProjectedUtilization, "must NOT set projection when scaling failed") - assert.Equal(t, 1500.0, out[0].EstimatedSavings, "EstimatedSavings must remain unscaled when scaling failed") - }) -} - -// TestApplyTargetCoverage_DropTargetAlreadyMet verifies that when existing -// coverage already meets or exceeds the target, the recommendation is -// dropped and the drop is recorded in a non-nil DropSummary under the -// DropTargetAlreadyMet category. If the drops.Add call for that branch -// were removed, d.Total() would stay at 0 and the assertion below would fail. -func TestApplyTargetCoverage_DropTargetAlreadyMet(t *testing.T) { - // ExistingCoveragePct=90 >= target=80: gapPct=80-90=-10 <= 0 -> drop. - rec := common.Recommendation{ - Service: common.ServiceEC2, - Region: "us-east-1", - ResourceType: "t3.medium", - Count: 5, - CommitmentType: common.CommitmentReservedInstance, - ExistingCoverageKnown: true, - AverageInstancesUsedPerHour: 8.0, - ExistingCoveragePct: 90.0, - } - - d := common.NewDropSummary() - out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, d) - - assert.Empty(t, out, "already-covered rec should be dropped") - assert.Equal(t, 1, d.Total(), "drop summary should record 1 drop") - assert.Contains(t, d.FormatOneLine(), common.DropTargetAlreadyMet, - "drop summary should name the target-already-met category") -} - -// TestApplyTargetCoverage_DropTargetSizedToZero verifies that when the -// floor(avg * gapPct / 100) formula produces 0, the recommendation is -// dropped and the drop is recorded in a non-nil DropSummary under the -// DropTargetSizedToZero category. If the drops.Add call for that branch -// were removed, d.Total() would stay at 0 and the assertion below would fail. -func TestApplyTargetCoverage_DropTargetSizedToZero(t *testing.T) { - // avg=0.4, target=80%, existing=0%: floor(0.4 * 80/100) = floor(0.32) = 0 -> drop. - rec := common.Recommendation{ - Service: common.ServiceEC2, - Region: "us-east-1", - ResourceType: "t3.micro", - Count: 1, - CommitmentType: common.CommitmentReservedInstance, - ExistingCoverageKnown: true, - AverageInstancesUsedPerHour: 0.4, - ExistingCoveragePct: 0.0, - } - - d := common.NewDropSummary() - out := ApplyTargetCoverage([]common.Recommendation{rec}, 80, d) - - assert.Empty(t, out, "floor-to-zero rec should be dropped") - assert.Equal(t, 1, d.Total(), "drop summary should record 1 drop") - assert.Contains(t, d.FormatOneLine(), common.DropTargetSizedToZero, - "drop summary should name the target-sized-to-zero category") -} - func TestResolveAccountName_LookupErrorIsReturnedAndRememberedForTheRun(t *testing.T) { ctx := context.Background() org := &MockOrganizationsClient{} diff --git a/cmd/main_flags_validation_test.go b/cmd/main_flags_validation_test.go new file mode 100644 index 000000000..3ea48c72a --- /dev/null +++ b/cmd/main_flags_validation_test.go @@ -0,0 +1,156 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFilterFlagValidation(t *testing.T) { + // Save original toolCfg values + origCfg := toolCfg + + defer func() { + toolCfg = origCfg + }() + + tests := []struct { + name string + errorContains string + includeRegions []string + excludeRegions []string + includeInstanceTypes []string + excludeInstanceTypes []string + expectError bool + }{ + { + name: "No conflicts", + includeRegions: []string{"us-east-1"}, + excludeRegions: []string{"us-west-2"}, + includeInstanceTypes: []string{"db.t3.micro"}, + excludeInstanceTypes: []string{"db.t3.large"}, + expectError: false, + }, + { + name: "Region conflict", + includeRegions: []string{"us-east-1", "us-west-2"}, + excludeRegions: []string{"us-west-2"}, + includeInstanceTypes: []string{}, + excludeInstanceTypes: []string{}, + expectError: true, + errorContains: "region 'us-west-2' cannot be both included and excluded", + }, + { + name: "Instance type conflict", + includeRegions: []string{}, + excludeRegions: []string{}, + includeInstanceTypes: []string{"db.t3.small"}, + excludeInstanceTypes: []string{"db.t3.small"}, + expectError: true, + errorContains: "instance type 'db.t3.small' cannot be both included and excluded", + }, + { + name: "Empty filters valid", + includeRegions: []string{}, + excludeRegions: []string{}, + includeInstanceTypes: []string{}, + excludeInstanceTypes: []string{}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set test values in toolCfg + toolCfg.IncludeRegions = tt.includeRegions + toolCfg.ExcludeRegions = tt.excludeRegions + toolCfg.IncludeInstanceTypes = tt.includeInstanceTypes + toolCfg.ExcludeInstanceTypes = tt.excludeInstanceTypes + toolCfg.Coverage = 80.0 + toolCfg.PaymentOption = "no-upfront" + toolCfg.TermYears = 3 + + // Call validateFlags + err := validateFlags(nil, nil) + + if tt.expectError { + assert.Error(t, err) + if err != nil && tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestValidateFlags(t *testing.T) { + tests := []struct { + name string + setPayment string + setCoverage float64 + setTerm int + expectError bool + }{ + { + name: "Valid flags", + setCoverage: 80.0, + setTerm: 1, + setPayment: "partial-upfront", + expectError: false, + }, + { + name: "Coverage too high", + setCoverage: 150.0, + setTerm: 1, + setPayment: "partial-upfront", + expectError: true, + }, + { + name: "Coverage negative", + setCoverage: -10.0, + setTerm: 1, + setPayment: "partial-upfront", + expectError: true, + }, + { + name: "Invalid term", + setCoverage: 80.0, + setTerm: 2, + setPayment: "partial-upfront", + expectError: true, + }, + { + name: "Invalid payment option", + setCoverage: 80.0, + setTerm: 1, + setPayment: "invalid", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save original values + origCfg := toolCfg + + // Set test values + toolCfg.Coverage = tt.setCoverage + toolCfg.TermYears = tt.setTerm + toolCfg.PaymentOption = tt.setPayment + + // Call validateFlags + err := validateFlags(nil, []string{}) + + // Restore original values + toolCfg = origCfg + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/cmd/main_parse_services_test.go b/cmd/main_parse_services_test.go new file mode 100644 index 000000000..21771dfa3 --- /dev/null +++ b/cmd/main_parse_services_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestParseServices(t *testing.T) { + tests := []struct { + name string + input []string + expected []common.ServiceType + errorContains string + }{ + { + name: "Valid services", + input: []string{"rds", "elasticache", "ec2"}, + expected: []common.ServiceType{ + common.ServiceRDS, + common.ServiceElastiCache, + common.ServiceEC2, + }, + }, + { + name: "Mixed case services", + input: []string{"RDS", "ElastiCache", "EC2"}, + expected: []common.ServiceType{ + common.ServiceRDS, + common.ServiceElastiCache, + common.ServiceEC2, + }, + }, + { + name: "Invalid services", + errorContains: "invalid", + input: []string{"invalid", "unknown"}, + expected: nil, + }, + { + name: "Mix of valid and invalid", + input: []string{"rds", "invalid", "ec2"}, + errorContains: "invalid", + expected: nil, + }, + { + name: "All supported services", + input: []string{"rds", "elasticache", "ec2", "opensearch", "redshift", "memorydb"}, + expected: []common.ServiceType{ + common.ServiceRDS, + common.ServiceElastiCache, + common.ServiceEC2, + common.ServiceOpenSearch, + common.ServiceRedshift, + common.ServiceMemoryDB, + }, + }, + { + name: "Legacy elasticsearch alias", + input: []string{"elasticsearch"}, + expected: []common.ServiceType{ + common.ServiceElasticsearch, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := parseServices(tt.input) + if tt.errorContains != "" { + assert.ErrorContains(t, err, tt.errorContains) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetAllServices(t *testing.T) { + services := getAllServices() + + expected := []common.ServiceType{ + common.ServiceRDS, + common.ServiceElastiCache, + common.ServiceEC2, + common.ServiceOpenSearch, + common.ServiceRedshift, + common.ServiceMemoryDB, + common.ServiceSavingsPlansCompute, + common.ServiceSavingsPlansEC2Instance, + common.ServiceSavingsPlansSageMaker, + common.ServiceSavingsPlansDatabase, + } + + assert.Equal(t, expected, services) +} + +func TestParseServicesWithEmptyAndNil(t *testing.T) { + for _, input := range [][]string{nil, {}} { + result, err := parseServices(input) + assert.NoError(t, err) + assert.Nil(t, result) + } + for _, input := range [][]string{{"", "rds", ""}, {"rds", ""}, {"foo", "bar", "baz"}, {" rds"}} { + result, err := parseServices(input) + assert.Error(t, err) + assert.Nil(t, result) + } +} + +func TestParseServicesSavingsPlanAliases(t *testing.T) { + expected := []common.ServiceType{common.ServiceSavingsPlansCompute, common.ServiceSavingsPlansEC2Instance, common.ServiceSavingsPlansSageMaker, common.ServiceSavingsPlansDatabase} + for _, alias := range []string{"savingsplans", "savings-plans", "SP"} { + result, err := parseServices([]string{"ec2", alias, "savingsplans-compute", "rds", "EC2"}) + assert.NoError(t, err) + assert.Equal(t, append(append([]common.ServiceType{common.ServiceEC2}, expected...), common.ServiceRDS), result) + } + for i, name := range []string{"compute", "ec2instance", "sagemaker", "database"} { + result, err := parseServices([]string{"savingsplans-" + name, "savings-plans-" + name}) + assert.NoError(t, err) + assert.Equal(t, []common.ServiceType{expected[i]}, result) + } +} diff --git a/cmd/main_purchase_id_comprehensive_test.go b/cmd/main_purchase_id_comprehensive_test.go new file mode 100644 index 000000000..7381a049b --- /dev/null +++ b/cmd/main_purchase_id_comprehensive_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestGeneratePurchaseIDComprehensive(t *testing.T) { + // Use a test coverage value + testCoverage := 75.0 + + tests := []struct { + name string + region string + rec common.Recommendation + expectedContains []string + expectedNotContains []string + isDryRun bool + }{ + { + name: "RDS with account name and engine", + rec: common.Recommendation{ + Service: common.ServiceRDS, + ResourceType: "db.r5.large", + Count: 3, + AccountName: "Production Account", + Details: &common.DatabaseDetails{ + Engine: "PostgreSQL", + }, + }, + region: "eu-west-1", + isDryRun: false, + expectedContains: []string{ + "ri-", "production-account", "rds", "postgresql", "eu-west-1", + "db-r5-large", "3x", "75pct", + }, + }, + { + name: "ElastiCache with Redis engine", + rec: common.Recommendation{ + Service: common.ServiceElastiCache, + ResourceType: "cache.r5.xlarge", + Count: 5, + Details: &common.CacheDetails{ + Engine: "Redis", + }, + }, + region: "us-west-2", + isDryRun: false, + expectedContains: []string{ + "ri-", "elasticache", "redis", "us-west-2", + "cache-r5-xlarge", "5x", "75pct", + }, + }, + { + name: "EC2 with platform", + rec: common.Recommendation{ + Service: common.ServiceEC2, + ResourceType: "m5.2xlarge", + Count: 10, + Details: common.ComputeDetails{ + Platform: "Linux/UNIX", + }, + }, + region: "ap-southeast-1", + isDryRun: true, + expectedContains: []string{ + "dryrun-", "ec2", "linux-unix", "ap-southeast-1", + "m5-2xlarge", "10x", "75pct", + }, + }, + { + name: "MemoryDB recommendation", + rec: common.Recommendation{ + Service: common.ServiceMemoryDB, + ResourceType: "db.r6g.large", + Count: 2, + Details: &common.CacheDetails{Engine: "redis"}, + }, + region: "us-east-1", + isDryRun: false, + expectedContains: []string{ + "ri-", "memorydb", "memorydb", "us-east-1", + "db-r6g-large", "2x", "75pct", + }, + }, + { + name: "OpenSearch without engine", + rec: common.Recommendation{ + Service: common.ServiceOpenSearch, + ResourceType: "r5.large.search", + Count: 4, + }, + region: "eu-central-1", + isDryRun: false, + expectedContains: []string{ + "ri-", "opensearch", "eu-central-1", + "r5-large-search", "4x", "75pct", + }, + }, + { + name: "Elasticsearch alias (same as OpenSearch)", + rec: common.Recommendation{ + Service: common.ServiceElasticsearch, // Should work as alias for OpenSearch + ResourceType: "m5.xlarge.elasticsearch", + Count: 3, + }, + region: "us-west-1", + isDryRun: false, + expectedContains: []string{ + "ri-", "opensearch", "us-west-1", + "m5-xlarge-elasticsearch", "3x", "75pct", + }, + }, + { + name: "Redshift without engine", + rec: common.Recommendation{ + Service: common.ServiceRedshift, + ResourceType: "dc2.large", + Count: 8, + }, + region: "us-east-2", + isDryRun: false, + expectedContains: []string{ + "ri-", "redshift", "us-east-2", + "dc2-large", "8x", "75pct", + }, + }, + { + name: "RDS recommendation with account", + rec: common.Recommendation{ + Service: common.ServiceRDS, + ResourceType: "db.r6g.xlarge", + Count: 15, + AccountName: "Staging", + Details: &common.DatabaseDetails{ + Engine: "aurora-mysql", + AZConfig: "multi-az", + }, + }, + region: "ca-central-1", + isDryRun: false, + expectedContains: []string{ + "ri-", "staging", "aurora-mysql", "r6g-xlarge", + "15x", "75pct", "ca-central-1", + }, + }, + { + name: "ElastiCache single-AZ recommendation", + rec: common.Recommendation{ + Service: common.ServiceElastiCache, + ResourceType: "cache.m5.large", + Count: 1, + Details: &common.CacheDetails{ + Engine: "redis", + }, + }, + region: "ap-northeast-1", + isDryRun: true, + expectedContains: []string{ + "dryrun-", "redis", "m5-large", + "1x", "75pct", "ap-northeast-1", + }, + }, + { + name: "Recommendation with special characters in engine", + rec: common.Recommendation{ + Service: common.ServiceRDS, + ResourceType: "db.t3.micro", + Count: 20, + Details: &common.DatabaseDetails{ + Engine: "MySQL_8.0_Community", + }, + }, + region: "us-west-1", + isDryRun: false, + expectedContains: []string{ + "ri-", "rds", "mysql-8.0-community", + "db-t3-micro", "20x", "75pct", + }, + }, + { + name: "Large count recommendation", + rec: common.Recommendation{ + Service: common.ServiceEC2, + ResourceType: "t3.nano", + Count: 999, + }, + region: "eu-west-2", + isDryRun: false, + expectedContains: []string{ + "ri-", "ec2", "eu-west-2", + "t3-nano", "999x", "75pct", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generatePurchaseID(tt.rec, tt.region, 1, tt.isDryRun, testCoverage) + + // Check expected contains + for _, expected := range tt.expectedContains { + assert.Contains(t, result, expected, "Expected ID to contain '%s'", expected) + } + + // Check expected not contains + for _, notExpected := range tt.expectedNotContains { + assert.NotContains(t, result, notExpected, "Expected ID not to contain '%s'", notExpected) + } + + // Should always contain timestamp and UUID + assert.Regexp(t, `-\d{8}-\d{6}-[a-f0-9]{8}$`, result) + }) + } +} diff --git a/cmd/main_purchase_id_test.go b/cmd/main_purchase_id_test.go new file mode 100644 index 000000000..b2411473a --- /dev/null +++ b/cmd/main_purchase_id_test.go @@ -0,0 +1,231 @@ +package main + +import ( + "fmt" + "strings" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestGeneratePurchaseID(t *testing.T) { + tests := []struct { + name string + region string + expectedPrefix string + rec common.Recommendation + index int + coverage float64 + isDryRun bool + }{ + { + name: "RDS Recommendation - dry run", + rec: common.Recommendation{ + Service: common.ServiceRDS, + ResourceType: "db.t3.micro", + Count: 2, + Details: &common.DatabaseDetails{ + Engine: "mysql", + AZConfig: "single-az", + }, + }, + region: "us-east-1", + index: 1, + isDryRun: true, + coverage: 80.0, + expectedPrefix: "dryrun-rds-mysql-us-east-1-db-t3-micro-2x", + }, + { + name: "EC2 Recommendation - actual purchase", + rec: common.Recommendation{ + Service: common.ServiceEC2, + ResourceType: "t3.large", + Count: 5, + }, + region: "eu-west-1", + index: 3, + isDryRun: false, + coverage: 80.0, + expectedPrefix: "ri-ec2-eu-west-1-t3-large-5x", + }, + { + name: "ElastiCache Recommendation - dry run", + rec: common.Recommendation{ + Service: common.ServiceElastiCache, + ResourceType: "cache.r5.large", + Count: 1, + Details: &common.CacheDetails{ + Engine: "redis", + }, + }, + region: "us-west-2", + index: 2, + isDryRun: true, + coverage: 80.0, + expectedPrefix: "dryrun-elasticache-redis-us-west-2-cache-r5-large-1x", + }, + { + name: "RDS Recommendation - multi-AZ", + rec: common.Recommendation{ + Service: common.ServiceRDS, + ResourceType: "db.m5.xlarge", + Count: 3, + Details: &common.DatabaseDetails{ + Engine: "postgres", + AZConfig: "multi-az", + }, + }, + region: "ap-southeast-1", + index: 5, + isDryRun: false, + coverage: 80.0, + expectedPrefix: "ri-rds-postgres-ap-southeast-1-db-m5-xlarge-3x", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generatePurchaseID(tt.rec, tt.region, tt.index, tt.isDryRun, tt.coverage) + assert.Contains(t, result, tt.expectedPrefix) + // Should contain timestamp (YYYYMMDD-HHMMSS) and UUID suffix (8 chars) + assert.Regexp(t, `-\d{8}-\d{6}-[a-f0-9]{8}$`, result) + }) + } +} + +func TestGeneratePurchaseIDEdgeCases(t *testing.T) { + testCoverage := 80.0 + + // Test with recommendations that have special characters + rec := common.Recommendation{ + Service: common.ServiceRDS, + ResourceType: "db.r5b.2xlarge", + Count: 10, + Details: &common.DatabaseDetails{ + Engine: "MySQL 8.0", + AZConfig: "single-az", + }, + } + + id := generatePurchaseID(rec, "us-east-1", 999, false, testCoverage) + assert.Contains(t, id, "rds") + assert.Contains(t, id, "r5b-2xlarge") + assert.Contains(t, id, "10x") + // Index is no longer included due to UUID replacement + + // Test with empty region + id = generatePurchaseID(rec, "", 1, true, testCoverage) + assert.Contains(t, id, "dryrun") + + // Test with very long instance type + rec.ResourceType = "db.x2gd.metal.16xlarge" + id = generatePurchaseID(rec, "ap-south-1", 1, false, testCoverage) + assert.Contains(t, id, "x2gd-metal") +} + +func TestGeneratePurchaseIDCoverageVariations(t *testing.T) { + rec := common.Recommendation{ + Service: common.ServiceRDS, + ResourceType: "db.t3.small", + Count: 1, + Details: &common.DatabaseDetails{ + Engine: "mysql", + }, + } + + tests := []struct { + name string + expectedCoverage string + coverage float64 + }{ + {"Coverage 0%", "0pct", 0.0}, + {"Coverage 50%", "50pct", 50.0}, + {"Coverage 75.5%", "76pct", 75.5}, // Rounds to nearest integer + {"Coverage 99%", "99pct", 99.0}, + {"Coverage 100%", "100pct", 100.0}, + {"Coverage 33.3%", "33pct", 33.3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generatePurchaseID(rec, "us-east-1", 1, false, tt.coverage) + assert.Contains(t, result, tt.expectedCoverage) + }) + } +} + +func TestGeneratePurchaseID_EdgeCases(t *testing.T) { + tests := []struct { + name string + region string + rec common.Recommendation + index int + isDryRun bool + }{ + { + name: "RDS dry run", + rec: common.Recommendation{ + Service: common.ServiceRDS, + ResourceType: "db.t3.micro", + Count: 2, + }, + region: "us-east-1", + index: 1, + isDryRun: true, + }, + { + name: "EC2 actual purchase", + rec: common.Recommendation{ + Service: common.ServiceEC2, + ResourceType: "t3.large", + Count: 5, + }, + region: "eu-west-1", + index: 99, + isDryRun: false, + }, + { + name: "ElastiCache with dots in instance type", + rec: common.Recommendation{ + Service: common.ServiceElastiCache, + ResourceType: "cache.r6g.2xlarge", + Count: 1, + }, + region: "ap-southeast-1", + index: 1000, + isDryRun: false, + }, + { + name: "Unknown service", + rec: common.Recommendation{ + Service: common.ServiceType("future-service"), + ResourceType: "unknown.large", + Count: 10, + }, + region: "us-west-2", + index: 1, + isDryRun: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + testCoverage := 80.0 + id := generatePurchaseID(tt.rec, tt.region, tt.index, tt.isDryRun, testCoverage) + + // Verify ID contains expected parts + if tt.isDryRun { + assert.Contains(t, id, "dryrun") + } else { + assert.Contains(t, id, "ri") + } + + assert.Contains(t, id, tt.region) + assert.Contains(t, id, strings.ReplaceAll(tt.rec.ResourceType, ".", "-")) + assert.Contains(t, id, fmt.Sprintf("%dx", tt.rec.Count)) + // Should contain timestamp (YYYYMMDD-HHMMSS) and UUID suffix (8 chars) + assert.Regexp(t, `-\d{8}-\d{6}-[a-f0-9]{8}$`, id) + }) + } +} diff --git a/cmd/main_service_client_test.go b/cmd/main_service_client_test.go new file mode 100644 index 000000000..3df913c49 --- /dev/null +++ b/cmd/main_service_client_test.go @@ -0,0 +1,199 @@ +package main + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/assert" +) + +func TestCreateServiceClient(t *testing.T) { + cfg := aws.Config{ + Region: "us-east-1", + } + + tests := []struct { + name string + service common.ServiceType + expectNil bool + }{ + { + name: "RDS service", + service: common.ServiceRDS, + expectNil: false, + }, + { + name: "ElastiCache service", + service: common.ServiceElastiCache, + expectNil: false, + }, + { + name: "EC2 service", + service: common.ServiceEC2, + expectNil: false, + }, + { + name: "OpenSearch service", + service: common.ServiceOpenSearch, + expectNil: false, + }, + { + name: "Redshift service", + service: common.ServiceRedshift, + expectNil: false, + }, + { + name: "MemoryDB service", + service: common.ServiceMemoryDB, + expectNil: false, + }, + { + name: "Compute Savings Plans service", + service: common.ServiceSavingsPlansCompute, + expectNil: false, + }, + { + name: "EC2 Instance Savings Plans service", + service: common.ServiceSavingsPlansEC2Instance, + expectNil: false, + }, + { + name: "SageMaker Savings Plans service", + service: common.ServiceSavingsPlansSageMaker, + expectNil: false, + }, + { + name: "Database Savings Plans service", + service: common.ServiceSavingsPlansDatabase, + expectNil: false, + }, + { + // Umbrella sentinel is no longer dispatched to a client — the + // per-plan-type slugs above carry the actual work. Keep the case + // to lock the contract: createServiceClient returns nil for the + // umbrella so the caller knows to fan out. + name: "Savings Plans umbrella sentinel (returns nil)", + service: common.ServiceSavingsPlansAll, + expectNil: true, + }, + { + name: "Unknown service", + service: common.ServiceType("unknown"), + expectNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := createServiceClient(tt.service, cfg) + if tt.expectNil { + assert.Nil(t, client) + } else { + assert.NotNil(t, client) + } + }) + } +} + +func TestCreateServiceClientAllServices(t *testing.T) { + cfg := aws.Config{ + Region: "eu-central-1", + } + + // Test that all services return non-nil clients now + services := getAllServices() + for _, service := range services { + client := createServiceClient(service, cfg) + assert.NotNil(t, client, "Service %s should have a client", service) + } +} + +func TestSanitizeAccountName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"Simple name", "production", "production"}, + {"Spaces to hyphens", "my account", "my-account"}, + {"Underscores to hyphens", "my_account", "my-account"}, + {"Uppercase to lowercase", "PRODUCTION", "production"}, + {"Special chars removed", "my@account#123", "myaccount123"}, + {"Dots to hyphens", "my.account.com", "my-account-com"}, + {"Long name preserved", "very-long-production-environment-name", "very-long-production-environment-name"}, + {"Empty string", "", ""}, + {"Only special chars", "@#$%", ""}, + {"Multiple hyphens collapsed", "my---account", "my-account"}, + {"Leading/trailing hyphens removed", "-account-", "account"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := sanitizeAccountName(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestValidateInstanceTypes(t *testing.T) { + tests := []struct { + name string + errorContains string + instanceTypes []string + expectError bool + }{ + { + name: "Empty slice is valid", + instanceTypes: []string{}, + expectError: false, + }, + { + name: "Valid instance types", + instanceTypes: []string{"db.t3.micro", "cache.r5.large", "t3.medium"}, + expectError: false, + }, + { + name: "Invalid - no dot", + instanceTypes: []string{"t3micro"}, + expectError: true, + errorContains: "invalid instance type format", + }, + { + name: "Invalid - empty string", + instanceTypes: []string{"db.t3.micro", "", "cache.r5.large"}, + expectError: true, + errorContains: "empty instance type", + }, + { + name: "Valid with multiple dots", + instanceTypes: []string{"r5.xlarge.search", "db.r6g.2xlarge"}, + expectError: false, + }, + { + name: "Single valid instance type", + instanceTypes: []string{"m5.large"}, + expectError: false, + }, + { + name: "Mix of valid and invalid", + instanceTypes: []string{"db.t3.small", "invalidtype"}, + expectError: true, + errorContains: "invalid instance type format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateInstanceTypes(tt.instanceTypes) + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/cmd/main_test.go b/cmd/main_test.go index fd18d0b3b..f0c738c82 100644 --- a/cmd/main_test.go +++ b/cmd/main_test.go @@ -5,10 +5,8 @@ import ( "fmt" "os" "path/filepath" - "strings" "testing" - "github.com/LeanerCloud/cloud-commitments-go/pkg/common" "github.com/aws/aws-sdk-go-v2/aws" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -70,269 +68,6 @@ func TestMain(m *testing.M) { os.Exit(code) } -func TestParseServices(t *testing.T) { - tests := []struct { - name string - input []string - expected []common.ServiceType - errorContains string - }{ - { - name: "Valid services", - input: []string{"rds", "elasticache", "ec2"}, - expected: []common.ServiceType{ - common.ServiceRDS, - common.ServiceElastiCache, - common.ServiceEC2, - }, - }, - { - name: "Mixed case services", - input: []string{"RDS", "ElastiCache", "EC2"}, - expected: []common.ServiceType{ - common.ServiceRDS, - common.ServiceElastiCache, - common.ServiceEC2, - }, - }, - { - name: "Invalid services", - errorContains: "invalid", - input: []string{"invalid", "unknown"}, - expected: nil, - }, - { - name: "Mix of valid and invalid", - input: []string{"rds", "invalid", "ec2"}, - errorContains: "invalid", - expected: nil, - }, - { - name: "All supported services", - input: []string{"rds", "elasticache", "ec2", "opensearch", "redshift", "memorydb"}, - expected: []common.ServiceType{ - common.ServiceRDS, - common.ServiceElastiCache, - common.ServiceEC2, - common.ServiceOpenSearch, - common.ServiceRedshift, - common.ServiceMemoryDB, - }, - }, - { - name: "Legacy elasticsearch alias", - input: []string{"elasticsearch"}, - expected: []common.ServiceType{ - common.ServiceElasticsearch, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := parseServices(tt.input) - if tt.errorContains != "" { - assert.ErrorContains(t, err, tt.errorContains) - } else { - assert.NoError(t, err) - } - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestGetAllServices(t *testing.T) { - services := getAllServices() - - expected := []common.ServiceType{ - common.ServiceRDS, - common.ServiceElastiCache, - common.ServiceEC2, - common.ServiceOpenSearch, - common.ServiceRedshift, - common.ServiceMemoryDB, - common.ServiceSavingsPlansCompute, - common.ServiceSavingsPlansEC2Instance, - common.ServiceSavingsPlansSageMaker, - common.ServiceSavingsPlansDatabase, - } - - assert.Equal(t, expected, services) -} - -func TestGeneratePurchaseID(t *testing.T) { - tests := []struct { - name string - region string - expectedPrefix string - rec common.Recommendation - index int - coverage float64 - isDryRun bool - }{ - { - name: "RDS Recommendation - dry run", - rec: common.Recommendation{ - Service: common.ServiceRDS, - ResourceType: "db.t3.micro", - Count: 2, - Details: &common.DatabaseDetails{ - Engine: "mysql", - AZConfig: "single-az", - }, - }, - region: "us-east-1", - index: 1, - isDryRun: true, - coverage: 80.0, - expectedPrefix: "dryrun-rds-mysql-us-east-1-db-t3-micro-2x", - }, - { - name: "EC2 Recommendation - actual purchase", - rec: common.Recommendation{ - Service: common.ServiceEC2, - ResourceType: "t3.large", - Count: 5, - }, - region: "eu-west-1", - index: 3, - isDryRun: false, - coverage: 80.0, - expectedPrefix: "ri-ec2-eu-west-1-t3-large-5x", - }, - { - name: "ElastiCache Recommendation - dry run", - rec: common.Recommendation{ - Service: common.ServiceElastiCache, - ResourceType: "cache.r5.large", - Count: 1, - Details: &common.CacheDetails{ - Engine: "redis", - }, - }, - region: "us-west-2", - index: 2, - isDryRun: true, - coverage: 80.0, - expectedPrefix: "dryrun-elasticache-redis-us-west-2-cache-r5-large-1x", - }, - { - name: "RDS Recommendation - multi-AZ", - rec: common.Recommendation{ - Service: common.ServiceRDS, - ResourceType: "db.m5.xlarge", - Count: 3, - Details: &common.DatabaseDetails{ - Engine: "postgres", - AZConfig: "multi-az", - }, - }, - region: "ap-southeast-1", - index: 5, - isDryRun: false, - coverage: 80.0, - expectedPrefix: "ri-rds-postgres-ap-southeast-1-db-m5-xlarge-3x", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := generatePurchaseID(tt.rec, tt.region, tt.index, tt.isDryRun, tt.coverage) - assert.Contains(t, result, tt.expectedPrefix) - // Should contain timestamp (YYYYMMDD-HHMMSS) and UUID suffix (8 chars) - assert.Regexp(t, `-\d{8}-\d{6}-[a-f0-9]{8}$`, result) - }) - } -} - -func TestCreateServiceClient(t *testing.T) { - cfg := aws.Config{ - Region: "us-east-1", - } - - tests := []struct { - name string - service common.ServiceType - expectNil bool - }{ - { - name: "RDS service", - service: common.ServiceRDS, - expectNil: false, - }, - { - name: "ElastiCache service", - service: common.ServiceElastiCache, - expectNil: false, - }, - { - name: "EC2 service", - service: common.ServiceEC2, - expectNil: false, - }, - { - name: "OpenSearch service", - service: common.ServiceOpenSearch, - expectNil: false, - }, - { - name: "Redshift service", - service: common.ServiceRedshift, - expectNil: false, - }, - { - name: "MemoryDB service", - service: common.ServiceMemoryDB, - expectNil: false, - }, - { - name: "Compute Savings Plans service", - service: common.ServiceSavingsPlansCompute, - expectNil: false, - }, - { - name: "EC2 Instance Savings Plans service", - service: common.ServiceSavingsPlansEC2Instance, - expectNil: false, - }, - { - name: "SageMaker Savings Plans service", - service: common.ServiceSavingsPlansSageMaker, - expectNil: false, - }, - { - name: "Database Savings Plans service", - service: common.ServiceSavingsPlansDatabase, - expectNil: false, - }, - { - // Umbrella sentinel is no longer dispatched to a client — the - // per-plan-type slugs above carry the actual work. Keep the case - // to lock the contract: createServiceClient returns nil for the - // umbrella so the caller knows to fan out. - name: "Savings Plans umbrella sentinel (returns nil)", - service: common.ServiceSavingsPlansAll, - expectNil: true, - }, - { - name: "Unknown service", - service: common.ServiceType("unknown"), - expectNil: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - client := createServiceClient(tt.service, cfg) - if tt.expectNil { - assert.Nil(t, client) - } else { - assert.NotNil(t, client) - } - }) - } -} - func TestRunTool(t *testing.T) { // Skip this integration test that requires AWS credentials t.Skip("Skipping integration test that requires AWS credentials - functionality tested in TestProcessServiceWithMocks") @@ -430,277 +165,6 @@ func TestCommandFlags(t *testing.T) { } } -func TestGeneratePurchaseIDEdgeCases(t *testing.T) { - testCoverage := 80.0 - - // Test with recommendations that have special characters - rec := common.Recommendation{ - Service: common.ServiceRDS, - ResourceType: "db.r5b.2xlarge", - Count: 10, - Details: &common.DatabaseDetails{ - Engine: "MySQL 8.0", - AZConfig: "single-az", - }, - } - - id := generatePurchaseID(rec, "us-east-1", 999, false, testCoverage) - assert.Contains(t, id, "rds") - assert.Contains(t, id, "r5b-2xlarge") - assert.Contains(t, id, "10x") - // Index is no longer included due to UUID replacement - - // Test with empty region - id = generatePurchaseID(rec, "", 1, true, testCoverage) - assert.Contains(t, id, "dryrun") - - // Test with very long instance type - rec.ResourceType = "db.x2gd.metal.16xlarge" - id = generatePurchaseID(rec, "ap-south-1", 1, false, testCoverage) - assert.Contains(t, id, "x2gd-metal") -} - -func TestGeneratePurchaseIDComprehensive(t *testing.T) { - // Use a test coverage value - testCoverage := 75.0 - - tests := []struct { - name string - region string - rec common.Recommendation - expectedContains []string - expectedNotContains []string - isDryRun bool - }{ - { - name: "RDS with account name and engine", - rec: common.Recommendation{ - Service: common.ServiceRDS, - ResourceType: "db.r5.large", - Count: 3, - AccountName: "Production Account", - Details: &common.DatabaseDetails{ - Engine: "PostgreSQL", - }, - }, - region: "eu-west-1", - isDryRun: false, - expectedContains: []string{ - "ri-", "production-account", "rds", "postgresql", "eu-west-1", - "db-r5-large", "3x", "75pct", - }, - }, - { - name: "ElastiCache with Redis engine", - rec: common.Recommendation{ - Service: common.ServiceElastiCache, - ResourceType: "cache.r5.xlarge", - Count: 5, - Details: &common.CacheDetails{ - Engine: "Redis", - }, - }, - region: "us-west-2", - isDryRun: false, - expectedContains: []string{ - "ri-", "elasticache", "redis", "us-west-2", - "cache-r5-xlarge", "5x", "75pct", - }, - }, - { - name: "EC2 with platform", - rec: common.Recommendation{ - Service: common.ServiceEC2, - ResourceType: "m5.2xlarge", - Count: 10, - Details: common.ComputeDetails{ - Platform: "Linux/UNIX", - }, - }, - region: "ap-southeast-1", - isDryRun: true, - expectedContains: []string{ - "dryrun-", "ec2", "linux-unix", "ap-southeast-1", - "m5-2xlarge", "10x", "75pct", - }, - }, - { - name: "MemoryDB recommendation", - rec: common.Recommendation{ - Service: common.ServiceMemoryDB, - ResourceType: "db.r6g.large", - Count: 2, - Details: &common.CacheDetails{Engine: "redis"}, - }, - region: "us-east-1", - isDryRun: false, - expectedContains: []string{ - "ri-", "memorydb", "memorydb", "us-east-1", - "db-r6g-large", "2x", "75pct", - }, - }, - { - name: "OpenSearch without engine", - rec: common.Recommendation{ - Service: common.ServiceOpenSearch, - ResourceType: "r5.large.search", - Count: 4, - }, - region: "eu-central-1", - isDryRun: false, - expectedContains: []string{ - "ri-", "opensearch", "eu-central-1", - "r5-large-search", "4x", "75pct", - }, - }, - { - name: "Elasticsearch alias (same as OpenSearch)", - rec: common.Recommendation{ - Service: common.ServiceElasticsearch, // Should work as alias for OpenSearch - ResourceType: "m5.xlarge.elasticsearch", - Count: 3, - }, - region: "us-west-1", - isDryRun: false, - expectedContains: []string{ - "ri-", "opensearch", "us-west-1", - "m5-xlarge-elasticsearch", "3x", "75pct", - }, - }, - { - name: "Redshift without engine", - rec: common.Recommendation{ - Service: common.ServiceRedshift, - ResourceType: "dc2.large", - Count: 8, - }, - region: "us-east-2", - isDryRun: false, - expectedContains: []string{ - "ri-", "redshift", "us-east-2", - "dc2-large", "8x", "75pct", - }, - }, - { - name: "RDS recommendation with account", - rec: common.Recommendation{ - Service: common.ServiceRDS, - ResourceType: "db.r6g.xlarge", - Count: 15, - AccountName: "Staging", - Details: &common.DatabaseDetails{ - Engine: "aurora-mysql", - AZConfig: "multi-az", - }, - }, - region: "ca-central-1", - isDryRun: false, - expectedContains: []string{ - "ri-", "staging", "aurora-mysql", "r6g-xlarge", - "15x", "75pct", "ca-central-1", - }, - }, - { - name: "ElastiCache single-AZ recommendation", - rec: common.Recommendation{ - Service: common.ServiceElastiCache, - ResourceType: "cache.m5.large", - Count: 1, - Details: &common.CacheDetails{ - Engine: "redis", - }, - }, - region: "ap-northeast-1", - isDryRun: true, - expectedContains: []string{ - "dryrun-", "redis", "m5-large", - "1x", "75pct", "ap-northeast-1", - }, - }, - { - name: "Recommendation with special characters in engine", - rec: common.Recommendation{ - Service: common.ServiceRDS, - ResourceType: "db.t3.micro", - Count: 20, - Details: &common.DatabaseDetails{ - Engine: "MySQL_8.0_Community", - }, - }, - region: "us-west-1", - isDryRun: false, - expectedContains: []string{ - "ri-", "rds", "mysql-8.0-community", - "db-t3-micro", "20x", "75pct", - }, - }, - { - name: "Large count recommendation", - rec: common.Recommendation{ - Service: common.ServiceEC2, - ResourceType: "t3.nano", - Count: 999, - }, - region: "eu-west-2", - isDryRun: false, - expectedContains: []string{ - "ri-", "ec2", "eu-west-2", - "t3-nano", "999x", "75pct", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := generatePurchaseID(tt.rec, tt.region, 1, tt.isDryRun, testCoverage) - - // Check expected contains - for _, expected := range tt.expectedContains { - assert.Contains(t, result, expected, "Expected ID to contain '%s'", expected) - } - - // Check expected not contains - for _, notExpected := range tt.expectedNotContains { - assert.NotContains(t, result, notExpected, "Expected ID not to contain '%s'", notExpected) - } - - // Should always contain timestamp and UUID - assert.Regexp(t, `-\d{8}-\d{6}-[a-f0-9]{8}$`, result) - }) - } -} - -func TestGeneratePurchaseIDCoverageVariations(t *testing.T) { - rec := common.Recommendation{ - Service: common.ServiceRDS, - ResourceType: "db.t3.small", - Count: 1, - Details: &common.DatabaseDetails{ - Engine: "mysql", - }, - } - - tests := []struct { - name string - expectedCoverage string - coverage float64 - }{ - {"Coverage 0%", "0pct", 0.0}, - {"Coverage 50%", "50pct", 50.0}, - {"Coverage 75.5%", "76pct", 75.5}, // Rounds to nearest integer - {"Coverage 99%", "99pct", 99.0}, - {"Coverage 100%", "100pct", 100.0}, - {"Coverage 33.3%", "33pct", 33.3}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := generatePurchaseID(rec, "us-east-1", 1, false, tt.coverage) - assert.Contains(t, result, tt.expectedCoverage) - }) - } -} - // TestEffectiveSizingPct locks the rule that target-coverage wins over // coverage when both are configured. Without this, dry-run purchase IDs (and // any other audit label that calls effectiveSizingPct) print the unused @@ -725,195 +189,6 @@ func TestEffectiveSizingPct(t *testing.T) { } } -func TestParseServicesWithEmptyAndNil(t *testing.T) { - for _, input := range [][]string{nil, {}} { - result, err := parseServices(input) - assert.NoError(t, err) - assert.Nil(t, result) - } - for _, input := range [][]string{{"", "rds", ""}, {"rds", ""}, {"foo", "bar", "baz"}, {" rds"}} { - result, err := parseServices(input) - assert.Error(t, err) - assert.Nil(t, result) - } -} - -func TestParseServicesSavingsPlanAliases(t *testing.T) { - expected := []common.ServiceType{common.ServiceSavingsPlansCompute, common.ServiceSavingsPlansEC2Instance, common.ServiceSavingsPlansSageMaker, common.ServiceSavingsPlansDatabase} - for _, alias := range []string{"savingsplans", "savings-plans", "SP"} { - result, err := parseServices([]string{"ec2", alias, "savingsplans-compute", "rds", "EC2"}) - assert.NoError(t, err) - assert.Equal(t, append(append([]common.ServiceType{common.ServiceEC2}, expected...), common.ServiceRDS), result) - } - for i, name := range []string{"compute", "ec2instance", "sagemaker", "database"} { - result, err := parseServices([]string{"savingsplans-" + name, "savings-plans-" + name}) - assert.NoError(t, err) - assert.Equal(t, []common.ServiceType{expected[i]}, result) - } -} - -func TestFilterFlagValidation(t *testing.T) { - // Save original toolCfg values - origCfg := toolCfg - - defer func() { - toolCfg = origCfg - }() - - tests := []struct { - name string - errorContains string - includeRegions []string - excludeRegions []string - includeInstanceTypes []string - excludeInstanceTypes []string - expectError bool - }{ - { - name: "No conflicts", - includeRegions: []string{"us-east-1"}, - excludeRegions: []string{"us-west-2"}, - includeInstanceTypes: []string{"db.t3.micro"}, - excludeInstanceTypes: []string{"db.t3.large"}, - expectError: false, - }, - { - name: "Region conflict", - includeRegions: []string{"us-east-1", "us-west-2"}, - excludeRegions: []string{"us-west-2"}, - includeInstanceTypes: []string{}, - excludeInstanceTypes: []string{}, - expectError: true, - errorContains: "region 'us-west-2' cannot be both included and excluded", - }, - { - name: "Instance type conflict", - includeRegions: []string{}, - excludeRegions: []string{}, - includeInstanceTypes: []string{"db.t3.small"}, - excludeInstanceTypes: []string{"db.t3.small"}, - expectError: true, - errorContains: "instance type 'db.t3.small' cannot be both included and excluded", - }, - { - name: "Empty filters valid", - includeRegions: []string{}, - excludeRegions: []string{}, - includeInstanceTypes: []string{}, - excludeInstanceTypes: []string{}, - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Set test values in toolCfg - toolCfg.IncludeRegions = tt.includeRegions - toolCfg.ExcludeRegions = tt.excludeRegions - toolCfg.IncludeInstanceTypes = tt.includeInstanceTypes - toolCfg.ExcludeInstanceTypes = tt.excludeInstanceTypes - toolCfg.Coverage = 80.0 - toolCfg.PaymentOption = "no-upfront" - toolCfg.TermYears = 3 - - // Call validateFlags - err := validateFlags(nil, nil) - - if tt.expectError { - assert.Error(t, err) - if err != nil && tt.errorContains != "" { - assert.Contains(t, err.Error(), tt.errorContains) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestCreateServiceClientAllServices(t *testing.T) { - cfg := aws.Config{ - Region: "eu-central-1", - } - - // Test that all services return non-nil clients now - services := getAllServices() - for _, service := range services { - client := createServiceClient(service, cfg) - assert.NotNil(t, client, "Service %s should have a client", service) - } -} - -func TestValidateFlags(t *testing.T) { - tests := []struct { - name string - setPayment string - setCoverage float64 - setTerm int - expectError bool - }{ - { - name: "Valid flags", - setCoverage: 80.0, - setTerm: 1, - setPayment: "partial-upfront", - expectError: false, - }, - { - name: "Coverage too high", - setCoverage: 150.0, - setTerm: 1, - setPayment: "partial-upfront", - expectError: true, - }, - { - name: "Coverage negative", - setCoverage: -10.0, - setTerm: 1, - setPayment: "partial-upfront", - expectError: true, - }, - { - name: "Invalid term", - setCoverage: 80.0, - setTerm: 2, - setPayment: "partial-upfront", - expectError: true, - }, - { - name: "Invalid payment option", - setCoverage: 80.0, - setTerm: 1, - setPayment: "invalid", - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Save original values - origCfg := toolCfg - - // Set test values - toolCfg.Coverage = tt.setCoverage - toolCfg.TermYears = tt.setTerm - toolCfg.PaymentOption = tt.setPayment - - // Call validateFlags - err := validateFlags(nil, []string{}) - - // Restore original values - toolCfg = origCfg - - if tt.expectError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - func TestValidateFlagsExtended(t *testing.T) { // Save original toolCfg origCfg := toolCfg @@ -1152,169 +427,6 @@ func TestValidateFlagsExtended(t *testing.T) { } } -func TestSanitizeAccountName(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - {"Simple name", "production", "production"}, - {"Spaces to hyphens", "my account", "my-account"}, - {"Underscores to hyphens", "my_account", "my-account"}, - {"Uppercase to lowercase", "PRODUCTION", "production"}, - {"Special chars removed", "my@account#123", "myaccount123"}, - {"Dots to hyphens", "my.account.com", "my-account-com"}, - {"Long name preserved", "very-long-production-environment-name", "very-long-production-environment-name"}, - {"Empty string", "", ""}, - {"Only special chars", "@#$%", ""}, - {"Multiple hyphens collapsed", "my---account", "my-account"}, - {"Leading/trailing hyphens removed", "-account-", "account"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := sanitizeAccountName(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} -func TestGeneratePurchaseID_EdgeCases(t *testing.T) { - tests := []struct { - name string - region string - rec common.Recommendation - index int - isDryRun bool - }{ - { - name: "RDS dry run", - rec: common.Recommendation{ - Service: common.ServiceRDS, - ResourceType: "db.t3.micro", - Count: 2, - }, - region: "us-east-1", - index: 1, - isDryRun: true, - }, - { - name: "EC2 actual purchase", - rec: common.Recommendation{ - Service: common.ServiceEC2, - ResourceType: "t3.large", - Count: 5, - }, - region: "eu-west-1", - index: 99, - isDryRun: false, - }, - { - name: "ElastiCache with dots in instance type", - rec: common.Recommendation{ - Service: common.ServiceElastiCache, - ResourceType: "cache.r6g.2xlarge", - Count: 1, - }, - region: "ap-southeast-1", - index: 1000, - isDryRun: false, - }, - { - name: "Unknown service", - rec: common.Recommendation{ - Service: common.ServiceType("future-service"), - ResourceType: "unknown.large", - Count: 10, - }, - region: "us-west-2", - index: 1, - isDryRun: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - testCoverage := 80.0 - id := generatePurchaseID(tt.rec, tt.region, tt.index, tt.isDryRun, testCoverage) - - // Verify ID contains expected parts - if tt.isDryRun { - assert.Contains(t, id, "dryrun") - } else { - assert.Contains(t, id, "ri") - } - - assert.Contains(t, id, tt.region) - assert.Contains(t, id, strings.ReplaceAll(tt.rec.ResourceType, ".", "-")) - assert.Contains(t, id, fmt.Sprintf("%dx", tt.rec.Count)) - // Should contain timestamp (YYYYMMDD-HHMMSS) and UUID suffix (8 chars) - assert.Regexp(t, `-\d{8}-\d{6}-[a-f0-9]{8}$`, id) - }) - } -} - -func TestValidateInstanceTypes(t *testing.T) { - tests := []struct { - name string - errorContains string - instanceTypes []string - expectError bool - }{ - { - name: "Empty slice is valid", - instanceTypes: []string{}, - expectError: false, - }, - { - name: "Valid instance types", - instanceTypes: []string{"db.t3.micro", "cache.r5.large", "t3.medium"}, - expectError: false, - }, - { - name: "Invalid - no dot", - instanceTypes: []string{"t3micro"}, - expectError: true, - errorContains: "invalid instance type format", - }, - { - name: "Invalid - empty string", - instanceTypes: []string{"db.t3.micro", "", "cache.r5.large"}, - expectError: true, - errorContains: "empty instance type", - }, - { - name: "Valid with multiple dots", - instanceTypes: []string{"r5.xlarge.search", "db.r6g.2xlarge"}, - expectError: false, - }, - { - name: "Single valid instance type", - instanceTypes: []string{"m5.large"}, - expectError: false, - }, - { - name: "Mix of valid and invalid", - instanceTypes: []string{"db.t3.small", "invalidtype"}, - expectError: true, - errorContains: "invalid instance type format", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := validateInstanceTypes(tt.instanceTypes) - if tt.expectError { - assert.Error(t, err) - if tt.errorContains != "" { - assert.Contains(t, err.Error(), tt.errorContains) - } - } else { - assert.NoError(t, err) - } - }) - } -} - func TestServicesCommandRejectsUnknownBeforeRun(t *testing.T) { original := toolCfg t.Cleanup(func() { toolCfg = original }) diff --git a/cmd/multi_service_account_names_test.go b/cmd/multi_service_account_names_test.go new file mode 100644 index 000000000..cf82de645 --- /dev/null +++ b/cmd/multi_service_account_names_test.go @@ -0,0 +1,219 @@ +package main + +import ( + "context" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/organizations" + orgtypes "github.com/aws/aws-sdk-go-v2/service/organizations/types" + "github.com/stretchr/testify/assert" +) + +func TestPopulateAccountNames(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + recommendations []common.Recommendation + setupMock func(m *MockOrganizationsClient) + expectedNames []string + }{ + { + name: "Populates account names from IDs", + recommendations: []common.Recommendation{ + {Account: "123456789012", AccountName: ""}, + {Account: "210987654321", AccountName: ""}, + }, + setupMock: func(m *MockOrganizationsClient) { + m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ + AccountId: aws.String("123456789012"), + }).Return(&organizations.DescribeAccountOutput{ + Account: &orgtypes.Account{ + Name: aws.String("Production"), + }, + }, nil).Once() + m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ + AccountId: aws.String("210987654321"), + }).Return(&organizations.DescribeAccountOutput{ + Account: &orgtypes.Account{ + Name: aws.String("Development"), + }, + }, nil).Once() + }, + expectedNames: []string{"Production", "Development"}, + }, + { + name: "Handles empty account IDs", + recommendations: []common.Recommendation{ + {Account: "", AccountName: ""}, + {Account: "123456789012", AccountName: ""}, + }, + setupMock: func(m *MockOrganizationsClient) { + m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ + AccountId: aws.String("123456789012"), + }).Return(&organizations.DescribeAccountOutput{ + Account: &orgtypes.Account{ + Name: aws.String("Production"), + }, + }, nil).Once() + }, + expectedNames: []string{"", "Production"}, + }, + { + name: "Uses cached values for repeated accounts", + recommendations: []common.Recommendation{ + {Account: "123456789012", AccountName: ""}, + {Account: "123456789012", AccountName: ""}, + {Account: "123456789012", AccountName: ""}, + }, + setupMock: func(m *MockOrganizationsClient) { + // Should only be called once due to caching + m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ + AccountId: aws.String("123456789012"), + }).Return(&organizations.DescribeAccountOutput{ + Account: &orgtypes.Account{ + Name: aws.String("Production"), + }, + }, nil).Once() + }, + expectedNames: []string{"Production", "Production", "Production"}, + }, + { + name: "Handles empty recommendations", + recommendations: []common.Recommendation{}, + setupMock: func(m *MockOrganizationsClient) { + // No calls expected + }, + expectedNames: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockOrg := &MockOrganizationsClient{} + tt.setupMock(mockOrg) + + cache := &TestAccountAliasCache{ + cache: make(map[string]string), + orgClient: mockOrg, + } + + // Manually populate account names using our test cache + for i := range tt.recommendations { + if tt.recommendations[i].Account != "" { + tt.recommendations[i].AccountName = cache.GetAccountAlias(ctx, tt.recommendations[i].Account) + } + } + + assert.Equal(t, len(tt.expectedNames), len(tt.recommendations)) + for i, rec := range tt.recommendations { + assert.Equal(t, tt.expectedNames[i], rec.AccountName) + } + + mockOrg.AssertExpectations(t) + }) + } +} + +// TestPopulateAccountNamesLogic tests the logic of populateAccountNames +// by verifying it populates the AccountName field correctly. +func TestPopulateAccountNamesLogic(t *testing.T) { + ctx := context.Background() + + t.Run("Correctly populates account names", func(t *testing.T) { + mockOrg := &MockOrganizationsClient{} + mockOrg.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ + AccountId: aws.String("123456789012"), + }).Return(&organizations.DescribeAccountOutput{ + Account: &orgtypes.Account{ + Name: aws.String("Production"), + }, + }, nil).Once() + + cache := &TestAccountAliasCache{ + cache: make(map[string]string), + orgClient: mockOrg, + } + + recs := []common.Recommendation{ + {Account: "123456789012", AccountName: ""}, + } + + // Simulate what populateAccountNames does - call GetAccountAlias for each rec + for i := range recs { + if recs[i].Account != "" { + recs[i].AccountName = cache.GetAccountAlias(ctx, recs[i].Account) + } + } + + assert.Equal(t, "Production", recs[0].AccountName) + mockOrg.AssertExpectations(t) + }) + + t.Run("Skips empty account IDs", func(t *testing.T) { + cache := &TestAccountAliasCache{ + cache: make(map[string]string), + orgClient: &MockOrganizationsClient{}, // No calls expected + } + + recs := []common.Recommendation{ + {Account: "", AccountName: ""}, + {Account: "", AccountName: "initial"}, + } + + // Simulate what populateAccountNames does + for i := range recs { + if recs[i].Account != "" { + recs[i].AccountName = cache.GetAccountAlias(ctx, recs[i].Account) + } + } + + // Empty accounts should not be modified (or return empty from GetAccountAlias) + assert.Equal(t, "", recs[0].AccountName) + assert.Equal(t, "initial", recs[1].AccountName) + }) + + t.Run("Handles multiple accounts with caching", func(t *testing.T) { + mockOrg := &MockOrganizationsClient{} + // Should only call once per unique account + mockOrg.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ + AccountId: aws.String("111222333444"), + }).Return(&organizations.DescribeAccountOutput{ + Account: &orgtypes.Account{ + Name: aws.String("Dev"), + }, + }, nil).Once() + mockOrg.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ + AccountId: aws.String("555666777888"), + }).Return(&organizations.DescribeAccountOutput{ + Account: &orgtypes.Account{ + Name: aws.String("Staging"), + }, + }, nil).Once() + + cache := &TestAccountAliasCache{ + cache: make(map[string]string), + orgClient: mockOrg, + } + + recs := []common.Recommendation{ + {Account: "111222333444", AccountName: ""}, + {Account: "111222333444", AccountName: ""}, // Same account - should use cache + {Account: "555666777888", AccountName: ""}, + } + + // Simulate what populateAccountNames does + for i := range recs { + if recs[i].Account != "" { + recs[i].AccountName = cache.GetAccountAlias(ctx, recs[i].Account) + } + } + + assert.Equal(t, "Dev", recs[0].AccountName) + assert.Equal(t, "Dev", recs[1].AccountName) + assert.Equal(t, "Staging", recs[2].AccountName) + mockOrg.AssertExpectations(t) + }) +} diff --git a/cmd/multi_service_adjust_excluded_versions_test.go b/cmd/multi_service_adjust_excluded_versions_test.go new file mode 100644 index 000000000..c3a386767 --- /dev/null +++ b/cmd/multi_service_adjust_excluded_versions_test.go @@ -0,0 +1,383 @@ +package main + +import ( + "bytes" + "log" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" + "github.com/stretchr/testify/assert" +) + +func TestAdjustRecommendationForExcludedVersions_MultipleVersionsInExtendedSupport(t *testing.T) { + recommendation := common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-east-1", + ResourceType: "db.r5.large", + Count: 10, + Details: &common.DatabaseDetails{ + Engine: "Aurora MySQL", + }, + } + + instanceVersions := map[string][]InstanceEngineVersion{ + "db.r5.large": { + {Engine: "aurora-mysql", EngineVersion: "5.6.mysql_aurora.1.22.5", InstanceClass: "db.r5.large", Region: "us-east-1"}, + {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, + {Engine: "aurora-mysql", EngineVersion: "8.0.mysql_aurora.3.04.0", InstanceClass: "db.r5.large", Region: "us-east-1"}, + }, + } + + // Version info with both 5.6 and 5.7 in extended support + versionInfo := map[string]MajorEngineVersionInfo{ + "aurora-mysql:5.6": { + Engine: "aurora-mysql", + MajorEngineVersion: "5.6", + SupportedEngineLifecycles: []EngineLifecycleInfo{ + { + LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport), + LifecycleSupportStartDate: time.Now().AddDate(0, -12, 0), + LifecycleSupportEndDate: time.Now().AddDate(2, 0, 0), + }, + }, + }, + "aurora-mysql:5.7": { + Engine: "aurora-mysql", + MajorEngineVersion: "5.7", + SupportedEngineLifecycles: []EngineLifecycleInfo{ + { + LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport), + LifecycleSupportStartDate: time.Now().AddDate(0, -6, 0), + LifecycleSupportEndDate: time.Now().AddDate(3, 0, 0), + }, + }, + }, + } + + result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, versionInfo) + + assert.Equal(t, 8, result.Count, "Should exclude 2 instances (5.6 and 5.7 both in extended support)") +} + +func TestAdjustRecommendationForExcludedVersions_NonRDSService(t *testing.T) { + recommendation := common.Recommendation{ + Service: common.ServiceEC2, + Region: "us-east-1", + ResourceType: "m5.large", + Count: 5, + Details: nil, // Not RDS + } + + instanceVersions := map[string][]InstanceEngineVersion{} + versionInfo := createTestVersionInfo() + + result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, versionInfo) + + assert.Equal(t, 5, result.Count, "Non-RDS services should not be adjusted") +} + +// TestAdjustRecommendationForExcludedVersions_TypedNilDetails pins the typed-nil +// guard on the *common.DatabaseDetails case. A nil interface is caught by the +// caller's own nil checks, but a (*common.DatabaseDetails)(nil) stored in the +// interface reaches the type switch, and reading details.Engine there panics. +// instanceVersions must contain an entry for the rec's ResourceType so the +// function gets past its early "no running instances" return and actually +// reaches the switch. +func TestAdjustRecommendationForExcludedVersions_TypedNilDetails(t *testing.T) { + recommendation := common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-east-1", + ResourceType: "db.r5.large", + Count: 7, + Details: (*common.DatabaseDetails)(nil), + } + + instanceVersions := map[string][]InstanceEngineVersion{ + "db.r5.large": { + {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, + }, + } + + assert.NotPanics(t, func() { + result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, createTestVersionInfo()) + assert.Equal(t, 7, result.Count, "typed-nil Details must pass through unadjusted") + }) +} + +// TestAdjustRecommendationForExcludedVersions_UnknownLifecycle pins the +// issue #1313 contract: when an instance's engine/major version is missing +// from versionInfo, the instance must NOT be silently treated as "not on +// extended support". The count stays untouched (conservative skip) and a +// warning naming the engine, version, and region is logged, once per +// engine:version pair. +func TestAdjustRecommendationForExcludedVersions_UnknownLifecycle(t *testing.T) { + recommendation := common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-east-1", + ResourceType: "db.r5.large", + Count: 4, + Details: &common.DatabaseDetails{ + Engine: "MySQL", + }, + } + + // Two instances of the same unknown version plus one of another: the + // warning must be deduplicated per engine:version, not per instance. + instanceVersions := map[string][]InstanceEngineVersion{ + "db.r5.large": { + {Engine: "mysql", EngineVersion: "9.0.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, + {Engine: "mysql", EngineVersion: "9.0.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, + {Engine: "mysql", EngineVersion: "10.1.0", InstanceClass: "db.r5.large", Region: "us-east-1"}, + }, + } + + // versionInfo has data for mysql:8.0 only, so 9.x and 10.x lookups miss. + versionInfo := map[string]MajorEngineVersionInfo{ + "mysql:8.0": { + Engine: "mysql", + MajorEngineVersion: "8.0", + SupportedEngineLifecycles: []EngineLifecycleInfo{ + { + LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsStandardSupport), + LifecycleSupportStartDate: time.Now().AddDate(-2, 0, 0), + LifecycleSupportEndDate: time.Now().AddDate(3, 0, 0), + }, + }, + }, + } + + var logBuf bytes.Buffer + origOutput := log.Writer() + origFlags := log.Flags() + log.SetOutput(&logBuf) + log.SetFlags(0) + defer func() { + log.SetOutput(origOutput) + log.SetFlags(origFlags) + }() + + result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, versionInfo) + + assert.Equal(t, 4, result.Count, "unknown lifecycle status must leave the count untouched") + + logged := logBuf.String() + assert.Contains(t, logged, "mysql 9.0.1", "warning must name the unknown engine and version") + assert.Contains(t, logged, "mysql 10.1.0", "warning must name the unknown engine and version") + assert.Contains(t, logged, "us-east-1", "warning must name the region") + assert.Equal(t, 2, strings.Count(logged, "No lifecycle data for"), "warning must be logged once per engine:version, not per instance") +} + +// TestAdjustRecommendationForExcludedVersions_ScalesMoneyWithCount: cutting the +// count must scale the money fields with it, or the report keeps the full +// recommendation's cost for fewer instances. +func TestAdjustRecommendationForExcludedVersions_ScalesMoneyWithCount(t *testing.T) { + monthly := 30.0 + recommendation := common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-west-2", + ResourceType: "db.r6g.large", + Count: 3, + CommitmentCost: 900, + OnDemandCost: 600, + EstimatedSavings: 150, + RecurringMonthlyCost: &monthly, + Details: &common.DatabaseDetails{Engine: "aurora-mysql"}, + } + instanceVersions := map[string][]InstanceEngineVersion{ + "db.r6g.large": { + {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.12.0", InstanceClass: "db.r6g.large", Region: "us-west-2"}, + }, + } + + result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, createTestVersionInfo()) + + assert.Equal(t, 2, result.Count) + assert.InDelta(t, 600.0, result.CommitmentCost, 0.01, "2/3 of 900") + assert.InDelta(t, 400.0, result.OnDemandCost, 0.01) + assert.InDelta(t, 100.0, result.EstimatedSavings, 0.01) + if assert.NotNil(t, result.RecurringMonthlyCost) { + assert.InDelta(t, 20.0, *result.RecurringMonthlyCost, 0.01) + } + assert.InDelta(t, 30.0, monthly, 0.001, "the input's pointer is not mutated") +} + +func TestAdjustRecommendationForExcludedVersions(t *testing.T) { + tests := []struct { + versionInfo map[string]MajorEngineVersionInfo + instanceVersions map[string][]InstanceEngineVersion + name string + recommendation common.Recommendation + expectedCount int + expectedAdjusted bool + }{ + { + name: "No running instances - recommendation unchanged", + recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-east-1", + ResourceType: "db.r5.large", + Count: 10, + Details: &common.DatabaseDetails{ + Engine: "Aurora MySQL", + }, + }, + versionInfo: createTestVersionInfo(), + instanceVersions: map[string][]InstanceEngineVersion{}, + expectedCount: 10, + expectedAdjusted: false, + }, + { + name: "Exclude 1 MySQL 5.7 instance in extended support", + recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-east-1", + ResourceType: "db.r5.large", + Count: 10, + Details: &common.DatabaseDetails{ + Engine: "Aurora MySQL", + }, + }, + versionInfo: createTestVersionInfo(), + instanceVersions: map[string][]InstanceEngineVersion{ + "db.r5.large": { + {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, + {Engine: "aurora-mysql", EngineVersion: "8.0.mysql_aurora.3.04.0", InstanceClass: "db.r5.large", Region: "us-east-1"}, + {Engine: "aurora-mysql", EngineVersion: "8.0.mysql_aurora.3.04.0", InstanceClass: "db.r5.large", Region: "us-east-1"}, + }, + }, + expectedCount: 9, // 10 - 1 MySQL 5.7 instance in extended support + expectedAdjusted: true, + }, + { + name: "Exclude all MySQL 5.7 instances in extended support", + recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Region: "eu-west-2", + ResourceType: "db.t3.small", + Count: 2, + Details: &common.DatabaseDetails{ + Engine: "Aurora MySQL", + }, + }, + versionInfo: createTestVersionInfo(), + instanceVersions: map[string][]InstanceEngineVersion{ + "db.t3.small": { + {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.2", InstanceClass: "db.t3.small", Region: "eu-west-2"}, + {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.2", InstanceClass: "db.t3.small", Region: "eu-west-2"}, + }, + }, + expectedCount: 0, // All excluded (both in extended support) + expectedAdjusted: true, + }, + { + name: "Different engine - no adjustment", + recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-east-1", + ResourceType: "db.r5.large", + Count: 5, + Details: &common.DatabaseDetails{ + Engine: "Aurora PostgreSQL", + }, + }, + versionInfo: createTestVersionInfo(), + instanceVersions: map[string][]InstanceEngineVersion{ + "db.r5.large": { + {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, + }, + }, + expectedCount: 5, // Different engine, no adjustment + expectedAdjusted: false, + }, + { + name: "Different region - no adjustment", + recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-east-1", + ResourceType: "db.r5.large", + Count: 5, + Details: &common.DatabaseDetails{ + Engine: "Aurora MySQL", + }, + }, + versionInfo: createTestVersionInfo(), + instanceVersions: map[string][]InstanceEngineVersion{ + "db.r5.large": { + {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.1", InstanceClass: "db.r5.large", Region: "eu-west-2"}, + }, + }, + expectedCount: 5, // Different region, no adjustment + expectedAdjusted: false, + }, + { + name: "MySQL (not Aurora) with standard mysql engine name", + recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Region: "eu-west-2", + ResourceType: "db.r5.4xlarge", + Count: 8, + Details: &common.DatabaseDetails{ + Engine: "MySQL", + }, + }, + versionInfo: map[string]MajorEngineVersionInfo{ + "mysql:5.7": { + Engine: "mysql", + MajorEngineVersion: "5.7", + SupportedEngineLifecycles: []EngineLifecycleInfo{ + { + LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport), + LifecycleSupportStartDate: time.Now().AddDate(0, -6, 0), + LifecycleSupportEndDate: time.Now().AddDate(3, 0, 0), + }, + }, + }, + }, + instanceVersions: map[string][]InstanceEngineVersion{ + "db.r5.4xlarge": { + {Engine: "mysql", EngineVersion: "5.7.44", InstanceClass: "db.r5.4xlarge", Region: "eu-west-2"}, + {Engine: "mysql", EngineVersion: "8.0.35", InstanceClass: "db.r5.4xlarge", Region: "eu-west-2"}, + }, + }, + expectedCount: 7, // 8 - 1 MySQL 5.7 instance in extended support + expectedAdjusted: true, + }, + { + name: "Engine name normalization - spaces vs hyphens", + recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-west-2", + ResourceType: "db.r6g.large", + Count: 3, + Details: &common.DatabaseDetails{ + Engine: "Aurora MySQL", // Space in name + }, + }, + versionInfo: createTestVersionInfo(), + instanceVersions: map[string][]InstanceEngineVersion{ + "db.r6g.large": { + {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.12.0", InstanceClass: "db.r6g.large", Region: "us-west-2"}, // Hyphen in name + }, + }, + expectedCount: 2, // Should match despite space vs hyphen + expectedAdjusted: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := adjustRecommendationForExcludedVersions(tt.recommendation, tt.instanceVersions, tt.versionInfo) + + assert.Equal(t, tt.expectedCount, result.Count, "Instance count mismatch") + + if tt.expectedAdjusted { + assert.NotEqual(t, tt.recommendation.Count, result.Count, "Count should have been adjusted") + } else { + assert.Equal(t, tt.recommendation.Count, result.Count, "Count should not have been adjusted") + } + }) + } +} diff --git a/cmd/multi_service_apply_filters_test.go b/cmd/multi_service_apply_filters_test.go new file mode 100644 index 000000000..9713b470b --- /dev/null +++ b/cmd/multi_service_apply_filters_test.go @@ -0,0 +1,299 @@ +package main + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestApplyFilters_RegionFiltering(t *testing.T) { + tests := []struct { + name string + currentRegion string + recs []common.Recommendation + includeRegions []string + excludeRegions []string + expectedCount int + }{ + { + name: "No region filters - all pass", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, + {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, + {Region: "eu-west-1", ResourceType: "db.t3.large", Count: 3, Service: common.ServiceRDS}, + }, + includeRegions: []string{}, + excludeRegions: []string{}, + currentRegion: "", + expectedCount: 3, + }, + { + name: "Include specific regions", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, + {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, + {Region: "eu-west-1", ResourceType: "db.t3.large", Count: 3, Service: common.ServiceRDS}, + }, + includeRegions: []string{"us-east-1", "us-west-2"}, + excludeRegions: []string{}, + currentRegion: "", + expectedCount: 2, + }, + { + name: "Exclude specific regions", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, + {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, + {Region: "eu-west-1", ResourceType: "db.t3.large", Count: 3, Service: common.ServiceRDS}, + }, + includeRegions: []string{}, + excludeRegions: []string{"us-west-2"}, + currentRegion: "", + expectedCount: 2, + }, + { + name: "Current region filter with RDS", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, + {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, + }, + includeRegions: []string{}, + excludeRegions: []string{}, + currentRegion: "us-east-1", + expectedCount: 1, + }, + { + name: "Savings Plans bypass region filter", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "ComputeSP", Count: 1, Service: common.ServiceSavingsPlansAll}, + {Region: "us-west-2", ResourceType: "ComputeSP", Count: 2, Service: common.ServiceSavingsPlansAll}, + }, + includeRegions: []string{}, + excludeRegions: []string{}, + currentRegion: "us-east-1", + expectedCount: 2, // Both should pass, SP is account-level + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{ + IncludeRegions: tt.includeRegions, + ExcludeRegions: tt.excludeRegions, + } + + result := applyFilters(tt.recs, &cfg, map[string][]InstanceEngineVersion{}, map[string]MajorEngineVersionInfo{}, tt.currentRegion, nil) + assert.Equal(t, tt.expectedCount, len(result), "Expected %d recommendations, got %d", tt.expectedCount, len(result)) + }) + } +} + +func TestApplyFilters_InstanceTypeFiltering(t *testing.T) { + tests := []struct { + name string + recs []common.Recommendation + includeInstanceTypes []string + excludeInstanceTypes []string + expectedCount int + }{ + { + name: "Include specific instance types", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, + {Region: "us-east-1", ResourceType: "db.r5.large", Count: 3, Service: common.ServiceRDS}, + }, + includeInstanceTypes: []string{"db.t3.small", "db.t3.medium"}, + excludeInstanceTypes: []string{}, + expectedCount: 2, + }, + { + name: "Exclude specific instance types", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, + {Region: "us-east-1", ResourceType: "db.r5.large", Count: 3, Service: common.ServiceRDS}, + }, + includeInstanceTypes: []string{}, + excludeInstanceTypes: []string{"db.r5.large"}, + expectedCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{ + IncludeInstanceTypes: tt.includeInstanceTypes, + ExcludeInstanceTypes: tt.excludeInstanceTypes, + } + + result := applyFilters(tt.recs, &cfg, map[string][]InstanceEngineVersion{}, map[string]MajorEngineVersionInfo{}, "", nil) + assert.Equal(t, tt.expectedCount, len(result)) + }) + } +} + +func TestApplyFilters_EngineFiltering(t *testing.T) { + tests := []struct { + name string + recs []common.Recommendation + includeEngines []string + excludeEngines []string + expectedCount int + }{ + { + name: "Include specific engines", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}}, + {Region: "us-east-1", ResourceType: "cache.t3.small", Count: 3, Service: common.ServiceElastiCache, Details: &common.CacheDetails{Engine: "redis"}}, + }, + includeEngines: []string{"postgresql", "mysql"}, + excludeEngines: []string{}, + expectedCount: 2, + }, + { + name: "Exclude specific engines", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}}, + {Region: "us-east-1", ResourceType: "cache.t3.small", Count: 3, Service: common.ServiceElastiCache, Details: &common.CacheDetails{Engine: "redis"}}, + }, + includeEngines: []string{}, + excludeEngines: []string{"redis"}, + expectedCount: 2, + }, + { + name: "Case insensitive engine matching", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "PostgreSQL"}}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "MySQL"}}, + }, + includeEngines: []string{"postgresql", "mysql"}, + excludeEngines: []string{}, + expectedCount: 2, + }, + { + name: "No engine details - with include list", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}}, + }, + includeEngines: []string{"mysql"}, + excludeEngines: []string{}, + expectedCount: 1, // Only the one with engine details + }, + { + name: "No engine details - no include list", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, + }, + includeEngines: []string{}, + excludeEngines: []string{}, + expectedCount: 2, // All pass + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{ + IncludeEngines: tt.includeEngines, + ExcludeEngines: tt.excludeEngines, + } + + result := applyFilters(tt.recs, &cfg, map[string][]InstanceEngineVersion{}, map[string]MajorEngineVersionInfo{}, "", nil) + assert.Equal(t, tt.expectedCount, len(result)) + }) + } +} + +func TestApplyFilters_AccountFiltering(t *testing.T) { + tests := []struct { + name string + recs []common.Recommendation + includeAccounts []string + excludeAccounts []string + expectedCount int + }{ + { + name: "Include specific accounts", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, AccountName: "prod-account"}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, AccountName: "dev-account"}, + {Region: "us-east-1", ResourceType: "db.t3.large", Count: 3, Service: common.ServiceRDS, AccountName: "staging-account"}, + }, + includeAccounts: []string{"prod-account", "DEV-account"}, + excludeAccounts: []string{}, + expectedCount: 2, // Exact, case-insensitive match + }, + { + name: "Exclude specific accounts", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, AccountName: "prod-account"}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, AccountName: "dev-account"}, + }, + includeAccounts: []string{}, + excludeAccounts: []string{"dev-account"}, + expectedCount: 1, + }, + { + name: "Empty account name with filters", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, AccountName: ""}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, AccountName: "prod"}, + }, + includeAccounts: []string{"prod"}, + excludeAccounts: []string{}, + expectedCount: 1, // Empty account name is filtered out + }, + { + name: "Empty account name without filters", + recs: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, AccountName: ""}, + {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, AccountName: "prod"}, + }, + includeAccounts: []string{}, + excludeAccounts: []string{}, + expectedCount: 2, // All pass + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{ + IncludeAccounts: tt.includeAccounts, + ExcludeAccounts: tt.excludeAccounts, + } + + result := applyFilters(tt.recs, &cfg, map[string][]InstanceEngineVersion{}, map[string]MajorEngineVersionInfo{}, "", nil) + assert.Equal(t, tt.expectedCount, len(result)) + }) + } +} + +func TestApplyFilters_CombinedFilters(t *testing.T) { + recs := []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}, AccountName: "prod-account"}, + {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}, AccountName: "dev-account"}, + {Region: "us-east-1", ResourceType: "db.r5.large", Count: 3, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}, AccountName: "staging-account"}, + {Region: "eu-west-1", ResourceType: "cache.t3.small", Count: 4, Service: common.ServiceElastiCache, Details: &common.CacheDetails{Engine: "redis"}, AccountName: "prod-account"}, + } + + cfg := Config{ + IncludeRegions: []string{"us-east-1", "us-west-2"}, + IncludeEngines: []string{"postgresql", "mysql"}, + ExcludeInstanceTypes: []string{"db.r5.large"}, + IncludeAccounts: []string{"prod-account", "dev-account"}, + } + + result := applyFilters(recs, &cfg, map[string][]InstanceEngineVersion{}, map[string]MajorEngineVersionInfo{}, "", nil) + + // Only the first two should pass all filters + assert.Equal(t, 2, len(result)) + if len(result) >= 2 { + assert.Equal(t, "db.t3.small", result[0].ResourceType) + assert.Equal(t, "db.t3.medium", result[1].ResourceType) + } +} diff --git a/cmd/multi_service_coverage_extended_support_test.go b/cmd/multi_service_coverage_extended_support_test.go new file mode 100644 index 000000000..b1a629fea --- /dev/null +++ b/cmd/multi_service_coverage_extended_support_test.go @@ -0,0 +1,199 @@ +package main + +import ( + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" + "github.com/stretchr/testify/assert" +) + +func TestIsInExtendedSupport_EdgeCases(t *testing.T) { + now := time.Now() + pastDate := now.AddDate(0, -6, 0) // 6 months ago + futureDate := now.AddDate(3, 0, 0) // 3 years from now + + versionInfo := map[string]MajorEngineVersionInfo{ + "mysql:5.7": { + Engine: "mysql", + MajorEngineVersion: "5.7", + SupportedEngineLifecycles: []EngineLifecycleInfo{ + { + LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport), + LifecycleSupportStartDate: pastDate, + LifecycleSupportEndDate: futureDate, + }, + }, + }, + "mysql:8.0": { + Engine: "mysql", + MajorEngineVersion: "8.0", + SupportedEngineLifecycles: []EngineLifecycleInfo{ + { + LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsStandardSupport), + LifecycleSupportStartDate: now.AddDate(-2, 0, 0), + LifecycleSupportEndDate: futureDate, + }, + }, + }, + } + + tests := []struct { + name string + engine string + fullVersion string + expectedExtended bool + expectedKnown bool + }{ + { + name: "MySQL 5.7 in extended support", + engine: "mysql", + fullVersion: "5.7.44", + expectedExtended: true, + expectedKnown: true, + }, + { + name: "MySQL 8.0 in standard support", + engine: "mysql", + fullVersion: "8.0.35", + expectedExtended: false, + expectedKnown: true, + }, + { + name: "Unknown version", + engine: "mysql", + fullVersion: "9.0.0", + expectedExtended: false, + expectedKnown: false, + }, + { + name: "Empty version", + engine: "mysql", + fullVersion: "", + expectedExtended: false, + expectedKnown: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + extended, known := isInExtendedSupport(tt.engine, tt.fullVersion, versionInfo) + assert.Equal(t, tt.expectedExtended, extended) + assert.Equal(t, tt.expectedKnown, known, "known flag mismatch") + }) + } +} + +// ==================== Tests for adjustRecommendationForExcludedVersions ==================== + +func TestAdjustRecommendationForExcludedVersions_AdditionalCases(t *testing.T) { + now := time.Now() + pastDate := now.AddDate(0, -6, 0) + futureDate := now.AddDate(3, 0, 0) + + versionInfo := map[string]MajorEngineVersionInfo{ + "mysql:5.7": { + Engine: "mysql", + MajorEngineVersion: "5.7", + SupportedEngineLifecycles: []EngineLifecycleInfo{ + { + LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport), + LifecycleSupportStartDate: pastDate, + LifecycleSupportEndDate: futureDate, + }, + }, + }, + } + + tests := []struct { + instanceVersions map[string][]InstanceEngineVersion + name string + rec common.Recommendation + expectedCount int + }{ + { + name: "No running instances - no adjustment", + rec: common.Recommendation{ + ResourceType: "db.t3.small", + Count: 10, + Region: "us-east-1", + Details: &common.DatabaseDetails{ + Engine: "mysql", + }, + }, + instanceVersions: map[string][]InstanceEngineVersion{}, + expectedCount: 10, + }, + { + name: "Running instances with extended support - adjust count", + rec: common.Recommendation{ + ResourceType: "db.t3.small", + Count: 10, + Region: "us-east-1", + Details: &common.DatabaseDetails{ + Engine: "mysql", + }, + }, + instanceVersions: map[string][]InstanceEngineVersion{ + "db.t3.small": { + { + Engine: "mysql", + EngineVersion: "5.7.44", + InstanceClass: "db.t3.small", + Region: "us-east-1", + }, + { + Engine: "mysql", + EngineVersion: "5.7.42", + InstanceClass: "db.t3.small", + Region: "us-east-1", + }, + }, + }, + expectedCount: 8, // 10 - 2 extended support instances + }, + { + name: "Running instances in different region - no adjustment", + rec: common.Recommendation{ + ResourceType: "db.t3.small", + Count: 10, + Region: "us-east-1", + Details: &common.DatabaseDetails{ + Engine: "mysql", + }, + }, + instanceVersions: map[string][]InstanceEngineVersion{ + "db.t3.small": { + { + Engine: "mysql", + EngineVersion: "5.7.44", + InstanceClass: "db.t3.small", + Region: "us-west-2", // Different region + }, + }, + }, + expectedCount: 10, // No adjustment + }, + { + name: "Non-RDS recommendation - no adjustment", + rec: common.Recommendation{ + ResourceType: "t3.small", + Count: 5, + Region: "us-east-1", + Details: common.ComputeDetails{ + Platform: "Linux", + }, + }, + instanceVersions: map[string][]InstanceEngineVersion{}, + expectedCount: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := adjustRecommendationForExcludedVersions(tt.rec, tt.instanceVersions, versionInfo) + assert.Equal(t, tt.expectedCount, result.Count) + }) + } +} diff --git a/cmd/multi_service_coverage_major_versions_test.go b/cmd/multi_service_coverage_major_versions_test.go new file mode 100644 index 000000000..af4cd2dda --- /dev/null +++ b/cmd/multi_service_coverage_major_versions_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awsrds "github.com/aws/aws-sdk-go-v2/service/rds" + rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// engineKeyedRDSMajorVersionsStub implements RDSMajorVersionsClient with +// canned per-engine responses and records which engines were queried, so +// queryMajorEngineVersionsWithClient can be tested hermetically (no AWS +// credentials, no network). +type engineKeyedRDSMajorVersionsStub struct { + versionsByEngine map[string][]rdstypes.DBMajorEngineVersion + errByEngine map[string]error + enginesQueried []string +} + +func (s *engineKeyedRDSMajorVersionsStub) DescribeDBMajorEngineVersions( + _ context.Context, + params *awsrds.DescribeDBMajorEngineVersionsInput, + _ ...func(*awsrds.Options), +) (*awsrds.DescribeDBMajorEngineVersionsOutput, error) { + engine := aws.ToString(params.Engine) + s.enginesQueried = append(s.enginesQueried, engine) + if err := s.errByEngine[engine]; err != nil { + return nil, err + } + return &awsrds.DescribeDBMajorEngineVersionsOutput{ + DBMajorEngineVersions: s.versionsByEngine[engine], + }, nil +} + +// TestQueryMajorEngineVersionsWithClient_Success replaces a former +// live-credential test: it stubs the RDS client and asserts the merged +// engine:major-version map deterministically. +func TestQueryMajorEngineVersionsWithClient_Success(t *testing.T) { + stub := &engineKeyedRDSMajorVersionsStub{ + versionsByEngine: map[string][]rdstypes.DBMajorEngineVersion{ + "mysql": {rdsMajorVersion("mysql", "5.7"), rdsMajorVersion("mysql", "8.0")}, + "postgres": {rdsMajorVersion("postgres", "13")}, + }, + } + + result, err := queryMajorEngineVersionsWithClient(context.Background(), stub) + require.NoError(t, err) + + assert.ElementsMatch(t, + []string{"mysql", "postgres", "aurora-mysql", "aurora-postgresql"}, + stub.enginesQueried, + "must query every supported engine exactly once") + + require.Len(t, result, 3) + assert.Equal(t, "mysql", result["mysql:5.7"].Engine) + assert.Equal(t, "5.7", result["mysql:5.7"].MajorEngineVersion) + assert.Equal(t, "8.0", result["mysql:8.0"].MajorEngineVersion) + assert.Equal(t, "13", result["postgres:13"].MajorEngineVersion) + require.Len(t, result["postgres:13"].SupportedEngineLifecycles, 1, + "lifecycle data must be carried through from the API response") +} + +// TestQueryMajorEngineVersionsWithClient_EngineErrorContinues asserts that one +// engine failing does not stop the remaining engine queries, but the overall +// call fails: missing lifecycle data must not silently disable the +// extended-support exclusion (#2147). +func TestQueryMajorEngineVersionsWithClient_EngineErrorContinues(t *testing.T) { + stub := &engineKeyedRDSMajorVersionsStub{ + versionsByEngine: map[string][]rdstypes.DBMajorEngineVersion{ + "postgres": {rdsMajorVersion("postgres", "15")}, + }, + errByEngine: map[string]error{ + "mysql": errors.New("DescribeDBMajorEngineVersions throttled"), + }, + } + + result, err := queryMajorEngineVersionsWithClient(context.Background(), stub) + require.Error(t, err, "an unavailable engine lifecycle query must fail the call") + assert.Nil(t, result) + + assert.ElementsMatch(t, + []string{"mysql", "postgres", "aurora-mysql", "aurora-postgresql"}, + stub.enginesQueried, + "a failing engine must not stop the remaining engine queries") +} + +// TestQueryMajorEngineVersions_ProfileSelection asserts validation-profile +// precedence deterministically: the configured profiles point at names +// guaranteed not to exist in the (redirected, empty) shared config, so +// config loading fails fast without any live AWS call and the error names +// the profile the function actually selected. +func TestQueryMajorEngineVersions_ProfileSelection(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + profile string + validationProfile string + wantProfileInErr string + }{ + { + name: "validation profile takes precedence", + profile: "cudly-test-main-profile-does-not-exist", + validationProfile: "cudly-test-validation-profile-does-not-exist", + wantProfileInErr: "cudly-test-validation-profile-does-not-exist", + }, + { + name: "falls back to main profile", + profile: "cudly-test-main-profile-does-not-exist", + wantProfileInErr: "cudly-test-main-profile-does-not-exist", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("AWS_CONFIG_FILE", os.DevNull) + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", os.DevNull) + + _, err := queryMajorEngineVersions(ctx, Config{ + Profile: tt.profile, + ValidationProfile: tt.validationProfile, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load AWS config") + assert.Contains(t, err.Error(), tt.wantProfileInErr, + "error must name the selected profile") + }) + } +} diff --git a/cmd/multi_service_coverage_test.go b/cmd/multi_service_coverage_test.go index 813fc874d..71e1e7d85 100644 --- a/cmd/multi_service_coverage_test.go +++ b/cmd/multi_service_coverage_test.go @@ -2,16 +2,12 @@ package main import ( "context" - "errors" "os" "testing" - "time" "github.com/LeanerCloud/cloud-commitments-go/pkg/common" awsprovider "github.com/LeanerCloud/cloud-commitments-go/providers/aws" "github.com/aws/aws-sdk-go-v2/aws" - awsrds "github.com/aws/aws-sdk-go-v2/service/rds" - rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,128 +18,6 @@ import ( // ==================== Tests for queryMajorEngineVersions ==================== -// engineKeyedRDSMajorVersionsStub implements RDSMajorVersionsClient with -// canned per-engine responses and records which engines were queried, so -// queryMajorEngineVersionsWithClient can be tested hermetically (no AWS -// credentials, no network). -type engineKeyedRDSMajorVersionsStub struct { - versionsByEngine map[string][]rdstypes.DBMajorEngineVersion - errByEngine map[string]error - enginesQueried []string -} - -func (s *engineKeyedRDSMajorVersionsStub) DescribeDBMajorEngineVersions( - _ context.Context, - params *awsrds.DescribeDBMajorEngineVersionsInput, - _ ...func(*awsrds.Options), -) (*awsrds.DescribeDBMajorEngineVersionsOutput, error) { - engine := aws.ToString(params.Engine) - s.enginesQueried = append(s.enginesQueried, engine) - if err := s.errByEngine[engine]; err != nil { - return nil, err - } - return &awsrds.DescribeDBMajorEngineVersionsOutput{ - DBMajorEngineVersions: s.versionsByEngine[engine], - }, nil -} - -// TestQueryMajorEngineVersionsWithClient_Success replaces a former -// live-credential test: it stubs the RDS client and asserts the merged -// engine:major-version map deterministically. -func TestQueryMajorEngineVersionsWithClient_Success(t *testing.T) { - stub := &engineKeyedRDSMajorVersionsStub{ - versionsByEngine: map[string][]rdstypes.DBMajorEngineVersion{ - "mysql": {rdsMajorVersion("mysql", "5.7"), rdsMajorVersion("mysql", "8.0")}, - "postgres": {rdsMajorVersion("postgres", "13")}, - }, - } - - result, err := queryMajorEngineVersionsWithClient(context.Background(), stub) - require.NoError(t, err) - - assert.ElementsMatch(t, - []string{"mysql", "postgres", "aurora-mysql", "aurora-postgresql"}, - stub.enginesQueried, - "must query every supported engine exactly once") - - require.Len(t, result, 3) - assert.Equal(t, "mysql", result["mysql:5.7"].Engine) - assert.Equal(t, "5.7", result["mysql:5.7"].MajorEngineVersion) - assert.Equal(t, "8.0", result["mysql:8.0"].MajorEngineVersion) - assert.Equal(t, "13", result["postgres:13"].MajorEngineVersion) - require.Len(t, result["postgres:13"].SupportedEngineLifecycles, 1, - "lifecycle data must be carried through from the API response") -} - -// TestQueryMajorEngineVersionsWithClient_EngineErrorContinues asserts that one -// engine failing does not stop the remaining engine queries, but the overall -// call fails: missing lifecycle data must not silently disable the -// extended-support exclusion (#2147). -func TestQueryMajorEngineVersionsWithClient_EngineErrorContinues(t *testing.T) { - stub := &engineKeyedRDSMajorVersionsStub{ - versionsByEngine: map[string][]rdstypes.DBMajorEngineVersion{ - "postgres": {rdsMajorVersion("postgres", "15")}, - }, - errByEngine: map[string]error{ - "mysql": errors.New("DescribeDBMajorEngineVersions throttled"), - }, - } - - result, err := queryMajorEngineVersionsWithClient(context.Background(), stub) - require.Error(t, err, "an unavailable engine lifecycle query must fail the call") - assert.Nil(t, result) - - assert.ElementsMatch(t, - []string{"mysql", "postgres", "aurora-mysql", "aurora-postgresql"}, - stub.enginesQueried, - "a failing engine must not stop the remaining engine queries") -} - -// TestQueryMajorEngineVersions_ProfileSelection asserts validation-profile -// precedence deterministically: the configured profiles point at names -// guaranteed not to exist in the (redirected, empty) shared config, so -// config loading fails fast without any live AWS call and the error names -// the profile the function actually selected. -func TestQueryMajorEngineVersions_ProfileSelection(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - profile string - validationProfile string - wantProfileInErr string - }{ - { - name: "validation profile takes precedence", - profile: "cudly-test-main-profile-does-not-exist", - validationProfile: "cudly-test-validation-profile-does-not-exist", - wantProfileInErr: "cudly-test-validation-profile-does-not-exist", - }, - { - name: "falls back to main profile", - profile: "cudly-test-main-profile-does-not-exist", - wantProfileInErr: "cudly-test-main-profile-does-not-exist", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Setenv("AWS_CONFIG_FILE", os.DevNull) - t.Setenv("AWS_SHARED_CREDENTIALS_FILE", os.DevNull) - - _, err := queryMajorEngineVersions(ctx, Config{ - Profile: tt.profile, - ValidationProfile: tt.validationProfile, - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to load AWS config") - assert.Contains(t, err.Error(), tt.wantProfileInErr, - "error must name the selected profile") - }) - } -} - // ==================== Tests for extractMajorVersion ==================== func TestExtractMajorVersion_ComprehensiveTests(t *testing.T) { @@ -236,195 +110,6 @@ func TestExtractMajorVersion_ComprehensiveTests(t *testing.T) { // ==================== Tests for isInExtendedSupport ==================== -func TestIsInExtendedSupport_EdgeCases(t *testing.T) { - now := time.Now() - pastDate := now.AddDate(0, -6, 0) // 6 months ago - futureDate := now.AddDate(3, 0, 0) // 3 years from now - - versionInfo := map[string]MajorEngineVersionInfo{ - "mysql:5.7": { - Engine: "mysql", - MajorEngineVersion: "5.7", - SupportedEngineLifecycles: []EngineLifecycleInfo{ - { - LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport), - LifecycleSupportStartDate: pastDate, - LifecycleSupportEndDate: futureDate, - }, - }, - }, - "mysql:8.0": { - Engine: "mysql", - MajorEngineVersion: "8.0", - SupportedEngineLifecycles: []EngineLifecycleInfo{ - { - LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsStandardSupport), - LifecycleSupportStartDate: now.AddDate(-2, 0, 0), - LifecycleSupportEndDate: futureDate, - }, - }, - }, - } - - tests := []struct { - name string - engine string - fullVersion string - expectedExtended bool - expectedKnown bool - }{ - { - name: "MySQL 5.7 in extended support", - engine: "mysql", - fullVersion: "5.7.44", - expectedExtended: true, - expectedKnown: true, - }, - { - name: "MySQL 8.0 in standard support", - engine: "mysql", - fullVersion: "8.0.35", - expectedExtended: false, - expectedKnown: true, - }, - { - name: "Unknown version", - engine: "mysql", - fullVersion: "9.0.0", - expectedExtended: false, - expectedKnown: false, - }, - { - name: "Empty version", - engine: "mysql", - fullVersion: "", - expectedExtended: false, - expectedKnown: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - extended, known := isInExtendedSupport(tt.engine, tt.fullVersion, versionInfo) - assert.Equal(t, tt.expectedExtended, extended) - assert.Equal(t, tt.expectedKnown, known, "known flag mismatch") - }) - } -} - -// ==================== Tests for adjustRecommendationForExcludedVersions ==================== - -func TestAdjustRecommendationForExcludedVersions_AdditionalCases(t *testing.T) { - now := time.Now() - pastDate := now.AddDate(0, -6, 0) - futureDate := now.AddDate(3, 0, 0) - - versionInfo := map[string]MajorEngineVersionInfo{ - "mysql:5.7": { - Engine: "mysql", - MajorEngineVersion: "5.7", - SupportedEngineLifecycles: []EngineLifecycleInfo{ - { - LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport), - LifecycleSupportStartDate: pastDate, - LifecycleSupportEndDate: futureDate, - }, - }, - }, - } - - tests := []struct { - instanceVersions map[string][]InstanceEngineVersion - name string - rec common.Recommendation - expectedCount int - }{ - { - name: "No running instances - no adjustment", - rec: common.Recommendation{ - ResourceType: "db.t3.small", - Count: 10, - Region: "us-east-1", - Details: &common.DatabaseDetails{ - Engine: "mysql", - }, - }, - instanceVersions: map[string][]InstanceEngineVersion{}, - expectedCount: 10, - }, - { - name: "Running instances with extended support - adjust count", - rec: common.Recommendation{ - ResourceType: "db.t3.small", - Count: 10, - Region: "us-east-1", - Details: &common.DatabaseDetails{ - Engine: "mysql", - }, - }, - instanceVersions: map[string][]InstanceEngineVersion{ - "db.t3.small": { - { - Engine: "mysql", - EngineVersion: "5.7.44", - InstanceClass: "db.t3.small", - Region: "us-east-1", - }, - { - Engine: "mysql", - EngineVersion: "5.7.42", - InstanceClass: "db.t3.small", - Region: "us-east-1", - }, - }, - }, - expectedCount: 8, // 10 - 2 extended support instances - }, - { - name: "Running instances in different region - no adjustment", - rec: common.Recommendation{ - ResourceType: "db.t3.small", - Count: 10, - Region: "us-east-1", - Details: &common.DatabaseDetails{ - Engine: "mysql", - }, - }, - instanceVersions: map[string][]InstanceEngineVersion{ - "db.t3.small": { - { - Engine: "mysql", - EngineVersion: "5.7.44", - InstanceClass: "db.t3.small", - Region: "us-west-2", // Different region - }, - }, - }, - expectedCount: 10, // No adjustment - }, - { - name: "Non-RDS recommendation - no adjustment", - rec: common.Recommendation{ - ResourceType: "t3.small", - Count: 5, - Region: "us-east-1", - Details: common.ComputeDetails{ - Platform: "Linux", - }, - }, - instanceVersions: map[string][]InstanceEngineVersion{}, - expectedCount: 5, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := adjustRecommendationForExcludedVersions(tt.rec, tt.instanceVersions, versionInfo) - assert.Equal(t, tt.expectedCount, result.Count) - }) - } -} - // ==================== Tests for validateFlags ==================== func TestValidateFlags_Coverage(t *testing.T) { diff --git a/cmd/multi_service_csv_coverage_column_test.go b/cmd/multi_service_csv_coverage_column_test.go new file mode 100644 index 000000000..ebf36b05c --- /dev/null +++ b/cmd/multi_service_csv_coverage_column_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "encoding/csv" + "os" + "strings" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWriteMultiServiceCSVReport_CoverageColumn confirms the ProjectedCoverage +// and RecommendedCount columns added for --target-coverage (#338) are emitted +// with the "blank-when-zero" formatting (matches the "0 = unknown" convention +// shared with the JSON-level omitempty tags). The sibling ProjectedUtilization +// and RecommendedUtilization fields are intentionally NOT emitted to CSV +// (both land at ~100% on every under-buy row, so they add noise without +// information). +func TestWriteMultiServiceCSVReport_CoverageColumn(t *testing.T) { + tmpDir := t.TempDir() + filepath := tmpDir + "/util.csv" + + results := []common.PurchaseResult{ + { + Recommendation: common.Recommendation{ + Service: common.ServiceEC2, + Region: "us-east-1", + ResourceType: "t3.medium", + Count: 7, // post-sizing + RecommendedCount: 10, // AWS pre-sizing + CommitmentCost: 700, // already scaled at sizing time + Term: "1yr", + ProjectedUtilization: 95.0, + ProjectedCoverage: 87.5, + ExistingCoveragePct: 20.0, + ExistingCoverageKnown: true, + RecommendedUtilization: 80.0, + AverageInstancesUsedPerHour: 10.0, + // Pointer form matches the live parser (parser_services.go + // stores &common.ComputeDetails{...}); extractEngine must + // handle both pointer and value Details. + Details: &common.ComputeDetails{Platform: "Linux/UNIX"}, + }, + Success: true, + }, + { + // All sizing-related fields zero — ProjectedCoverage and + // RecommendedCount cells should both be blank (SP rec or a + // pre-target rec that never went through sizing). UpfrontPayment + // is also blank when CommitmentCost is zero. No Details either, + // so the Engine column is blank. + Recommendation: common.Recommendation{ + Service: common.ServiceEC2, + Region: "us-east-1", + ResourceType: "m5.large", + Count: 5, + Term: "1yr", + }, + Success: true, + }, + } + + err := writeMultiServiceCSVReport(results, filepath) + require.NoError(t, err) + + content, err := os.ReadFile(filepath) + require.NoError(t, err) + csvText := string(content) + + // Header contains ProjectedCoverage, ExistingCoverage, RecommendedCount, + // and UpfrontPayment but NOT the always-100% utilization siblings. + assert.Contains(t, csvText, "ProjectedCoverage") + assert.Contains(t, csvText, "ExistingCoverage") + assert.Contains(t, csvText, "RecommendedCount") + assert.Contains(t, csvText, "UpfrontPayment") + assert.NotContains(t, csvText, "ProjectedUtilization", "column was removed; it's ~100% on every under-buy row") + assert.NotContains(t, csvText, "RecommendedUtilization", "column was removed; it's ~99-100% on every row") + + // First data row (populated rec) has the coverage and AWS-count values. + assert.Contains(t, csvText, "87.5", "ProjectedCoverage should render with one decimal") + + r := csv.NewReader(strings.NewReader(csvText)) + rows, err := r.ReadAll() + require.NoError(t, err) + // Header + 2 data rows + 1 TOTAL row. + require.Len(t, rows, 4) + // TOTAL row sits at the bottom with "TOTAL" in the Service column and + // summed Count / UpfrontPayment / EstimatedSavings. + totalRow := rows[3] + assert.Equal(t, "TOTAL", totalRow[0], "TOTAL label lands in Service column") + // Data rows are sorted by UpfrontPayment DESC; populated rec ($700) + // comes before the empty rec ($0). + header := rows[0] + idxProjCov, idxRecCount, idxUpfront, idxExisting := -1, -1, -1, -1 + idxEngine, idxInstances, idxCovered := -1, -1, -1 + for i, h := range header { + switch h { + case "ProjectedCoverage": + idxProjCov = i + case "RecommendedCount": + idxRecCount = i + case "UpfrontPayment": + idxUpfront = i + case "ExistingCoverage": + idxExisting = i + case "Engine": + idxEngine = i + case "Instances": + idxInstances = i + case "CoveredInstances": + idxCovered = i + } + } + require.NotEqual(t, -1, idxProjCov, "ProjectedCoverage column not found") + require.NotEqual(t, -1, idxRecCount, "RecommendedCount column not found") + require.NotEqual(t, -1, idxUpfront, "UpfrontPayment column not found") + require.NotEqual(t, -1, idxExisting, "ExistingCoverage column not found") + require.NotEqual(t, -1, idxEngine, "Engine column not found") + require.NotEqual(t, -1, idxInstances, "Instances column not found") + require.NotEqual(t, -1, idxCovered, "CoveredInstances column not found") + + // Populated row: RecommendedCount=10 renders as "10", UpfrontPayment + // emits CommitmentCost as-is (sizing already scaled it; see + // ApplyTargetCoverage), ProjectedCoverage=87.5 renders, ExistingCoverage=20.0, + // Engine pulled from *ComputeDetails.Platform. Instances = avg = 10.0. + // CoveredInstances = 10.0 × 20% = 2.0. + populatedRow := rows[1] + assert.Equal(t, "10", populatedRow[idxRecCount], "RecommendedCount should render as decimal") + assert.Equal(t, "700.00", populatedRow[idxUpfront], "UpfrontPayment should render rec.CommitmentCost as-is") + assert.Equal(t, "87.5", populatedRow[idxProjCov]) + assert.Equal(t, "20.0", populatedRow[idxExisting], "ExistingCoverage should render with one decimal") + assert.Equal(t, "Linux/UNIX", populatedRow[idxEngine], "Engine should pull from *ComputeDetails.Platform") + assert.Equal(t, "10.0", populatedRow[idxInstances], "Instances should render avg with one decimal") + assert.Equal(t, "2.0", populatedRow[idxCovered], "CoveredInstances = avg * existing_cov / 100") + + // Zero-fields row: optional cells blank, Engine blank when Details is nil. + // ExistingCoverage shows "n/a" because ExistingCoverageKnown wasn't set: + // CE had no data for this pool (distinct from "0% covered", which would + // be ExistingCoverageKnown=true, Pct=0 rendering as "0.0"). + zeroRow := rows[2] + assert.Equal(t, "", zeroRow[idxProjCov], "zero ProjectedCoverage should be blank") + assert.Equal(t, "", zeroRow[idxRecCount], "zero RecommendedCount should be blank (SP rec or pre-sizing)") + assert.Equal(t, "", zeroRow[idxUpfront], "zero CommitmentCost should leave UpfrontPayment blank") + assert.Equal(t, "n/a", zeroRow[idxExisting], "ExistingCoverage should render n/a when CE had no signal") + assert.Equal(t, "", zeroRow[idxEngine], "missing Details should leave Engine blank") + assert.Equal(t, "", zeroRow[idxInstances], "zero avg should leave Instances blank") + assert.Equal(t, "", zeroRow[idxCovered], "missing avg or existing_cov should leave CoveredInstances blank") +} diff --git a/cmd/multi_service_csv_formatters_test.go b/cmd/multi_service_csv_formatters_test.go new file mode 100644 index 000000000..fa7e03812 --- /dev/null +++ b/cmd/multi_service_csv_formatters_test.go @@ -0,0 +1,195 @@ +package main + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +// TestFormatExistingCoverage locks the three-state rendering: +// - ExistingCoverageKnown=false → "n/a" (CE has no data for this pool) +// - ExistingCoverageKnown=true, Pct=0 → "0.0" (CE confirms zero coverage) +// - ExistingCoverageKnown=true, Pct>0 → formatted with one decimal +// +// Critical for operators interpreting the column: a blank or zero cell +// previously meant either "CE was queried but returned 0%" or "CE +// returned nothing", with no way to tell which. +func TestFormatExistingCoverage(t *testing.T) { + tests := []struct { + name string + rec common.Recommendation + want string + }{ + {"unknown (CE no data)", common.Recommendation{}, "n/a"}, + {"known zero coverage", common.Recommendation{ExistingCoverageKnown: true}, "0.0"}, + {"known partial coverage", common.Recommendation{ExistingCoverageKnown: true, ExistingCoveragePct: 37.74}, "37.7"}, + {"known full coverage", common.Recommendation{ExistingCoverageKnown: true, ExistingCoveragePct: 100.0}, "100.0"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, formatExistingCoverage(tt.rec)) + }) + } +} + +// TestFormatRecurringMonthlyOrBlank locks the nil-vs-zero distinction: +// nil pointer (AWS API didn't return RecurringStandardMonthlyCost) +// renders as blank, zero value (genuinely no monthly fee, e.g. +// all-upfront RIs) renders as "0.00". Operators need to tell "we don't +// know" apart from "definitely zero" to compute total cost correctly. +func TestFormatRecurringMonthlyOrBlank(t *testing.T) { + zero := 0.0 + twenty := 20.5 + tests := []struct { + name string + in *float64 + want string + }{ + {"nil → blank (unknown)", nil, ""}, + {"zero pointer → 0.00 (definitely zero)", &zero, "0.00"}, + {"non-zero → formatted", &twenty, "20.50"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, formatRecurringMonthlyOrBlank(tt.in)) + }) + } +} + +// TestExtractFamily covers the CSV Family column: the RDS family prefix and +// the Savings Plan instance family. +func TestExtractFamily(t *testing.T) { + tests := []struct { + name string + rec common.Recommendation + want string + }{ + {"RDS db.r7g.large", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large"}, "db.r7g"}, + {"RDS db.t4g.medium", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.medium"}, "db.t4g"}, + {"RelationalDB alias", common.Recommendation{Service: common.ServiceRelationalDB, ResourceType: "db.m5.xlarge"}, "db.m5"}, + // Non-RDS services blank even when ResourceType looks RDS-shaped. + {"EC2 ignored", common.Recommendation{Service: common.ServiceEC2, ResourceType: "m5.large"}, ""}, + {"ElastiCache ignored", common.Recommendation{Service: common.ServiceElastiCache, ResourceType: "cache.t3.micro"}, ""}, + // Malformed RDS type. + {"RDS bare type", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g"}, ""}, + {"EC2 Instance SP", common.Recommendation{Service: common.ServiceSavingsPlansEC2Instance, Details: &common.SavingsPlanDetails{InstanceFamily: "m5"}}, "m5"}, + {"Compute SP has no family", common.Recommendation{Service: common.ServiceSavingsPlansCompute, Details: &common.SavingsPlanDetails{}}, ""}, + {"nil SP details", common.Recommendation{Service: common.ServiceSavingsPlansCompute, Details: (*common.SavingsPlanDetails)(nil)}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, extractFamily(tt.rec)) + }) + } +} + +// TestFormatNormalizedUnitsOrBlank confirms NU values land for RDS rows +// with known sizes and stay blank for non-RDS / zero-count / unknown-size +// inputs, matching the "0/empty = unknown" convention used elsewhere. +func TestFormatNormalizedUnitsOrBlank(t *testing.T) { + tests := []struct { + name string + rec common.Recommendation + want string + }{ + // 15 × db.r7g.large = 15 × 4 NU = 60 NU + {"r7g.large × 15", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large", Count: 15}, "60"}, + // 3 × db.t4g.medium = 3 × 2 NU = 6 NU + {"t4g.medium × 3", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.medium", Count: 3}, "6"}, + // Fractional NU survives via %g (db.t4g.micro = 0.5 NU) + {"t4g.micro × 3", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.micro", Count: 3}, "1.5"}, + // Non-RDS service → blank + {"EC2 row blank", common.Recommendation{Service: common.ServiceEC2, ResourceType: "m5.large", Count: 5}, ""}, + // Zero count → blank + {"zero count blank", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large", Count: 0}, ""}, + // Unknown size → blank + {"unknown size blank", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.bogus", Count: 5}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, formatNormalizedUnitsOrBlank(tt.rec)) + }) + } +} + +// TestExtractDeployment covers the deployment-extraction helper used by +// the RDS row in the CSV. Single-AZ / Multi-AZ is critical context for +// pricing verification (Multi-AZ list price is ~2x Single-AZ) so the +// column should land for every RDS rec. DatabaseDetails is always a +// pointer (every producer constructs it that way); no value-typed case +// is needed. +func TestExtractDeployment(t *testing.T) { + tests := []struct { + name string + rec common.Recommendation + want string + }{ + {"*DatabaseDetails Single-AZ", common.Recommendation{Details: &common.DatabaseDetails{AZConfig: "single-az"}}, "single-az"}, + {"*DatabaseDetails Multi-AZ", common.Recommendation{Details: &common.DatabaseDetails{AZConfig: "multi-az"}}, "multi-az"}, + {"DatabaseDetails empty AZConfig", common.Recommendation{Details: &common.DatabaseDetails{Engine: "mysql"}}, ""}, + // Non-RDS Details → blank (column is RDS-only data). + {"CacheDetails -> empty", common.Recommendation{Details: &common.CacheDetails{Engine: "redis"}}, ""}, + {"ComputeDetails -> empty", common.Recommendation{Details: &common.ComputeDetails{Platform: "Linux/UNIX"}}, ""}, + {"nil Details -> empty", common.Recommendation{}, ""}, + {"nil *DatabaseDetails -> empty", common.Recommendation{Details: (*common.DatabaseDetails)(nil)}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, extractDeployment(tt.rec)) + }) + } +} + +// TestExtractEngine covers the four cases the helper dispatches on: +// DatabaseDetails (RDS engine), CacheDetails (ElastiCache engine), +// ComputeDetails (EC2 platform), and unset/other Details (blank). +// DatabaseDetails/CacheDetails are always pointers (every producer +// constructs them that way); ComputeDetails still accepts a value because +// the Azure compute client and the GCP compute-engine client both construct +// it that way. +func TestExtractEngine(t *testing.T) { + tests := []struct { + name string + rec common.Recommendation + want string + }{ + {"*DatabaseDetails -> Engine", common.Recommendation{Details: &common.DatabaseDetails{Engine: "aurora-postgresql"}}, "aurora-postgresql"}, + {"*CacheDetails -> Engine", common.Recommendation{Details: &common.CacheDetails{Engine: "redis"}}, "redis"}, + {"*ComputeDetails -> Platform", common.Recommendation{Details: &common.ComputeDetails{Platform: "Linux/UNIX"}}, "Linux/UNIX"}, + // The Azure compute client and GCP's compute-engine client both + // still construct ComputeDetails as a value; keep coverage for + // that form. + {"ComputeDetails (value) -> Platform", common.Recommendation{Details: common.ComputeDetails{Platform: "Windows"}}, "Windows"}, + // Fallbacks. + {"nil Details -> empty", common.Recommendation{}, ""}, + {"SavingsPlanDetails -> empty", common.Recommendation{Details: &common.SavingsPlanDetails{HourlyCommitment: 1.0}}, ""}, + {"nil *DatabaseDetails -> empty", common.Recommendation{Details: (*common.DatabaseDetails)(nil)}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, extractEngine(tt.rec)) + }) + } +} + +// TestFormatCurrencyOrBlank locks the blank-when-zero behavior for the +// UpfrontPayment column. Non-zero renders with two decimals; zero renders +// as an empty cell so users can distinguish "no upfront due" from "actual +// $0 upfront", consistent with the rest of the optional CSV columns. +func TestFormatCurrencyOrBlank(t *testing.T) { + tests := []struct { + name string + in float64 + want string + }{ + {"non-zero renders with two decimals", 1234.56, "1234.56"}, + {"integer value gets .00", 700, "700.00"}, + {"zero blanks the cell", 0, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, formatCurrencyOrBlank(tt.in)) + }) + } +} diff --git a/cmd/multi_service_csv_report_test.go b/cmd/multi_service_csv_report_test.go new file mode 100644 index 000000000..4a9e232a9 --- /dev/null +++ b/cmd/multi_service_csv_report_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestWriteMultiServiceCSVReport_EmptyResults(t *testing.T) { + tmpFile := "/tmp/test_empty_results.csv" + defer os.Remove(tmpFile) + + err := writeMultiServiceCSVReport([]common.PurchaseResult{}, tmpFile) + assert.NoError(t, err) + + // File should not be created for empty results + _, err = os.Stat(tmpFile) + assert.True(t, os.IsNotExist(err)) +} + +func TestWriteMultiServiceCSVReport_Success(t *testing.T) { + tmpFile := "/tmp/test_csv_success.csv" + defer os.Remove(tmpFile) + + results := []common.PurchaseResult{ + { + Recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-east-1", + ResourceType: "db.t3.small", + Count: 2, + Account: "123456789012", + AccountName: "test-account", + Term: "1yr", + PaymentOption: "all-upfront", + EstimatedSavings: 100.50, + }, + Success: true, + CommitmentID: "test-commitment-id", + Timestamp: time.Now(), + }, + } + + err := writeMultiServiceCSVReport(results, tmpFile) + assert.NoError(t, err) + + // Verify file was created and has content + content, err := os.ReadFile(tmpFile) + assert.NoError(t, err) + assert.Contains(t, string(content), "Service,Region,ResourceType") + assert.Contains(t, string(content), "rds") + assert.Contains(t, string(content), "us-east-1") + assert.Contains(t, string(content), "db.t3.small") + assert.Contains(t, string(content), "test-commitment-id") +} + +func TestWriteMultiServiceCSVReport_WithError(t *testing.T) { + tmpFile := "/tmp/test_csv_with_error.csv" + defer os.Remove(tmpFile) + + results := []common.PurchaseResult{ + { + Recommendation: common.Recommendation{ + Service: common.ServiceEC2, + Region: "us-west-2", + ResourceType: "t3.medium", + Count: 1, + }, + Success: false, + CommitmentID: "", + Error: fmt.Errorf("purchase failed: insufficient quota"), + Timestamp: time.Now(), + }, + } + + err := writeMultiServiceCSVReport(results, tmpFile) + assert.NoError(t, err) + + // Verify error is included in CSV + content, err := os.ReadFile(tmpFile) + assert.NoError(t, err) + assert.Contains(t, string(content), "false") + assert.Contains(t, string(content), "purchase failed: insufficient quota") +} + +func TestWriteMultiServiceCSVReport_InvalidPath(t *testing.T) { + // Use an invalid path (directory that doesn't exist) + invalidPath := "/nonexistent/directory/test.csv" + + results := []common.PurchaseResult{ + { + Recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-east-1", + ResourceType: "db.t3.small", + Count: 1, + }, + Success: true, + Timestamp: time.Now(), + }, + } + + err := writeMultiServiceCSVReport(results, invalidPath) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create CSV file") +} + +func TestWriteMultiServiceCSVReport_MultipleResults(t *testing.T) { + tmpFile := "/tmp/test_csv_multiple.csv" + defer os.Remove(tmpFile) + + results := []common.PurchaseResult{ + { + Recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Region: "us-east-1", + ResourceType: "db.t3.small", + Count: 2, + EstimatedSavings: 150.25, + }, + Success: true, + CommitmentID: "commitment-1", + Timestamp: time.Now(), + }, + { + Recommendation: common.Recommendation{ + Service: common.ServiceElastiCache, + Region: "us-west-2", + ResourceType: "cache.t3.micro", + Count: 3, + EstimatedSavings: 75.50, + }, + Success: true, + CommitmentID: "commitment-2", + Timestamp: time.Now(), + }, + } + + err := writeMultiServiceCSVReport(results, tmpFile) + assert.NoError(t, err) + + // Verify both results are in CSV + content, err := os.ReadFile(tmpFile) + assert.NoError(t, err) + assert.Contains(t, string(content), "commitment-1") + assert.Contains(t, string(content), "commitment-2") + assert.Contains(t, string(content), "150.25") + assert.Contains(t, string(content), "75.50") +} diff --git a/cmd/multi_service_csv_test.go b/cmd/multi_service_csv_test.go index 69470964a..be450f6aa 100644 --- a/cmd/multi_service_csv_test.go +++ b/cmd/multi_service_csv_test.go @@ -1,14 +1,9 @@ package main import ( - "bytes" - "encoding/csv" "errors" "os" "path/filepath" - "slices" - "strconv" - "strings" "testing" "time" @@ -200,455 +195,6 @@ func TestWriteMultiServiceCSVReport(t *testing.T) { } } -// TestWriteMultiServiceCSVReport_CoverageColumn confirms the ProjectedCoverage -// and RecommendedCount columns added for --target-coverage (#338) are emitted -// with the "blank-when-zero" formatting (matches the "0 = unknown" convention -// shared with the JSON-level omitempty tags). The sibling ProjectedUtilization -// and RecommendedUtilization fields are intentionally NOT emitted to CSV -// (both land at ~100% on every under-buy row, so they add noise without -// information). -func TestWriteMultiServiceCSVReport_CoverageColumn(t *testing.T) { - tmpDir := t.TempDir() - filepath := tmpDir + "/util.csv" - - results := []common.PurchaseResult{ - { - Recommendation: common.Recommendation{ - Service: common.ServiceEC2, - Region: "us-east-1", - ResourceType: "t3.medium", - Count: 7, // post-sizing - RecommendedCount: 10, // AWS pre-sizing - CommitmentCost: 700, // already scaled at sizing time - Term: "1yr", - ProjectedUtilization: 95.0, - ProjectedCoverage: 87.5, - ExistingCoveragePct: 20.0, - ExistingCoverageKnown: true, - RecommendedUtilization: 80.0, - AverageInstancesUsedPerHour: 10.0, - // Pointer form matches the live parser (parser_services.go - // stores &common.ComputeDetails{...}); extractEngine must - // handle both pointer and value Details. - Details: &common.ComputeDetails{Platform: "Linux/UNIX"}, - }, - Success: true, - }, - { - // All sizing-related fields zero — ProjectedCoverage and - // RecommendedCount cells should both be blank (SP rec or a - // pre-target rec that never went through sizing). UpfrontPayment - // is also blank when CommitmentCost is zero. No Details either, - // so the Engine column is blank. - Recommendation: common.Recommendation{ - Service: common.ServiceEC2, - Region: "us-east-1", - ResourceType: "m5.large", - Count: 5, - Term: "1yr", - }, - Success: true, - }, - } - - err := writeMultiServiceCSVReport(results, filepath) - require.NoError(t, err) - - content, err := os.ReadFile(filepath) - require.NoError(t, err) - csvText := string(content) - - // Header contains ProjectedCoverage, ExistingCoverage, RecommendedCount, - // and UpfrontPayment but NOT the always-100% utilization siblings. - assert.Contains(t, csvText, "ProjectedCoverage") - assert.Contains(t, csvText, "ExistingCoverage") - assert.Contains(t, csvText, "RecommendedCount") - assert.Contains(t, csvText, "UpfrontPayment") - assert.NotContains(t, csvText, "ProjectedUtilization", "column was removed; it's ~100% on every under-buy row") - assert.NotContains(t, csvText, "RecommendedUtilization", "column was removed; it's ~99-100% on every row") - - // First data row (populated rec) has the coverage and AWS-count values. - assert.Contains(t, csvText, "87.5", "ProjectedCoverage should render with one decimal") - - r := csv.NewReader(strings.NewReader(csvText)) - rows, err := r.ReadAll() - require.NoError(t, err) - // Header + 2 data rows + 1 TOTAL row. - require.Len(t, rows, 4) - // TOTAL row sits at the bottom with "TOTAL" in the Service column and - // summed Count / UpfrontPayment / EstimatedSavings. - totalRow := rows[3] - assert.Equal(t, "TOTAL", totalRow[0], "TOTAL label lands in Service column") - // Data rows are sorted by UpfrontPayment DESC; populated rec ($700) - // comes before the empty rec ($0). - header := rows[0] - idxProjCov, idxRecCount, idxUpfront, idxExisting := -1, -1, -1, -1 - idxEngine, idxInstances, idxCovered := -1, -1, -1 - for i, h := range header { - switch h { - case "ProjectedCoverage": - idxProjCov = i - case "RecommendedCount": - idxRecCount = i - case "UpfrontPayment": - idxUpfront = i - case "ExistingCoverage": - idxExisting = i - case "Engine": - idxEngine = i - case "Instances": - idxInstances = i - case "CoveredInstances": - idxCovered = i - } - } - require.NotEqual(t, -1, idxProjCov, "ProjectedCoverage column not found") - require.NotEqual(t, -1, idxRecCount, "RecommendedCount column not found") - require.NotEqual(t, -1, idxUpfront, "UpfrontPayment column not found") - require.NotEqual(t, -1, idxExisting, "ExistingCoverage column not found") - require.NotEqual(t, -1, idxEngine, "Engine column not found") - require.NotEqual(t, -1, idxInstances, "Instances column not found") - require.NotEqual(t, -1, idxCovered, "CoveredInstances column not found") - - // Populated row: RecommendedCount=10 renders as "10", UpfrontPayment - // emits CommitmentCost as-is (sizing already scaled it; see - // ApplyTargetCoverage), ProjectedCoverage=87.5 renders, ExistingCoverage=20.0, - // Engine pulled from *ComputeDetails.Platform. Instances = avg = 10.0. - // CoveredInstances = 10.0 × 20% = 2.0. - populatedRow := rows[1] - assert.Equal(t, "10", populatedRow[idxRecCount], "RecommendedCount should render as decimal") - assert.Equal(t, "700.00", populatedRow[idxUpfront], "UpfrontPayment should render rec.CommitmentCost as-is") - assert.Equal(t, "87.5", populatedRow[idxProjCov]) - assert.Equal(t, "20.0", populatedRow[idxExisting], "ExistingCoverage should render with one decimal") - assert.Equal(t, "Linux/UNIX", populatedRow[idxEngine], "Engine should pull from *ComputeDetails.Platform") - assert.Equal(t, "10.0", populatedRow[idxInstances], "Instances should render avg with one decimal") - assert.Equal(t, "2.0", populatedRow[idxCovered], "CoveredInstances = avg * existing_cov / 100") - - // Zero-fields row: optional cells blank, Engine blank when Details is nil. - // ExistingCoverage shows "n/a" because ExistingCoverageKnown wasn't set: - // CE had no data for this pool (distinct from "0% covered", which would - // be ExistingCoverageKnown=true, Pct=0 rendering as "0.0"). - zeroRow := rows[2] - assert.Equal(t, "", zeroRow[idxProjCov], "zero ProjectedCoverage should be blank") - assert.Equal(t, "", zeroRow[idxRecCount], "zero RecommendedCount should be blank (SP rec or pre-sizing)") - assert.Equal(t, "", zeroRow[idxUpfront], "zero CommitmentCost should leave UpfrontPayment blank") - assert.Equal(t, "n/a", zeroRow[idxExisting], "ExistingCoverage should render n/a when CE had no signal") - assert.Equal(t, "", zeroRow[idxEngine], "missing Details should leave Engine blank") - assert.Equal(t, "", zeroRow[idxInstances], "zero avg should leave Instances blank") - assert.Equal(t, "", zeroRow[idxCovered], "missing avg or existing_cov should leave CoveredInstances blank") -} - -// TestWriteMultiServiceCSVReport_SortAndTotal confirms data rows are -// sorted by UpfrontPayment DESC and that a TOTAL summary row lands at -// the bottom with the column sums. Operators reading the file top-down -// want the biggest-dollar decisions surfaced first; the TOTAL row -// removes the need to copy-paste columns into a spreadsheet to add -// them up. -func TestWriteMultiServiceCSVReport_SortAndTotal(t *testing.T) { - tmpDir := t.TempDir() - fp := tmpDir + "/sort-total.csv" - - // Three recs: $5K, $20K, $1K upfront. After DESC sort the order - // should be 20K, 5K, 1K. - results := []common.PurchaseResult{ - {Recommendation: common.Recommendation{Service: "rds", ResourceType: "db.r6g.large", Count: 5, CommitmentCost: 5000, EstimatedSavings: 500}}, - {Recommendation: common.Recommendation{Service: "rds", ResourceType: "db.r6g.2xlarge", Count: 2, CommitmentCost: 20000, EstimatedSavings: 1500}}, - {Recommendation: common.Recommendation{Service: "rds", ResourceType: "db.t4g.medium", Count: 4, CommitmentCost: 1000, EstimatedSavings: 80}}, - } - require.NoError(t, writeMultiServiceCSVReport(results, fp)) - content, err := os.ReadFile(fp) - require.NoError(t, err) - - r := csv.NewReader(strings.NewReader(string(content))) - rows, err := r.ReadAll() - require.NoError(t, err) - require.Len(t, rows, 5) // header + 3 data + TOTAL - - // Find UpfrontPayment column index. - header := rows[0] - idxUpfront := -1 - idxCount := -1 - idxService := -1 - idxNU := -1 - idxSavings := -1 - for i, h := range header { - switch h { - case "UpfrontPayment": - idxUpfront = i - case "Count": - idxCount = i - case "Service": - idxService = i - case "NormalizedUnits": - idxNU = i - case "EstimatedSavings": - idxSavings = i - } - } - - // Sort order: $20K, $5K, $1K. - assert.Equal(t, "20000.00", rows[1][idxUpfront], "row 1 has the largest upfront") - assert.Equal(t, "5000.00", rows[2][idxUpfront]) - assert.Equal(t, "1000.00", rows[3][idxUpfront]) - - // TOTAL row aggregates: count=11, upfront=$26K, savings=$2,080. - // NU = 5×4 + 2×16 + 4×2 = 20 + 32 + 8 = 60. - totalRow := rows[4] - assert.Equal(t, "TOTAL", totalRow[idxService]) - assert.Equal(t, "11", totalRow[idxCount]) - assert.Equal(t, "60", totalRow[idxNU]) - assert.Equal(t, "26000.00", totalRow[idxUpfront]) - assert.Equal(t, "2080.00", totalRow[idxSavings]) -} - -// TestBuildTotalRow_NUSkipsRowsWithBlankNUCell is the regression test for -// issue #2005: the TOTAL NormalizedUnits cell must equal the sum of the -// per-row NU cells above it. ElastiCache and OpenSearch types such as -// "cache.r7g.large" have three dot-separated parts, so the NU lookup -// resolves their size suffix even though their own NU cell renders blank; -// before the fix those rows inflated the TOTAL. -func TestBuildTotalRow_NUSkipsRowsWithBlankNUCell(t *testing.T) { - results := []common.PurchaseResult{ - // 5 × db.r6g.large = 20 NU (rendered on the row). - {Recommendation: common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r6g.large", Count: 5}}, - // 2 × db.r6g.xlarge = 16 NU via the relational-db alias (rendered). - {Recommendation: common.Recommendation{Service: common.ServiceRelationalDB, ResourceType: "db.r6g.xlarge", Count: 2}}, - // Non-RDS rows whose NU cell is blank: must add nothing. - {Recommendation: common.Recommendation{Service: common.ServiceElastiCache, ResourceType: "cache.r7g.large", Count: 3}}, - {Recommendation: common.Recommendation{Service: common.ServiceOpenSearch, ResourceType: "r6g.large.search", Count: 4}}, - {Recommendation: common.Recommendation{Service: common.ServiceEC2, ResourceType: "m5.large", Count: 7}}, - } - - var buf bytes.Buffer - require.NoError(t, writeMultiServiceCSV(&buf, results)) - rows, err := csv.NewReader(&buf).ReadAll() - require.NoError(t, err) - require.Len(t, rows, len(results)+2) // header + data rows + TOTAL - - idxNU := slices.Index(rows[0], "NormalizedUnits") - require.GreaterOrEqual(t, idxNU, 0) - - var columnSum float64 - for _, row := range rows[1 : len(rows)-1] { - if row[idxNU] == "" { - continue - } - v, err := strconv.ParseFloat(row[idxNU], 64) - require.NoError(t, err) - columnSum += v - } - require.InDelta(t, 36, columnSum, 1e-9, "only the two RDS rows render NU") - - totalRow := rows[len(rows)-1] - require.Equal(t, "TOTAL", totalRow[0]) - assert.Equal(t, "36", totalRow[idxNU], "TOTAL NU must equal the sum of the visible NU cells") -} - -// TestBuildTotalRow_NUBlankWhenNoRowHasNU locks that a run with no RDS rows -// leaves the TOTAL NU cell blank instead of summing NU for blank-cell rows. -func TestBuildTotalRow_NUBlankWhenNoRowHasNU(t *testing.T) { - results := []common.PurchaseResult{ - {Recommendation: common.Recommendation{Service: common.ServiceElastiCache, ResourceType: "cache.r7g.large", Count: 3}}, - } - var buf bytes.Buffer - require.NoError(t, writeMultiServiceCSV(&buf, results)) - rows, err := csv.NewReader(&buf).ReadAll() - require.NoError(t, err) - require.Len(t, rows, 3) // header + 1 data row + TOTAL - - totalRow := rows[2] - require.Equal(t, "TOTAL", totalRow[0]) - assert.Equal(t, "3", totalRow[slices.Index(rows[0], "Count")], "Count still sums") - assert.Empty(t, totalRow[slices.Index(rows[0], "NormalizedUnits")], "NormalizedUnits stays blank when no row renders NU") -} - -// TestFormatExistingCoverage locks the three-state rendering: -// - ExistingCoverageKnown=false → "n/a" (CE has no data for this pool) -// - ExistingCoverageKnown=true, Pct=0 → "0.0" (CE confirms zero coverage) -// - ExistingCoverageKnown=true, Pct>0 → formatted with one decimal -// -// Critical for operators interpreting the column: a blank or zero cell -// previously meant either "CE was queried but returned 0%" or "CE -// returned nothing", with no way to tell which. -func TestFormatExistingCoverage(t *testing.T) { - tests := []struct { - name string - rec common.Recommendation - want string - }{ - {"unknown (CE no data)", common.Recommendation{}, "n/a"}, - {"known zero coverage", common.Recommendation{ExistingCoverageKnown: true}, "0.0"}, - {"known partial coverage", common.Recommendation{ExistingCoverageKnown: true, ExistingCoveragePct: 37.74}, "37.7"}, - {"known full coverage", common.Recommendation{ExistingCoverageKnown: true, ExistingCoveragePct: 100.0}, "100.0"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, formatExistingCoverage(tt.rec)) - }) - } -} - -// TestFormatRecurringMonthlyOrBlank locks the nil-vs-zero distinction: -// nil pointer (AWS API didn't return RecurringStandardMonthlyCost) -// renders as blank, zero value (genuinely no monthly fee, e.g. -// all-upfront RIs) renders as "0.00". Operators need to tell "we don't -// know" apart from "definitely zero" to compute total cost correctly. -func TestFormatRecurringMonthlyOrBlank(t *testing.T) { - zero := 0.0 - twenty := 20.5 - tests := []struct { - name string - in *float64 - want string - }{ - {"nil → blank (unknown)", nil, ""}, - {"zero pointer → 0.00 (definitely zero)", &zero, "0.00"}, - {"non-zero → formatted", &twenty, "20.50"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, formatRecurringMonthlyOrBlank(tt.in)) - }) - } -} - -// TestExtractFamily covers the CSV Family column: the RDS family prefix and -// the Savings Plan instance family. -func TestExtractFamily(t *testing.T) { - tests := []struct { - name string - rec common.Recommendation - want string - }{ - {"RDS db.r7g.large", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large"}, "db.r7g"}, - {"RDS db.t4g.medium", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.medium"}, "db.t4g"}, - {"RelationalDB alias", common.Recommendation{Service: common.ServiceRelationalDB, ResourceType: "db.m5.xlarge"}, "db.m5"}, - // Non-RDS services blank even when ResourceType looks RDS-shaped. - {"EC2 ignored", common.Recommendation{Service: common.ServiceEC2, ResourceType: "m5.large"}, ""}, - {"ElastiCache ignored", common.Recommendation{Service: common.ServiceElastiCache, ResourceType: "cache.t3.micro"}, ""}, - // Malformed RDS type. - {"RDS bare type", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g"}, ""}, - {"EC2 Instance SP", common.Recommendation{Service: common.ServiceSavingsPlansEC2Instance, Details: &common.SavingsPlanDetails{InstanceFamily: "m5"}}, "m5"}, - {"Compute SP has no family", common.Recommendation{Service: common.ServiceSavingsPlansCompute, Details: &common.SavingsPlanDetails{}}, ""}, - {"nil SP details", common.Recommendation{Service: common.ServiceSavingsPlansCompute, Details: (*common.SavingsPlanDetails)(nil)}, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, extractFamily(tt.rec)) - }) - } -} - -// TestFormatNormalizedUnitsOrBlank confirms NU values land for RDS rows -// with known sizes and stay blank for non-RDS / zero-count / unknown-size -// inputs, matching the "0/empty = unknown" convention used elsewhere. -func TestFormatNormalizedUnitsOrBlank(t *testing.T) { - tests := []struct { - name string - rec common.Recommendation - want string - }{ - // 15 × db.r7g.large = 15 × 4 NU = 60 NU - {"r7g.large × 15", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large", Count: 15}, "60"}, - // 3 × db.t4g.medium = 3 × 2 NU = 6 NU - {"t4g.medium × 3", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.medium", Count: 3}, "6"}, - // Fractional NU survives via %g (db.t4g.micro = 0.5 NU) - {"t4g.micro × 3", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.t4g.micro", Count: 3}, "1.5"}, - // Non-RDS service → blank - {"EC2 row blank", common.Recommendation{Service: common.ServiceEC2, ResourceType: "m5.large", Count: 5}, ""}, - // Zero count → blank - {"zero count blank", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.large", Count: 0}, ""}, - // Unknown size → blank - {"unknown size blank", common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r7g.bogus", Count: 5}, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, formatNormalizedUnitsOrBlank(tt.rec)) - }) - } -} - -// TestExtractDeployment covers the deployment-extraction helper used by -// the RDS row in the CSV. Single-AZ / Multi-AZ is critical context for -// pricing verification (Multi-AZ list price is ~2x Single-AZ) so the -// column should land for every RDS rec. DatabaseDetails is always a -// pointer (every producer constructs it that way); no value-typed case -// is needed. -func TestExtractDeployment(t *testing.T) { - tests := []struct { - name string - rec common.Recommendation - want string - }{ - {"*DatabaseDetails Single-AZ", common.Recommendation{Details: &common.DatabaseDetails{AZConfig: "single-az"}}, "single-az"}, - {"*DatabaseDetails Multi-AZ", common.Recommendation{Details: &common.DatabaseDetails{AZConfig: "multi-az"}}, "multi-az"}, - {"DatabaseDetails empty AZConfig", common.Recommendation{Details: &common.DatabaseDetails{Engine: "mysql"}}, ""}, - // Non-RDS Details → blank (column is RDS-only data). - {"CacheDetails -> empty", common.Recommendation{Details: &common.CacheDetails{Engine: "redis"}}, ""}, - {"ComputeDetails -> empty", common.Recommendation{Details: &common.ComputeDetails{Platform: "Linux/UNIX"}}, ""}, - {"nil Details -> empty", common.Recommendation{}, ""}, - {"nil *DatabaseDetails -> empty", common.Recommendation{Details: (*common.DatabaseDetails)(nil)}, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, extractDeployment(tt.rec)) - }) - } -} - -// TestExtractEngine covers the four cases the helper dispatches on: -// DatabaseDetails (RDS engine), CacheDetails (ElastiCache engine), -// ComputeDetails (EC2 platform), and unset/other Details (blank). -// DatabaseDetails/CacheDetails are always pointers (every producer -// constructs them that way); ComputeDetails still accepts a value because -// the Azure compute client and the GCP compute-engine client both construct -// it that way. -func TestExtractEngine(t *testing.T) { - tests := []struct { - name string - rec common.Recommendation - want string - }{ - {"*DatabaseDetails -> Engine", common.Recommendation{Details: &common.DatabaseDetails{Engine: "aurora-postgresql"}}, "aurora-postgresql"}, - {"*CacheDetails -> Engine", common.Recommendation{Details: &common.CacheDetails{Engine: "redis"}}, "redis"}, - {"*ComputeDetails -> Platform", common.Recommendation{Details: &common.ComputeDetails{Platform: "Linux/UNIX"}}, "Linux/UNIX"}, - // The Azure compute client and GCP's compute-engine client both - // still construct ComputeDetails as a value; keep coverage for - // that form. - {"ComputeDetails (value) -> Platform", common.Recommendation{Details: common.ComputeDetails{Platform: "Windows"}}, "Windows"}, - // Fallbacks. - {"nil Details -> empty", common.Recommendation{}, ""}, - {"SavingsPlanDetails -> empty", common.Recommendation{Details: &common.SavingsPlanDetails{HourlyCommitment: 1.0}}, ""}, - {"nil *DatabaseDetails -> empty", common.Recommendation{Details: (*common.DatabaseDetails)(nil)}, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, extractEngine(tt.rec)) - }) - } -} - -// TestFormatCurrencyOrBlank locks the blank-when-zero behavior for the -// UpfrontPayment column. Non-zero renders with two decimals; zero renders -// as an empty cell so users can distinguish "no upfront due" from "actual -// $0 upfront", consistent with the rest of the optional CSV columns. -func TestFormatCurrencyOrBlank(t *testing.T) { - tests := []struct { - name string - in float64 - want string - }{ - {"non-zero renders with two decimals", 1234.56, "1234.56"}, - {"integer value gets .00", 700, "700.00"}, - {"zero blanks the cell", 0, ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, formatCurrencyOrBlank(tt.in)) - }) - } -} - // Tests for loadRecommendationsFromCSV function. func TestLoadRecommendationsFromCSV(t *testing.T) { tests := []struct { @@ -881,106 +427,3 @@ rds,us-east-1,db.t3.micro,5,0,3yr,partial-upfront`, }) } } - -// Test loadRecommendationsFromCSV with file errors. -func TestLoadRecommendationsFromCSV_FileErrors(t *testing.T) { - tests := []struct { - name string - setup func(t *testing.T) string - errContains string - }{ - { - name: "Non-existent file", - setup: func(t *testing.T) string { - return "/nonexistent/path/to/file.csv" - }, - errContains: "failed to open CSV file", - }, - { - name: "Directory instead of file", - setup: func(t *testing.T) string { - return t.TempDir() - }, - errContains: "", - }, - { - name: "Empty file (no header)", - setup: func(t *testing.T) string { - tmpDir := t.TempDir() - csvPath := filepath.Join(tmpDir, "empty.csv") - err := os.WriteFile(csvPath, []byte(""), 0644) - require.NoError(t, err) - return csvPath - }, - errContains: "failed to read CSV header", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - path := tt.setup(t) - _, err := loadRecommendationsFromCSV(path) - assert.Error(t, err) - if tt.errContains != "" { - assert.Contains(t, err.Error(), tt.errContains) - } - }) - } -} - -// failingWriter rejects every write, standing in for a full disk or a -// network mount that fails when the buffered CSV is flushed. -type failingWriter struct{} - -func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("disk full") } - -// Issue #2004: csv.Writer buffers everything, so the bytes only reach the -// file at Flush. A Flush failure must surface instead of a nil return. -func TestWriteMultiServiceCSV_FlushErrorIsReturned(t *testing.T) { - results := []common.PurchaseResult{{ - Recommendation: common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.small", Count: 1}, - Success: true, - Timestamp: time.Now(), - }} - err := writeMultiServiceCSV(failingWriter{}, results) - require.Error(t, err) - assert.Contains(t, err.Error(), "disk full") -} - -// Issue #2004: a run with no purchase results writes no file, so it must -// not claim "CSV report written to". -func TestWriteReportAndSummary_NoResultsDoesNotClaimCSVWritten(t *testing.T) { - var logs strings.Builder - previous := AppLogger.Writer() - AppLogger.SetOutput(&logs) - t.Cleanup(func() { AppLogger.SetOutput(previous) }) - - path := filepath.Join(t.TempDir(), "report.csv") - writeReportAndSummary(nil, nil, true, Config{CSVOutput: path}, nil) - - _, statErr := os.Stat(path) - require.ErrorIs(t, statErr, os.ErrNotExist) - assert.NotContains(t, logs.String(), "CSV report written to") - assert.Contains(t, logs.String(), "CSV report not written") -} - -// Issue #2004: a failed write must not be reported as written either. -func TestWriteCSVReport_FailureDoesNotClaimCSVWritten(t *testing.T) { - var logs strings.Builder - previous := AppLogger.Writer() - AppLogger.SetOutput(&logs) - t.Cleanup(func() { AppLogger.SetOutput(previous) }) - - results := []common.PurchaseResult{{ - Recommendation: common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.small", Count: 1}, - Success: true, - Timestamp: time.Now(), - }} - writeCSVReport(results, filepath.Join(t.TempDir(), "missing-dir", "report.csv")) - assert.NotContains(t, logs.String(), "CSV report written to") - - logs.Reset() - path := filepath.Join(t.TempDir(), "report.csv") - writeCSVReport(results, path) - assert.Contains(t, logs.String(), "CSV report written to: "+path) -} diff --git a/cmd/multi_service_csv_total_row_test.go b/cmd/multi_service_csv_total_row_test.go new file mode 100644 index 000000000..128fc2b19 --- /dev/null +++ b/cmd/multi_service_csv_total_row_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "bytes" + "encoding/csv" + "os" + "slices" + "strconv" + "strings" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWriteMultiServiceCSVReport_SortAndTotal confirms data rows are +// sorted by UpfrontPayment DESC and that a TOTAL summary row lands at +// the bottom with the column sums. Operators reading the file top-down +// want the biggest-dollar decisions surfaced first; the TOTAL row +// removes the need to copy-paste columns into a spreadsheet to add +// them up. +func TestWriteMultiServiceCSVReport_SortAndTotal(t *testing.T) { + tmpDir := t.TempDir() + fp := tmpDir + "/sort-total.csv" + + // Three recs: $5K, $20K, $1K upfront. After DESC sort the order + // should be 20K, 5K, 1K. + results := []common.PurchaseResult{ + {Recommendation: common.Recommendation{Service: "rds", ResourceType: "db.r6g.large", Count: 5, CommitmentCost: 5000, EstimatedSavings: 500}}, + {Recommendation: common.Recommendation{Service: "rds", ResourceType: "db.r6g.2xlarge", Count: 2, CommitmentCost: 20000, EstimatedSavings: 1500}}, + {Recommendation: common.Recommendation{Service: "rds", ResourceType: "db.t4g.medium", Count: 4, CommitmentCost: 1000, EstimatedSavings: 80}}, + } + require.NoError(t, writeMultiServiceCSVReport(results, fp)) + content, err := os.ReadFile(fp) + require.NoError(t, err) + + r := csv.NewReader(strings.NewReader(string(content))) + rows, err := r.ReadAll() + require.NoError(t, err) + require.Len(t, rows, 5) // header + 3 data + TOTAL + + // Find UpfrontPayment column index. + header := rows[0] + idxUpfront := -1 + idxCount := -1 + idxService := -1 + idxNU := -1 + idxSavings := -1 + for i, h := range header { + switch h { + case "UpfrontPayment": + idxUpfront = i + case "Count": + idxCount = i + case "Service": + idxService = i + case "NormalizedUnits": + idxNU = i + case "EstimatedSavings": + idxSavings = i + } + } + + // Sort order: $20K, $5K, $1K. + assert.Equal(t, "20000.00", rows[1][idxUpfront], "row 1 has the largest upfront") + assert.Equal(t, "5000.00", rows[2][idxUpfront]) + assert.Equal(t, "1000.00", rows[3][idxUpfront]) + + // TOTAL row aggregates: count=11, upfront=$26K, savings=$2,080. + // NU = 5×4 + 2×16 + 4×2 = 20 + 32 + 8 = 60. + totalRow := rows[4] + assert.Equal(t, "TOTAL", totalRow[idxService]) + assert.Equal(t, "11", totalRow[idxCount]) + assert.Equal(t, "60", totalRow[idxNU]) + assert.Equal(t, "26000.00", totalRow[idxUpfront]) + assert.Equal(t, "2080.00", totalRow[idxSavings]) +} + +// TestBuildTotalRow_NUSkipsRowsWithBlankNUCell is the regression test for +// issue #2005: the TOTAL NormalizedUnits cell must equal the sum of the +// per-row NU cells above it. ElastiCache and OpenSearch types such as +// "cache.r7g.large" have three dot-separated parts, so the NU lookup +// resolves their size suffix even though their own NU cell renders blank; +// before the fix those rows inflated the TOTAL. +func TestBuildTotalRow_NUSkipsRowsWithBlankNUCell(t *testing.T) { + results := []common.PurchaseResult{ + // 5 × db.r6g.large = 20 NU (rendered on the row). + {Recommendation: common.Recommendation{Service: common.ServiceRDS, ResourceType: "db.r6g.large", Count: 5}}, + // 2 × db.r6g.xlarge = 16 NU via the relational-db alias (rendered). + {Recommendation: common.Recommendation{Service: common.ServiceRelationalDB, ResourceType: "db.r6g.xlarge", Count: 2}}, + // Non-RDS rows whose NU cell is blank: must add nothing. + {Recommendation: common.Recommendation{Service: common.ServiceElastiCache, ResourceType: "cache.r7g.large", Count: 3}}, + {Recommendation: common.Recommendation{Service: common.ServiceOpenSearch, ResourceType: "r6g.large.search", Count: 4}}, + {Recommendation: common.Recommendation{Service: common.ServiceEC2, ResourceType: "m5.large", Count: 7}}, + } + + var buf bytes.Buffer + require.NoError(t, writeMultiServiceCSV(&buf, results)) + rows, err := csv.NewReader(&buf).ReadAll() + require.NoError(t, err) + require.Len(t, rows, len(results)+2) // header + data rows + TOTAL + + idxNU := slices.Index(rows[0], "NormalizedUnits") + require.GreaterOrEqual(t, idxNU, 0) + + var columnSum float64 + for _, row := range rows[1 : len(rows)-1] { + if row[idxNU] == "" { + continue + } + v, err := strconv.ParseFloat(row[idxNU], 64) + require.NoError(t, err) + columnSum += v + } + require.InDelta(t, 36, columnSum, 1e-9, "only the two RDS rows render NU") + + totalRow := rows[len(rows)-1] + require.Equal(t, "TOTAL", totalRow[0]) + assert.Equal(t, "36", totalRow[idxNU], "TOTAL NU must equal the sum of the visible NU cells") +} + +// TestBuildTotalRow_NUBlankWhenNoRowHasNU locks that a run with no RDS rows +// leaves the TOTAL NU cell blank instead of summing NU for blank-cell rows. +func TestBuildTotalRow_NUBlankWhenNoRowHasNU(t *testing.T) { + results := []common.PurchaseResult{ + {Recommendation: common.Recommendation{Service: common.ServiceElastiCache, ResourceType: "cache.r7g.large", Count: 3}}, + } + var buf bytes.Buffer + require.NoError(t, writeMultiServiceCSV(&buf, results)) + rows, err := csv.NewReader(&buf).ReadAll() + require.NoError(t, err) + require.Len(t, rows, 3) // header + 1 data row + TOTAL + + totalRow := rows[2] + require.Equal(t, "TOTAL", totalRow[0]) + assert.Equal(t, "3", totalRow[slices.Index(rows[0], "Count")], "Count still sums") + assert.Empty(t, totalRow[slices.Index(rows[0], "NormalizedUnits")], "NormalizedUnits stays blank when no row renders NU") +} diff --git a/cmd/multi_service_csv_write_failure_test.go b/cmd/multi_service_csv_write_failure_test.go new file mode 100644 index 000000000..b00784ee1 --- /dev/null +++ b/cmd/multi_service_csv_write_failure_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test loadRecommendationsFromCSV with file errors. +func TestLoadRecommendationsFromCSV_FileErrors(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + errContains string + }{ + { + name: "Non-existent file", + setup: func(t *testing.T) string { + return "/nonexistent/path/to/file.csv" + }, + errContains: "failed to open CSV file", + }, + { + name: "Directory instead of file", + setup: func(t *testing.T) string { + return t.TempDir() + }, + errContains: "", + }, + { + name: "Empty file (no header)", + setup: func(t *testing.T) string { + tmpDir := t.TempDir() + csvPath := filepath.Join(tmpDir, "empty.csv") + err := os.WriteFile(csvPath, []byte(""), 0644) + require.NoError(t, err) + return csvPath + }, + errContains: "failed to read CSV header", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t) + _, err := loadRecommendationsFromCSV(path) + assert.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + }) + } +} + +// failingWriter rejects every write, standing in for a full disk or a +// network mount that fails when the buffered CSV is flushed. +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("disk full") } + +// Issue #2004: csv.Writer buffers everything, so the bytes only reach the +// file at Flush. A Flush failure must surface instead of a nil return. +func TestWriteMultiServiceCSV_FlushErrorIsReturned(t *testing.T) { + results := []common.PurchaseResult{{ + Recommendation: common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.small", Count: 1}, + Success: true, + Timestamp: time.Now(), + }} + err := writeMultiServiceCSV(failingWriter{}, results) + require.Error(t, err) + assert.Contains(t, err.Error(), "disk full") +} + +// Issue #2004: a run with no purchase results writes no file, so it must +// not claim "CSV report written to". +func TestWriteReportAndSummary_NoResultsDoesNotClaimCSVWritten(t *testing.T) { + var logs strings.Builder + previous := AppLogger.Writer() + AppLogger.SetOutput(&logs) + t.Cleanup(func() { AppLogger.SetOutput(previous) }) + + path := filepath.Join(t.TempDir(), "report.csv") + writeReportAndSummary(nil, nil, true, Config{CSVOutput: path}, nil) + + _, statErr := os.Stat(path) + require.ErrorIs(t, statErr, os.ErrNotExist) + assert.NotContains(t, logs.String(), "CSV report written to") + assert.Contains(t, logs.String(), "CSV report not written") +} + +// Issue #2004: a failed write must not be reported as written either. +func TestWriteCSVReport_FailureDoesNotClaimCSVWritten(t *testing.T) { + var logs strings.Builder + previous := AppLogger.Writer() + AppLogger.SetOutput(&logs) + t.Cleanup(func() { AppLogger.SetOutput(previous) }) + + results := []common.PurchaseResult{{ + Recommendation: common.Recommendation{Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.small", Count: 1}, + Success: true, + Timestamp: time.Now(), + }} + writeCSVReport(results, filepath.Join(t.TempDir(), "missing-dir", "report.csv")) + assert.NotContains(t, logs.String(), "CSV report written to") + + logs.Reset() + path := filepath.Join(t.TempDir(), "report.csv") + writeCSVReport(results, path) + assert.Contains(t, logs.String(), "CSV report written to: "+path) +} diff --git a/cmd/multi_service_determine_services_test.go b/cmd/multi_service_determine_services_test.go new file mode 100644 index 000000000..3e7674006 --- /dev/null +++ b/cmd/multi_service_determine_services_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDetermineServicesToProcess_AllServices(t *testing.T) { + cfg := Config{ + AllServices: true, + } + + result, err := determineServicesToProcess(cfg) + assert.NoError(t, err) + + // Should contain all supported services + assert.Contains(t, result, common.ServiceRDS) + assert.Contains(t, result, common.ServiceElastiCache) + assert.Contains(t, result, common.ServiceEC2) + assert.Contains(t, result, common.ServiceOpenSearch) + assert.Contains(t, result, common.ServiceRedshift) + assert.Contains(t, result, common.ServiceMemoryDB) +} + +func TestDetermineServicesToProcess_SpecificServices(t *testing.T) { + cfg := Config{ + AllServices: false, + Services: []string{"rds", "elasticache"}, + } + + result, err := determineServicesToProcess(cfg) + assert.NoError(t, err) + + assert.Equal(t, 2, len(result)) + assert.Contains(t, result, common.ServiceRDS) + assert.Contains(t, result, common.ServiceElastiCache) +} + +func TestDetermineServicesToProcess_DefaultAndInvalid(t *testing.T) { + for _, services := range [][]string{nil, {}} { + result, err := determineServicesToProcess(Config{Services: services}) + assert.NoError(t, err) + assert.Equal(t, []common.ServiceType{common.ServiceRDS}, result) + } + result, err := determineServicesToProcess(Config{Services: []string{"rds", "elasticahe"}}) + assert.ErrorContains(t, err, "elasticahe") + assert.Nil(t, result) +} + +func TestDetermineServicesToProcess_SavingsPlanTypeFilters(t *testing.T) { + allSP := []common.ServiceType{ + common.ServiceSavingsPlansCompute, common.ServiceSavingsPlansEC2Instance, + common.ServiceSavingsPlansSageMaker, common.ServiceSavingsPlansDatabase, + } + tests := []struct { + name string + cfg Config + want []common.ServiceType + wantErr string + }{ + {"no filter keeps all four", Config{Services: []string{"savingsplans"}}, allSP, ""}, + {"include narrows, case-insensitive", Config{Services: []string{"savingsplans"}, IncludeSPTypes: []string{"EC2Instance"}}, + []common.ServiceType{common.ServiceSavingsPlansEC2Instance}, ""}, + {"exclude drops", Config{Services: []string{"savingsplans"}, ExcludeSPTypes: []string{"sagemaker", "Database"}}, + []common.ServiceType{common.ServiceSavingsPlansCompute, common.ServiceSavingsPlansEC2Instance}, ""}, + {"include then exclude", Config{Services: []string{"savingsplans"}, IncludeSPTypes: []string{"compute", "ec2instance"}, ExcludeSPTypes: []string{"compute"}}, + []common.ServiceType{common.ServiceSavingsPlansEC2Instance}, ""}, + {"non-SP services untouched", Config{Services: []string{"rds", "savingsplans"}, IncludeSPTypes: []string{"compute"}}, + []common.ServiceType{common.ServiceRDS, common.ServiceSavingsPlansCompute}, ""}, + {"unknown include fails loud", Config{Services: []string{"savingsplans"}, IncludeSPTypes: []string{"ec2"}}, nil, `unknown Savings Plan type "ec2"`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := determineServicesToProcess(tc.cfg) + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} diff --git a/cmd/multi_service_engine_versions_test.go b/cmd/multi_service_engine_versions_test.go index a34155be4..9bcf1a57e 100644 --- a/cmd/multi_service_engine_versions_test.go +++ b/cmd/multi_service_engine_versions_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "log" - "strings" "testing" "time" @@ -14,342 +13,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestAdjustRecommendationForExcludedVersions(t *testing.T) { - tests := []struct { - versionInfo map[string]MajorEngineVersionInfo - instanceVersions map[string][]InstanceEngineVersion - name string - recommendation common.Recommendation - expectedCount int - expectedAdjusted bool - }{ - { - name: "No running instances - recommendation unchanged", - recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-east-1", - ResourceType: "db.r5.large", - Count: 10, - Details: &common.DatabaseDetails{ - Engine: "Aurora MySQL", - }, - }, - versionInfo: createTestVersionInfo(), - instanceVersions: map[string][]InstanceEngineVersion{}, - expectedCount: 10, - expectedAdjusted: false, - }, - { - name: "Exclude 1 MySQL 5.7 instance in extended support", - recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-east-1", - ResourceType: "db.r5.large", - Count: 10, - Details: &common.DatabaseDetails{ - Engine: "Aurora MySQL", - }, - }, - versionInfo: createTestVersionInfo(), - instanceVersions: map[string][]InstanceEngineVersion{ - "db.r5.large": { - {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, - {Engine: "aurora-mysql", EngineVersion: "8.0.mysql_aurora.3.04.0", InstanceClass: "db.r5.large", Region: "us-east-1"}, - {Engine: "aurora-mysql", EngineVersion: "8.0.mysql_aurora.3.04.0", InstanceClass: "db.r5.large", Region: "us-east-1"}, - }, - }, - expectedCount: 9, // 10 - 1 MySQL 5.7 instance in extended support - expectedAdjusted: true, - }, - { - name: "Exclude all MySQL 5.7 instances in extended support", - recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Region: "eu-west-2", - ResourceType: "db.t3.small", - Count: 2, - Details: &common.DatabaseDetails{ - Engine: "Aurora MySQL", - }, - }, - versionInfo: createTestVersionInfo(), - instanceVersions: map[string][]InstanceEngineVersion{ - "db.t3.small": { - {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.2", InstanceClass: "db.t3.small", Region: "eu-west-2"}, - {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.2", InstanceClass: "db.t3.small", Region: "eu-west-2"}, - }, - }, - expectedCount: 0, // All excluded (both in extended support) - expectedAdjusted: true, - }, - { - name: "Different engine - no adjustment", - recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-east-1", - ResourceType: "db.r5.large", - Count: 5, - Details: &common.DatabaseDetails{ - Engine: "Aurora PostgreSQL", - }, - }, - versionInfo: createTestVersionInfo(), - instanceVersions: map[string][]InstanceEngineVersion{ - "db.r5.large": { - {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, - }, - }, - expectedCount: 5, // Different engine, no adjustment - expectedAdjusted: false, - }, - { - name: "Different region - no adjustment", - recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-east-1", - ResourceType: "db.r5.large", - Count: 5, - Details: &common.DatabaseDetails{ - Engine: "Aurora MySQL", - }, - }, - versionInfo: createTestVersionInfo(), - instanceVersions: map[string][]InstanceEngineVersion{ - "db.r5.large": { - {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.1", InstanceClass: "db.r5.large", Region: "eu-west-2"}, - }, - }, - expectedCount: 5, // Different region, no adjustment - expectedAdjusted: false, - }, - { - name: "MySQL (not Aurora) with standard mysql engine name", - recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Region: "eu-west-2", - ResourceType: "db.r5.4xlarge", - Count: 8, - Details: &common.DatabaseDetails{ - Engine: "MySQL", - }, - }, - versionInfo: map[string]MajorEngineVersionInfo{ - "mysql:5.7": { - Engine: "mysql", - MajorEngineVersion: "5.7", - SupportedEngineLifecycles: []EngineLifecycleInfo{ - { - LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport), - LifecycleSupportStartDate: time.Now().AddDate(0, -6, 0), - LifecycleSupportEndDate: time.Now().AddDate(3, 0, 0), - }, - }, - }, - }, - instanceVersions: map[string][]InstanceEngineVersion{ - "db.r5.4xlarge": { - {Engine: "mysql", EngineVersion: "5.7.44", InstanceClass: "db.r5.4xlarge", Region: "eu-west-2"}, - {Engine: "mysql", EngineVersion: "8.0.35", InstanceClass: "db.r5.4xlarge", Region: "eu-west-2"}, - }, - }, - expectedCount: 7, // 8 - 1 MySQL 5.7 instance in extended support - expectedAdjusted: true, - }, - { - name: "Engine name normalization - spaces vs hyphens", - recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-west-2", - ResourceType: "db.r6g.large", - Count: 3, - Details: &common.DatabaseDetails{ - Engine: "Aurora MySQL", // Space in name - }, - }, - versionInfo: createTestVersionInfo(), - instanceVersions: map[string][]InstanceEngineVersion{ - "db.r6g.large": { - {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.12.0", InstanceClass: "db.r6g.large", Region: "us-west-2"}, // Hyphen in name - }, - }, - expectedCount: 2, // Should match despite space vs hyphen - expectedAdjusted: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := adjustRecommendationForExcludedVersions(tt.recommendation, tt.instanceVersions, tt.versionInfo) - - assert.Equal(t, tt.expectedCount, result.Count, "Instance count mismatch") - - if tt.expectedAdjusted { - assert.NotEqual(t, tt.recommendation.Count, result.Count, "Count should have been adjusted") - } else { - assert.Equal(t, tt.recommendation.Count, result.Count, "Count should not have been adjusted") - } - }) - } -} - -func TestAdjustRecommendationForExcludedVersions_MultipleVersionsInExtendedSupport(t *testing.T) { - recommendation := common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-east-1", - ResourceType: "db.r5.large", - Count: 10, - Details: &common.DatabaseDetails{ - Engine: "Aurora MySQL", - }, - } - - instanceVersions := map[string][]InstanceEngineVersion{ - "db.r5.large": { - {Engine: "aurora-mysql", EngineVersion: "5.6.mysql_aurora.1.22.5", InstanceClass: "db.r5.large", Region: "us-east-1"}, - {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, - {Engine: "aurora-mysql", EngineVersion: "8.0.mysql_aurora.3.04.0", InstanceClass: "db.r5.large", Region: "us-east-1"}, - }, - } - - // Version info with both 5.6 and 5.7 in extended support - versionInfo := map[string]MajorEngineVersionInfo{ - "aurora-mysql:5.6": { - Engine: "aurora-mysql", - MajorEngineVersion: "5.6", - SupportedEngineLifecycles: []EngineLifecycleInfo{ - { - LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport), - LifecycleSupportStartDate: time.Now().AddDate(0, -12, 0), - LifecycleSupportEndDate: time.Now().AddDate(2, 0, 0), - }, - }, - }, - "aurora-mysql:5.7": { - Engine: "aurora-mysql", - MajorEngineVersion: "5.7", - SupportedEngineLifecycles: []EngineLifecycleInfo{ - { - LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsExtendedSupport), - LifecycleSupportStartDate: time.Now().AddDate(0, -6, 0), - LifecycleSupportEndDate: time.Now().AddDate(3, 0, 0), - }, - }, - }, - } - - result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, versionInfo) - - assert.Equal(t, 8, result.Count, "Should exclude 2 instances (5.6 and 5.7 both in extended support)") -} - -func TestAdjustRecommendationForExcludedVersions_NonRDSService(t *testing.T) { - recommendation := common.Recommendation{ - Service: common.ServiceEC2, - Region: "us-east-1", - ResourceType: "m5.large", - Count: 5, - Details: nil, // Not RDS - } - - instanceVersions := map[string][]InstanceEngineVersion{} - versionInfo := createTestVersionInfo() - - result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, versionInfo) - - assert.Equal(t, 5, result.Count, "Non-RDS services should not be adjusted") -} - -// TestAdjustRecommendationForExcludedVersions_TypedNilDetails pins the typed-nil -// guard on the *common.DatabaseDetails case. A nil interface is caught by the -// caller's own nil checks, but a (*common.DatabaseDetails)(nil) stored in the -// interface reaches the type switch, and reading details.Engine there panics. -// instanceVersions must contain an entry for the rec's ResourceType so the -// function gets past its early "no running instances" return and actually -// reaches the switch. -func TestAdjustRecommendationForExcludedVersions_TypedNilDetails(t *testing.T) { - recommendation := common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-east-1", - ResourceType: "db.r5.large", - Count: 7, - Details: (*common.DatabaseDetails)(nil), - } - - instanceVersions := map[string][]InstanceEngineVersion{ - "db.r5.large": { - {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.11.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, - }, - } - - assert.NotPanics(t, func() { - result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, createTestVersionInfo()) - assert.Equal(t, 7, result.Count, "typed-nil Details must pass through unadjusted") - }) -} - -// TestAdjustRecommendationForExcludedVersions_UnknownLifecycle pins the -// issue #1313 contract: when an instance's engine/major version is missing -// from versionInfo, the instance must NOT be silently treated as "not on -// extended support". The count stays untouched (conservative skip) and a -// warning naming the engine, version, and region is logged, once per -// engine:version pair. -func TestAdjustRecommendationForExcludedVersions_UnknownLifecycle(t *testing.T) { - recommendation := common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-east-1", - ResourceType: "db.r5.large", - Count: 4, - Details: &common.DatabaseDetails{ - Engine: "MySQL", - }, - } - - // Two instances of the same unknown version plus one of another: the - // warning must be deduplicated per engine:version, not per instance. - instanceVersions := map[string][]InstanceEngineVersion{ - "db.r5.large": { - {Engine: "mysql", EngineVersion: "9.0.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, - {Engine: "mysql", EngineVersion: "9.0.1", InstanceClass: "db.r5.large", Region: "us-east-1"}, - {Engine: "mysql", EngineVersion: "10.1.0", InstanceClass: "db.r5.large", Region: "us-east-1"}, - }, - } - - // versionInfo has data for mysql:8.0 only, so 9.x and 10.x lookups miss. - versionInfo := map[string]MajorEngineVersionInfo{ - "mysql:8.0": { - Engine: "mysql", - MajorEngineVersion: "8.0", - SupportedEngineLifecycles: []EngineLifecycleInfo{ - { - LifecycleSupportName: string(rdstypes.LifecycleSupportNameOpenSourceRdsStandardSupport), - LifecycleSupportStartDate: time.Now().AddDate(-2, 0, 0), - LifecycleSupportEndDate: time.Now().AddDate(3, 0, 0), - }, - }, - }, - } - - var logBuf bytes.Buffer - origOutput := log.Writer() - origFlags := log.Flags() - log.SetOutput(&logBuf) - log.SetFlags(0) - defer func() { - log.SetOutput(origOutput) - log.SetFlags(origFlags) - }() - - result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, versionInfo) - - assert.Equal(t, 4, result.Count, "unknown lifecycle status must leave the count untouched") - - logged := logBuf.String() - assert.Contains(t, logged, "mysql 9.0.1", "warning must name the unknown engine and version") - assert.Contains(t, logged, "mysql 10.1.0", "warning must name the unknown engine and version") - assert.Contains(t, logged, "us-east-1", "warning must name the region") - assert.Equal(t, 2, strings.Count(logged, "No lifecycle data for"), "warning must be logged once per engine:version, not per instance") -} - func TestExtractMajorVersion_Additional(t *testing.T) { tests := []struct { name string @@ -409,189 +72,6 @@ func TestExtractMajorVersion_Additional(t *testing.T) { } } -// Comprehensive tests for extractMajorVersion function. -func TestExtractMajorVersion_Comprehensive(t *testing.T) { - tests := []struct { - name string - engine string - version string - expected string - }{ - // Aurora MySQL special formats - { - name: "Aurora MySQL 2.x format (MySQL 5.7 compatible)", - engine: "aurora-mysql", - version: "mysql_aurora.2.11.1", - expected: "5.7", - }, - { - name: "Aurora MySQL 3.x format (MySQL 8.0 compatible)", - engine: "aurora-mysql", - version: "mysql_aurora.3.04.0", - expected: "8.0", - }, - { - name: "Aurora MySQL 2.x with full version", - engine: "aurora-mysql", - version: "5.7.mysql_aurora.2.12.0", - expected: "5.7", - }, - { - name: "Aurora MySQL 3.x with full version", - engine: "aurora-mysql", - version: "8.0.mysql_aurora.3.05.2", - expected: "8.0", - }, - { - name: "Aurora MySQL with only major.minor", - engine: "aurora-mysql", - version: "5.7", - expected: "5.7", - }, - { - name: "Aurora MySQL with only major.minor v8", - engine: "aurora-mysql", - version: "8.0", - expected: "8.0", - }, - { - name: "Aurora MySQL engine name with spaces", - engine: "Aurora MySQL", - version: "mysql_aurora.2.11.1", - expected: "5.7", - }, - { - name: "Aurora MySQL engine name normalized", - engine: "AuroraMYSQL", - version: "mysql_aurora.3.04.0", - expected: "8.0", - }, - - // Standard MySQL versions - { - name: "MySQL 5.6.x", - engine: "mysql", - version: "5.6.51", - expected: "5.6", - }, - { - name: "MySQL 5.7.x", - engine: "mysql", - version: "5.7.40", - expected: "5.7", - }, - { - name: "MySQL 8.0.x", - engine: "mysql", - version: "8.0.33", - expected: "8.0", - }, - { - name: "MySQL with only major.minor", - engine: "mysql", - version: "5.7", - expected: "5.7", - }, - - // PostgreSQL versions - { - name: "PostgreSQL 11.x", - engine: "postgres", - version: "11.19", - expected: "11.19", - }, - { - name: "PostgreSQL 12.x", - engine: "postgres", - version: "12.15", - expected: "12.15", - }, - { - name: "PostgreSQL 13.x", - engine: "postgres", - version: "13.11", - expected: "13.11", - }, - { - name: "PostgreSQL 14.x", - engine: "postgres", - version: "14.8", - expected: "14.8", - }, - { - name: "PostgreSQL 15.x", - engine: "postgres", - version: "15.3", - expected: "15.3", - }, - - // Aurora PostgreSQL versions - { - name: "Aurora PostgreSQL 11.x", - engine: "aurora-postgresql", - version: "11.18", - expected: "11.18", - }, - { - name: "Aurora PostgreSQL 13.x", - engine: "aurora-postgresql", - version: "13.10", - expected: "13.10", - }, - { - name: "Aurora PostgreSQL 14.x", - engine: "aurora-postgresql", - version: "14.7", - expected: "14.7", - }, - - // Edge cases - { - name: "Version with only major number", - engine: "mysql", - version: "8", - expected: "8", - }, - { - name: "Version with patch containing letters", - engine: "mysql", - version: "5.7.40a", - expected: "5.7", - }, - { - name: "Version with non-numeric minor (extracts numeric part)", - engine: "mysql", - version: "8.0rc1", - expected: "8.0", - }, - { - name: "Empty version string", - engine: "mysql", - version: "", - expected: "", - }, - { - name: "Version with extra dots", - engine: "postgres", - version: "13.10.1.2", - expected: "13.10", - }, - { - name: "Engine name with hyphens", - engine: "aurora-mysql", - version: "5.7.44", - expected: "5.7", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := extractMajorVersion(tt.engine, tt.version) - assert.Equal(t, tt.expected, result, "extractMajorVersion(%q, %q) should return %q", tt.engine, tt.version, tt.expected) - }) - } -} - func TestIsInExtendedSupport(t *testing.T) { now := time.Now() pastDate := now.AddDate(0, -6, 0) @@ -951,37 +431,3 @@ func TestQueryRunningInstanceEngineVersions_ProfileSelection(t *testing.T) { }) } } - -// TestAdjustRecommendationForExcludedVersions_ScalesMoneyWithCount: cutting the -// count must scale the money fields with it, or the report keeps the full -// recommendation's cost for fewer instances. -func TestAdjustRecommendationForExcludedVersions_ScalesMoneyWithCount(t *testing.T) { - monthly := 30.0 - recommendation := common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-west-2", - ResourceType: "db.r6g.large", - Count: 3, - CommitmentCost: 900, - OnDemandCost: 600, - EstimatedSavings: 150, - RecurringMonthlyCost: &monthly, - Details: &common.DatabaseDetails{Engine: "aurora-mysql"}, - } - instanceVersions := map[string][]InstanceEngineVersion{ - "db.r6g.large": { - {Engine: "aurora-mysql", EngineVersion: "5.7.mysql_aurora.2.12.0", InstanceClass: "db.r6g.large", Region: "us-west-2"}, - }, - } - - result := adjustRecommendationForExcludedVersions(recommendation, instanceVersions, createTestVersionInfo()) - - assert.Equal(t, 2, result.Count) - assert.InDelta(t, 600.0, result.CommitmentCost, 0.01, "2/3 of 900") - assert.InDelta(t, 400.0, result.OnDemandCost, 0.01) - assert.InDelta(t, 100.0, result.EstimatedSavings, 0.01) - if assert.NotNil(t, result.RecurringMonthlyCost) { - assert.InDelta(t, 20.0, *result.RecurringMonthlyCost, 0.01) - } - assert.InDelta(t, 30.0, monthly, 0.001, "the input's pointer is not mutated") -} diff --git a/cmd/multi_service_extended_support_unavailable_test.go b/cmd/multi_service_extended_support_unavailable_test.go index 3babe4519..00c127622 100644 --- a/cmd/multi_service_extended_support_unavailable_test.go +++ b/cmd/multi_service_extended_support_unavailable_test.go @@ -9,7 +9,6 @@ import ( "net/http/httptest" "net/url" "os" - "os/exec" "path/filepath" "strings" "sync" @@ -457,56 +456,3 @@ func TestFetchAllRecs_RDSRecInFailedRegionAborts(t *testing.T) { require.Error(t, err) assert.ErrorContains(t, err, "eu-west-1") } - -// The main pipeline wiring (runToolMultiService) is exercised in a re-exec'd -// child: the child runs the real command with the real engine-version fetcher -// against this process's stub endpoint, and exits through log.Fatalf. -func TestRunToolMultiService_ExtendedSupportExclusionWiring(t *testing.T) { - if os.Getenv("CUDLY_EXCL_CHILD") == "1" { - engineVersionFetcher = fetchEngineVersionData - rootCmd.SetArgs(strings.Fields(os.Getenv("CUDLY_EXCL_ARGS"))) - require.NoError(t, rootCmd.Execute()) - return - } - - const denyHint = "Cannot apply extended-support exclusion" - tests := []struct { - name string - setup func(*rdsAPIStub) - args string - wantExit1 bool - wantRDS bool // exclusion queries expected - }{ - {"purchase run, engine lifecycle denied", func(s *rdsAPIStub) { s.failEngines["mysql"] = true }, "--purchase", true, true}, - {"dry run, engine lifecycle denied", func(s *rdsAPIStub) { s.failEngines["mysql"] = true }, "", true, true}, - {"dry run, rec in a region whose inventory is denied", func(s *rdsAPIStub) { s.failRegions["us-east-1"] = true }, "", true, true}, - {"opt-in skips every exclusion query", func(s *rdsAPIStub) { s.failEngines["mysql"], s.failRegions["us-east-1"] = true, true }, "--include-extended-support", false, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - stub := newRDSStub("us-east-1") - tt.setup(stub) - stub.serveEnv(t) - dir := t.TempDir() - args := "--services rds --term 1 --coverage 100 --regions us-east-1 --output " + filepath.Join(dir, "out.csv") + - " --audit-log " + filepath.Join(dir, "audit.jsonl") + " " + tt.args - child := exec.Command(os.Args[0], "-test.run=^TestRunToolMultiService_ExtendedSupportExclusionWiring$") - child.Env = append(os.Environ(), "CUDLY_EXCL_CHILD=1", "CUDLY_EXCL_ARGS="+args, "AWS_REGION=us-east-1") - out, err := child.CombinedOutput() - - assert.Positive(t, stub.total(), "child must have reached the stub:\n%s", out) - assert.Zero(t, stub.count("Purchase"), "no purchase call may be made") - if tt.wantExit1 { - var exitErr *exec.ExitError - require.ErrorAs(t, err, &exitErr, "output:\n%s", out) - assert.Equal(t, 1, exitErr.ExitCode()) - assert.Contains(t, string(out), denyHint) - assert.Contains(t, string(out), "--include-extended-support") - return - } - require.NoError(t, err, "output:\n%s", out) - assert.Zero(t, stub.count("DescribeDBInstances")) - assert.Zero(t, stub.count("DescribeDBMajorEngineVersions")) - }) - } -} diff --git a/cmd/multi_service_extended_support_wiring_test.go b/cmd/multi_service_extended_support_wiring_test.go new file mode 100644 index 000000000..b133e6fd5 --- /dev/null +++ b/cmd/multi_service_extended_support_wiring_test.go @@ -0,0 +1,65 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The main pipeline wiring (runToolMultiService) is exercised in a re-exec'd +// child: the child runs the real command with the real engine-version fetcher +// against this process's stub endpoint, and exits through log.Fatalf. +func TestRunToolMultiService_ExtendedSupportExclusionWiring(t *testing.T) { + if os.Getenv("CUDLY_EXCL_CHILD") == "1" { + engineVersionFetcher = fetchEngineVersionData + rootCmd.SetArgs(strings.Fields(os.Getenv("CUDLY_EXCL_ARGS"))) + require.NoError(t, rootCmd.Execute()) + return + } + + const denyHint = "Cannot apply extended-support exclusion" + tests := []struct { + name string + setup func(*rdsAPIStub) + args string + wantExit1 bool + wantRDS bool // exclusion queries expected + }{ + {"purchase run, engine lifecycle denied", func(s *rdsAPIStub) { s.failEngines["mysql"] = true }, "--purchase", true, true}, + {"dry run, engine lifecycle denied", func(s *rdsAPIStub) { s.failEngines["mysql"] = true }, "", true, true}, + {"dry run, rec in a region whose inventory is denied", func(s *rdsAPIStub) { s.failRegions["us-east-1"] = true }, "", true, true}, + {"opt-in skips every exclusion query", func(s *rdsAPIStub) { s.failEngines["mysql"], s.failRegions["us-east-1"] = true, true }, "--include-extended-support", false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stub := newRDSStub("us-east-1") + tt.setup(stub) + stub.serveEnv(t) + dir := t.TempDir() + args := "--services rds --term 1 --coverage 100 --regions us-east-1 --output " + filepath.Join(dir, "out.csv") + + " --audit-log " + filepath.Join(dir, "audit.jsonl") + " " + tt.args + child := exec.Command(os.Args[0], "-test.run=^TestRunToolMultiService_ExtendedSupportExclusionWiring$") + child.Env = append(os.Environ(), "CUDLY_EXCL_CHILD=1", "CUDLY_EXCL_ARGS="+args, "AWS_REGION=us-east-1") + out, err := child.CombinedOutput() + + assert.Positive(t, stub.total(), "child must have reached the stub:\n%s", out) + assert.Zero(t, stub.count("Purchase"), "no purchase call may be made") + if tt.wantExit1 { + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr, "output:\n%s", out) + assert.Equal(t, 1, exitErr.ExitCode()) + assert.Contains(t, string(out), denyHint) + assert.Contains(t, string(out), "--include-extended-support") + return + } + require.NoError(t, err, "output:\n%s", out) + assert.Zero(t, stub.count("DescribeDBInstances")) + assert.Zero(t, stub.count("DescribeDBMajorEngineVersions")) + }) + } +} diff --git a/cmd/multi_service_extract_major_version_test.go b/cmd/multi_service_extract_major_version_test.go new file mode 100644 index 000000000..dcf30b6cd --- /dev/null +++ b/cmd/multi_service_extract_major_version_test.go @@ -0,0 +1,190 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// Comprehensive tests for extractMajorVersion function. +func TestExtractMajorVersion_Comprehensive(t *testing.T) { + tests := []struct { + name string + engine string + version string + expected string + }{ + // Aurora MySQL special formats + { + name: "Aurora MySQL 2.x format (MySQL 5.7 compatible)", + engine: "aurora-mysql", + version: "mysql_aurora.2.11.1", + expected: "5.7", + }, + { + name: "Aurora MySQL 3.x format (MySQL 8.0 compatible)", + engine: "aurora-mysql", + version: "mysql_aurora.3.04.0", + expected: "8.0", + }, + { + name: "Aurora MySQL 2.x with full version", + engine: "aurora-mysql", + version: "5.7.mysql_aurora.2.12.0", + expected: "5.7", + }, + { + name: "Aurora MySQL 3.x with full version", + engine: "aurora-mysql", + version: "8.0.mysql_aurora.3.05.2", + expected: "8.0", + }, + { + name: "Aurora MySQL with only major.minor", + engine: "aurora-mysql", + version: "5.7", + expected: "5.7", + }, + { + name: "Aurora MySQL with only major.minor v8", + engine: "aurora-mysql", + version: "8.0", + expected: "8.0", + }, + { + name: "Aurora MySQL engine name with spaces", + engine: "Aurora MySQL", + version: "mysql_aurora.2.11.1", + expected: "5.7", + }, + { + name: "Aurora MySQL engine name normalized", + engine: "AuroraMYSQL", + version: "mysql_aurora.3.04.0", + expected: "8.0", + }, + + // Standard MySQL versions + { + name: "MySQL 5.6.x", + engine: "mysql", + version: "5.6.51", + expected: "5.6", + }, + { + name: "MySQL 5.7.x", + engine: "mysql", + version: "5.7.40", + expected: "5.7", + }, + { + name: "MySQL 8.0.x", + engine: "mysql", + version: "8.0.33", + expected: "8.0", + }, + { + name: "MySQL with only major.minor", + engine: "mysql", + version: "5.7", + expected: "5.7", + }, + + // PostgreSQL versions + { + name: "PostgreSQL 11.x", + engine: "postgres", + version: "11.19", + expected: "11.19", + }, + { + name: "PostgreSQL 12.x", + engine: "postgres", + version: "12.15", + expected: "12.15", + }, + { + name: "PostgreSQL 13.x", + engine: "postgres", + version: "13.11", + expected: "13.11", + }, + { + name: "PostgreSQL 14.x", + engine: "postgres", + version: "14.8", + expected: "14.8", + }, + { + name: "PostgreSQL 15.x", + engine: "postgres", + version: "15.3", + expected: "15.3", + }, + + // Aurora PostgreSQL versions + { + name: "Aurora PostgreSQL 11.x", + engine: "aurora-postgresql", + version: "11.18", + expected: "11.18", + }, + { + name: "Aurora PostgreSQL 13.x", + engine: "aurora-postgresql", + version: "13.10", + expected: "13.10", + }, + { + name: "Aurora PostgreSQL 14.x", + engine: "aurora-postgresql", + version: "14.7", + expected: "14.7", + }, + + // Edge cases + { + name: "Version with only major number", + engine: "mysql", + version: "8", + expected: "8", + }, + { + name: "Version with patch containing letters", + engine: "mysql", + version: "5.7.40a", + expected: "5.7", + }, + { + name: "Version with non-numeric minor (extracts numeric part)", + engine: "mysql", + version: "8.0rc1", + expected: "8.0", + }, + { + name: "Empty version string", + engine: "mysql", + version: "", + expected: "", + }, + { + name: "Version with extra dots", + engine: "postgres", + version: "13.10.1.2", + expected: "13.10", + }, + { + name: "Engine name with hyphens", + engine: "aurora-mysql", + version: "5.7.44", + expected: "5.7", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractMajorVersion(tt.engine, tt.version) + assert.Equal(t, tt.expected, result, "extractMajorVersion(%q, %q) should return %q", tt.engine, tt.version, tt.expected) + }) + } +} diff --git a/cmd/multi_service_filter_adjust_test.go b/cmd/multi_service_filter_adjust_test.go new file mode 100644 index 000000000..6efc13e58 --- /dev/null +++ b/cmd/multi_service_filter_adjust_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilterAndAdjustRecommendations(t *testing.T) { + // Save and restore ALL global variables + saved := saveGlobalVars() + defer saved.restore() + + tests := []struct { + setupFilters func() + name string + recommendations []common.Recommendation + coverage float64 + expectedMin int + expectedMax int + }{ + { + name: "100% coverage no filters", + recommendations: []common.Recommendation{ + {Service: common.ServiceRDS, ResourceType: "db.t3.small", Count: 5}, + {Service: common.ServiceRDS, ResourceType: "db.t3.medium", Count: 3}, + }, + coverage: 100.0, + setupFilters: func() { + toolCfg.MaxInstances = 0 + toolCfg.OverrideCount = 0 + }, + expectedMin: 2, + expectedMax: 2, + }, + { + name: "50% coverage", + recommendations: []common.Recommendation{ + {Service: common.ServiceRDS, ResourceType: "db.t3.small", Count: 10}, + {Service: common.ServiceRDS, ResourceType: "db.t3.medium", Count: 6}, + }, + coverage: 50.0, + setupFilters: func() { + toolCfg.MaxInstances = 0 + toolCfg.OverrideCount = 0 + }, + expectedMin: 1, + expectedMax: 2, + }, + { + name: "Instance limit applied", + // EstimatedSavings is set because a binding --max-instances has to + // rank the rows it chooses between; requireRankingSignal refuses a + // run whose rows carry no savings value rather than capping them by + // name. + recommendations: []common.Recommendation{ + {Service: common.ServiceRDS, ResourceType: "db.t3.small", Count: 10, EstimatedSavings: 100}, + {Service: common.ServiceRDS, ResourceType: "db.t3.medium", Count: 10, EstimatedSavings: 200}, + {Service: common.ServiceRDS, ResourceType: "db.t3.large", Count: 10, EstimatedSavings: 300}, + }, + coverage: 100.0, + setupFilters: func() { + toolCfg.MaxInstances = 15 + toolCfg.OverrideCount = 0 + }, + expectedMin: 1, + expectedMax: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup filters + tt.setupFilters() + + // Suppress logger + // Logger output disabled for testing + + result, err := filterAndAdjustRecommendations(context.Background(), tt.recommendations, tt.coverage, toolCfg) + require.NoError(t, err) + + // Verify result is within expected range + assert.GreaterOrEqual(t, len(result), tt.expectedMin) + assert.LessOrEqual(t, len(result), tt.expectedMax) + + // Verify all results have count > 0 + for _, rec := range result { + assert.Positive(t, rec.Count) + } + }) + } +} diff --git a/cmd/multi_service_filters_pool_size_test.go b/cmd/multi_service_filters_pool_size_test.go new file mode 100644 index 000000000..eac60fa2a --- /dev/null +++ b/cmd/multi_service_filters_pool_size_test.go @@ -0,0 +1,166 @@ +package main + +import ( + "fmt" + "log" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestApplyFilters_DropMinPoolSize verifies that a recommendation whose +// AverageInstancesUsedPerHour is below --min-pool-size is recorded in a +// non-nil DropSummary under the DropMinPoolSize category. If the +// drops.Add call for that path were removed, d.Total() would stay at 0 +// and the first assertion below would fail. +func TestApplyFilters_DropMinPoolSize(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + + // Pool with avg=1.5 is below min-pool-size=2; it should be dropped. + rec := common.Recommendation{ + Region: "us-east-1", + ResourceType: "db.t3.micro", + Count: 2, + AverageInstancesUsedPerHour: 1.5, + } + toolCfg.MinPoolSize = 2.0 + + d := common.NewDropSummary() + result := applyFilters( + []common.Recommendation{rec}, + &toolCfg, + make(map[string][]InstanceEngineVersion), + make(map[string]MajorEngineVersionInfo), + "", + d, + ) + + assert.Empty(t, result, "below-min-pool-size rec should be filtered out") + assert.Equal(t, 1, d.Total(), "drop summary should record 1 drop") + assert.Contains(t, d.FormatOneLine(), common.DropMinPoolSize, + "drop summary should name the --min-pool-size category") +} + +// applyFiltersPreExtraction is the pre-refactor applyFilters implementation, +// kept verbatim (from origin/main, before the recfilter.Filters.ApplyMinPoolSize +// extraction) as a differential oracle. It applies the --min-pool-size check +// inline, in the same loop and same order as processRecommendation, rather +// than as a separate pass over the full slice. +func applyFiltersPreExtraction(recs []common.Recommendation, cfg *Config, instanceVersions map[string][]InstanceEngineVersion, versionInfo map[string]MajorEngineVersionInfo, currentRegion string, drops *common.DropSummary) []common.Recommendation { + var filtered []common.Recommendation + var poolDropCount int + var poolDropInstances float64 + + for i := range recs { + if cfg.MinPoolSize > 0 && !shouldIncludePoolSize(&recs[i], cfg) { + poolDropInstances += recs[i].AverageInstancesUsedPerHour + label := fmt.Sprintf("%s/%s/%s", recs[i].Service, recs[i].Region, recs[i].ResourceType) + log.Printf("INFO: --min-pool-size=%.1f dropped %s (avg=%.2f < threshold)", cfg.MinPoolSize, label, recs[i].AverageInstancesUsedPerHour) + poolDropCount++ + drops.Add(common.DropMinPoolSize, 1) + continue + } + adjusted, include, dropReason := processRecommendation(&recs[i], cfg, instanceVersions, versionInfo, currentRegion) + if include { + filtered = append(filtered, adjusted) + } else if dropReason != "" { + drops.Add(dropReason, 1) + } + } + + if poolDropCount > 0 { + log.Printf("INFO: --min-pool-size dropped %d recommendation(s) (%.2f avg instances/hr total)", poolDropCount, poolDropInstances) + } + + return filtered +} + +// TestApplyFilters_MinPoolSizeMultiRegionMatchesPreExtractionBehaviour is a +// differential regression test against a reviewer's claim that extracting +// the --min-pool-size check into recfilter.Filters.ApplyMinPoolSize changed +// its ordering relative to the currentRegion guard in processRecommendation +// (inflating common.DropMinPoolSize in multi-region runs). It runs the +// current applyFilters and the pre-extraction oracle above side by side, +// once per region, over the same multi-region recommendation set, and +// asserts both the survivors and the drop accounting match exactly. +func TestApplyFilters_MinPoolSizeMultiRegionMatchesPreExtractionBehaviour(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + + toolCfg = Config{MinPoolSize: 2.0} + + regions := []string{"us-east-1", "eu-west-1", "ap-southeast-1"} + const distinctBelowThreshold = 3 // one below-threshold rec per region below + + // Fresh copies per call: applyFilters mutates nothing in place today, but + // the oracle and the refactored code must each see their own slice so a + // hypothetical future in-place adjustment on one side can't leak into the + // other's input and mask a real divergence. + makeRecs := func() []common.Recommendation { + return []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.micro", Count: 3, AverageInstancesUsedPerHour: 5.0}, + {Region: "us-east-1", ResourceType: "db.t3.small", Count: 3, AverageInstancesUsedPerHour: 1.0}, // below threshold + {Region: "eu-west-1", ResourceType: "db.t3.micro", Count: 3, AverageInstancesUsedPerHour: 5.0}, + {Region: "eu-west-1", ResourceType: "db.t3.small", Count: 3, AverageInstancesUsedPerHour: 1.0}, // below threshold + {Region: "ap-southeast-1", ResourceType: "db.t3.micro", Count: 3, AverageInstancesUsedPerHour: 5.0}, + {Region: "ap-southeast-1", ResourceType: "db.t3.small", Count: 3, AverageInstancesUsedPerHour: 1.0}, // below threshold + {Region: "us-east-1", ResourceType: "db.t3.large", Count: 3, AverageInstancesUsedPerHour: 0}, // no-signal passthrough + { + Service: common.ServiceSavingsPlansCompute, + ResourceType: "ec2-instance", + Count: 10, + AverageInstancesUsedPerHour: 5.0, // account-level: bypasses the currentRegion guard + }, + } + } + + var totalDropMinPoolSizeNew, totalDropMinPoolSizeOld int + + for _, region := range regions { + t.Run(region, func(t *testing.T) { + dropsNew := common.NewDropSummary() + dropsOld := common.NewDropSummary() + + resultNew := applyFilters(makeRecs(), &toolCfg, make(map[string][]InstanceEngineVersion), make(map[string]MajorEngineVersionInfo), region, dropsNew) + resultOld := applyFiltersPreExtraction(makeRecs(), &toolCfg, make(map[string][]InstanceEngineVersion), make(map[string]MajorEngineVersionInfo), region, dropsOld) + + assert.Equal(t, resultOld, resultNew, + "region %s: refactored applyFilters diverged from the pre-extraction oracle -- the ApplyMinPoolSize extraction changed observable CLI filtering behavior", region) + assert.Equal(t, dropsOld.FormatOneLine(), dropsNew.FormatOneLine(), + "region %s: drop summaries diverged between pre- and post-extraction implementations", region) + assert.Equal(t, dropsOld.Total(), dropsNew.Total(), + "region %s: total drop counts diverged between pre- and post-extraction implementations", region) + + // The fixture is built so --min-pool-size is the ONLY drop reason + // either implementation can record, which is what lets the summed + // Total() below stand in for the min-pool count specifically. + // Asserting the exact per-pass count keeps that guarantee honest: + // if some other filter started dropping rows, Total() would exceed + // the below-threshold count and this would fail. + require.Contains(t, dropsNew.FormatOneLine(), common.DropMinPoolSize, + "region %s: fixture must exercise the --min-pool-size drop path", region) + require.Equal(t, distinctBelowThreshold, dropsNew.Total(), + "region %s: --min-pool-size must be the only drop reason this fixture records", region) + + totalDropMinPoolSizeNew += dropsNew.Total() + totalDropMinPoolSizeOld += dropsOld.Total() + }) + } + + require.Equal(t, totalDropMinPoolSizeOld, totalDropMinPoolSizeNew, + "old and new implementations must agree on the summed multi-region --min-pool-size drop count") + + // Pre-existing behavior, identical in both implementations (not introduced + // by the ApplyMinPoolSize extraction): each per-region call re-scans the + // FULL recommendation set passed to it, not a per-region subset, so a + // below-threshold recommendation from one region is re-counted as dropped + // during every other region's pass too. Summed across N region passes this + // is distinctBelowThreshold * N, not distinctBelowThreshold. This assertion + // pins today's (inflated) value rather than an aspirational deduplicated + // one -- see the test's summary report for the actual vs. distinct counts. + assert.Equal(t, distinctBelowThreshold*len(regions), totalDropMinPoolSizeNew, + "summed multi-region --min-pool-size drop count should match today's pre-existing inflation factor") +} diff --git a/cmd/multi_service_filters_savings_plans_test.go b/cmd/multi_service_filters_savings_plans_test.go new file mode 100644 index 000000000..77d227673 --- /dev/null +++ b/cmd/multi_service_filters_savings_plans_test.go @@ -0,0 +1,172 @@ +package main + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +// TestApplyFilters_SavingsPlansRegionFilters covers the CLI half of #1582. +// Savings Plans recommendations never populate the top-level rec.Region +// (parser_sp.go stores the CE-supplied region in Details.Region instead), so +// filtering on the bare field dropped every SP recommendation whenever +// --include-regions was set, and leaked region-scoped EC2Instance SPs past +// --exclude-regions. The provider-side filters were fixed first; these cases +// pin the same semantics on the CLI path. +func TestApplyFilters_SavingsPlansRegionFilters(t *testing.T) { + ec2SP := func(region string) common.Recommendation { + return common.Recommendation{ + Provider: common.ProviderAWS, + Service: common.ServiceSavingsPlansEC2Instance, + CommitmentType: common.CommitmentSavingsPlan, + Count: 1, + Details: &common.SavingsPlanDetails{ + PlanType: "EC2Instance", + InstanceFamily: "m5", + Region: region, + }, + } + } + computeSP := func() common.Recommendation { + return common.Recommendation{ + Provider: common.ProviderAWS, + Service: common.ServiceSavingsPlansCompute, + CommitmentType: common.CommitmentSavingsPlan, + Count: 1, + Details: &common.SavingsPlanDetails{PlanType: "Compute"}, + } + } + + tests := []struct { + name string + rec common.Recommendation + includeRegions []string + excludeRegions []string + wantKept bool + }{ + { + name: "EC2Instance SP in the included region survives", + rec: ec2SP("us-east-1"), + includeRegions: []string{"us-east-1"}, + wantKept: true, + }, + { + name: "EC2Instance SP outside the included region is dropped", + rec: ec2SP("eu-west-1"), + includeRegions: []string{"us-east-1"}, + wantKept: false, + }, + { + name: "region-agnostic Compute SP survives an include filter", + rec: computeSP(), + includeRegions: []string{"us-east-1"}, + wantKept: true, + }, + { + name: "EC2Instance SP in an excluded region is dropped", + rec: ec2SP("eu-west-1"), + excludeRegions: []string{"eu-west-1"}, + wantKept: false, + }, + { + name: "region-agnostic Compute SP survives an exclude filter", + rec: computeSP(), + excludeRegions: []string{"eu-west-1"}, + wantKept: true, + }, + { + // Cost Explorer omitted SavingsPlansDetails.Region. An EC2Instance + // SP is region-scoped, so an unknown region must not be treated as + // region-agnostic and waved past an explicit region filter. + name: "EC2Instance SP with an unknown region is not over-included", + rec: ec2SP(""), + includeRegions: []string{"us-east-1"}, + wantKept: false, + }, + { + name: "reservation rec with an unknown region is still dropped", + rec: common.Recommendation{Service: common.ServiceRDS, CommitmentType: common.CommitmentReservedInstance, Count: 1}, + includeRegions: []string{"us-east-1"}, + wantKept: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{ + IncludeRegions: tt.includeRegions, + ExcludeRegions: tt.excludeRegions, + } + want := 0 + if tt.wantKept { + want = 1 + } + got := applyFilters([]common.Recommendation{tt.rec}, &cfg, nil, nil, "", common.NewDropSummary()) + assert.Len(t, got, want, "region filter kept the wrong number of recommendations") + }) + } +} + +// TestApplyFilters_SavingsPlansRegionsFlag pins that --regions narrows +// region-scoped (EC2 Instance) Savings Plans by Details.Region while +// account-level plans stay unfiltered, and that it ANDs with +// --include-regions / --exclude-regions. +func TestApplyFilters_SavingsPlansRegionsFlag(t *testing.T) { + sp := func(svc common.ServiceType, planType, region string) common.Recommendation { + return common.Recommendation{ + Provider: common.ProviderAWS, + Service: svc, + CommitmentType: common.CommitmentSavingsPlan, + Count: 1, + Details: &common.SavingsPlanDetails{PlanType: planType, Region: region}, + } + } + ec2 := func(region string) common.Recommendation { + return sp(common.ServiceSavingsPlansEC2Instance, "EC2Instance", region) + } + + tests := []struct { + name string + rec common.Recommendation + regions []string + include []string + exclude []string + want bool + }{ + {"EC2 SP in another region dropped", ec2("us-east-1"), []string{"eu-west-2"}, nil, nil, false}, + {"EC2 SP in listed region kept", ec2("eu-west-2"), []string{"eu-west-2"}, nil, nil, true}, + {"Compute SP kept", sp(common.ServiceSavingsPlansCompute, "Compute", ""), []string{"eu-west-2"}, nil, nil, true}, + {"SageMaker SP kept", sp(common.ServiceSavingsPlansSageMaker, "SageMaker", ""), []string{"eu-west-2"}, nil, nil, true}, + {"Database SP kept", sp(common.ServiceSavingsPlansDatabase, "Database", ""), []string{"eu-west-2"}, nil, nil, true}, + {"EC2 SP with unknown region dropped", ec2(""), []string{"eu-west-2"}, nil, nil, false}, + {"empty --regions leaves EC2 SP alone", ec2("us-east-1"), nil, nil, nil, true}, + {"regions AND include: outside include dropped", ec2("eu-west-2"), []string{"eu-west-2"}, []string{"us-east-1"}, nil, false}, + {"regions AND include: both match kept", ec2("eu-west-2"), []string{"eu-west-2", "us-east-1"}, []string{"eu-west-2"}, nil, true}, + {"regions AND exclude: excluded dropped", ec2("us-east-1"), []string{"eu-west-2", "us-east-1"}, nil, []string{"us-east-1"}, false}, + {"regions AND exclude: other kept", ec2("eu-west-2"), []string{"eu-west-2", "us-east-1"}, nil, []string{"us-east-1"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{Regions: tt.regions, IncludeRegions: tt.include, ExcludeRegions: tt.exclude} + got := applyFilters([]common.Recommendation{tt.rec}, &cfg, nil, nil, "", common.NewDropSummary()) + assert.Equal(t, tt.want, len(got) == 1) + }) + } + + t.Run("reservation rec is not narrowed by --regions here", func(t *testing.T) { + ri := common.Recommendation{Service: common.ServiceRDS, CommitmentType: common.CommitmentReservedInstance, Region: "us-east-1", Count: 1} + got := applyFilters([]common.Recommendation{ri}, &Config{Regions: []string{"eu-west-2"}}, nil, nil, "", common.NewDropSummary()) + assert.Len(t, got, 1) + }) + + t.Run("eu-west-2 scenario", func(t *testing.T) { + recs := []common.Recommendation{ec2("us-east-1"), ec2("eu-west-2"), sp(common.ServiceSavingsPlansCompute, "Compute", "")} + got := applyFilters(recs, &Config{Regions: []string{"eu-west-2"}}, nil, nil, "", common.NewDropSummary()) + if assert.Len(t, got, 2) { + assert.Equal(t, common.ServiceSavingsPlansEC2Instance, got[0].Service) + assert.Equal(t, "eu-west-2", got[0].Details.(*common.SavingsPlanDetails).Region) + assert.Equal(t, common.ServiceSavingsPlansCompute, got[1].Service) + } + }) +} diff --git a/cmd/multi_service_filters_test.go b/cmd/multi_service_filters_test.go index 84589bc50..75a322161 100644 --- a/cmd/multi_service_filters_test.go +++ b/cmd/multi_service_filters_test.go @@ -2,8 +2,6 @@ package main import ( "context" - "fmt" - "log" "os" "path/filepath" "testing" @@ -126,358 +124,6 @@ func TestApplyFilters(t *testing.T) { } } -func TestShouldIncludeRegion(t *testing.T) { - // Save original values - origCfg := toolCfg - - defer func() { - toolCfg = origCfg - }() - - tests := []struct { - name string - region string - includeRegions []string - excludeRegions []string - expected bool - }{ - { - name: "No filters - should include", - region: "us-east-1", - includeRegions: []string{}, - excludeRegions: []string{}, - expected: true, - }, - { - name: "In include list", - region: "us-east-1", - includeRegions: []string{"us-east-1", "us-west-2"}, - excludeRegions: []string{}, - expected: true, - }, - { - name: "Not in include list", - region: "eu-west-1", - includeRegions: []string{"us-east-1"}, - excludeRegions: []string{}, - expected: false, - }, - { - name: "In exclude list", - region: "us-east-1", - includeRegions: []string{}, - excludeRegions: []string{"us-east-1"}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toolCfg.IncludeRegions = tt.includeRegions - toolCfg.ExcludeRegions = tt.excludeRegions - - result := shouldIncludeRegion(tt.region, &toolCfg) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestShouldIncludeInstanceType(t *testing.T) { - // Save original values - origCfg := toolCfg - - defer func() { - toolCfg = origCfg - }() - - tests := []struct { - name string - instanceType string - includeInstanceTypes []string - excludeInstanceTypes []string - expected bool - }{ - { - name: "No filters - should include", - instanceType: "db.t3.micro", - includeInstanceTypes: []string{}, - excludeInstanceTypes: []string{}, - expected: true, - }, - { - name: "In include list", - instanceType: "cache.t3.micro", - includeInstanceTypes: []string{"cache.t3.micro"}, - excludeInstanceTypes: []string{}, - expected: true, - }, - { - name: "In exclude list", - instanceType: "db.t3.large", - includeInstanceTypes: []string{}, - excludeInstanceTypes: []string{"db.t3.large"}, - expected: false, - }, - { - name: "Not in include list - excluded via whitelist", - instanceType: "db.r5.large", - includeInstanceTypes: []string{"db.t3.micro"}, - excludeInstanceTypes: []string{}, - expected: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toolCfg.IncludeInstanceTypes = tt.includeInstanceTypes - toolCfg.ExcludeInstanceTypes = tt.excludeInstanceTypes - - result := shouldIncludeInstanceType(tt.instanceType, &toolCfg) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestShouldIncludeEngine(t *testing.T) { - // Save original values - origCfg := toolCfg - - defer func() { - toolCfg = origCfg - }() - - tests := []struct { - name string - recommendation common.Recommendation - includeEngines []string - excludeEngines []string - expected bool - }{ - { - name: "ElastiCache Redis - no filters", - recommendation: common.Recommendation{ - Service: common.ServiceElastiCache, - Details: &common.CacheDetails{ - Engine: "redis", - }, - }, - includeEngines: []string{}, - excludeEngines: []string{}, - expected: true, - }, - { - name: "ElastiCache Redis - in include list", - recommendation: common.Recommendation{ - Service: common.ServiceElastiCache, - Details: &common.CacheDetails{ - Engine: "redis", - }, - }, - includeEngines: []string{"redis"}, - excludeEngines: []string{}, - expected: true, - }, - { - name: "ElastiCache Valkey - not in include list", - recommendation: common.Recommendation{ - Service: common.ServiceElastiCache, - Details: &common.CacheDetails{ - Engine: "valkey", - }, - }, - includeEngines: []string{"redis"}, - excludeEngines: []string{}, - expected: false, - }, - { - name: "ElastiCache Redis - in exclude list", - recommendation: common.Recommendation{ - Service: common.ServiceElastiCache, - Details: &common.CacheDetails{ - Engine: "redis", - }, - }, - includeEngines: []string{}, - excludeEngines: []string{"redis"}, - expected: false, - }, - { - name: "RDS with nil Details", - recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Details: nil, - }, - includeEngines: []string{"mysql"}, - excludeEngines: []string{}, - expected: false, // nil Details with include list - exclude unknown engines - }, - { - name: "RDS with nil Details - no filters", - recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Details: nil, - }, - includeEngines: []string{}, - excludeEngines: []string{}, - expected: true, // nil Details with no filters - include by default - }, - { - name: "RDS MySQL - with ServiceDetails", - recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Details: &common.DatabaseDetails{ - Engine: "mysql", - }, - }, - includeEngines: []string{"mysql", "postgresql"}, - excludeEngines: []string{}, - expected: true, - }, - { - name: "Case insensitive matching", - recommendation: common.Recommendation{ - Service: common.ServiceElastiCache, - Details: &common.CacheDetails{ - Engine: "Redis", - }, - }, - includeEngines: []string{"REDIS"}, - excludeEngines: []string{}, - expected: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toolCfg.IncludeEngines = tt.includeEngines - toolCfg.ExcludeEngines = tt.excludeEngines - - result := shouldIncludeEngine(&tt.recommendation, &toolCfg) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestShouldIncludeAccount(t *testing.T) { - // Save original values - origCfg := toolCfg - - defer func() { - toolCfg = origCfg - }() - - tests := []struct { - name string - accountID string - includeAccounts []string - excludeAccounts []string - expected bool - }{ - { - name: "No filters - should include", - accountID: "123456789012", - includeAccounts: []string{}, - excludeAccounts: []string{}, - expected: true, - }, - { - name: "In include list", - accountID: "123456789012", - includeAccounts: []string{"123456789012", "111111111111"}, - excludeAccounts: []string{}, - expected: true, - }, - { - name: "Not in include list", - accountID: "999888777666", - includeAccounts: []string{"123456789012"}, - excludeAccounts: []string{}, - expected: false, - }, - { - name: "In exclude list", - accountID: "123456789012", - includeAccounts: []string{}, - excludeAccounts: []string{"123456789012"}, - expected: false, - }, - { - name: "Not in exclude list", - accountID: "999888777666", - includeAccounts: []string{}, - excludeAccounts: []string{"123456789012"}, - expected: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - toolCfg.IncludeAccounts = tt.includeAccounts - toolCfg.ExcludeAccounts = tt.excludeAccounts - - result := shouldIncludeAccount(tt.accountID, &toolCfg) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestShouldIncludePoolSize(t *testing.T) { - tests := []struct { - name string - avg float64 - minPool float64 - expected bool - }{ - {"filter disabled (0)", 0.5, 0, true}, - {"avg=0 passes through", 0, 2.0, true}, - {"avg below threshold", 1.5, 2.0, false}, - {"avg equal to threshold", 2.0, 2.0, true}, - {"avg above threshold", 3.0, 2.0, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rec := common.Recommendation{AverageInstancesUsedPerHour: tt.avg} - cfg := Config{MinPoolSize: tt.minPool} - assert.Equal(t, tt.expected, shouldIncludePoolSize(&rec, &cfg)) - }) - } -} - -// TestApplyFilters_DropMinPoolSize verifies that a recommendation whose -// AverageInstancesUsedPerHour is below --min-pool-size is recorded in a -// non-nil DropSummary under the DropMinPoolSize category. If the -// drops.Add call for that path were removed, d.Total() would stay at 0 -// and the first assertion below would fail. -func TestApplyFilters_DropMinPoolSize(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - // Pool with avg=1.5 is below min-pool-size=2; it should be dropped. - rec := common.Recommendation{ - Region: "us-east-1", - ResourceType: "db.t3.micro", - Count: 2, - AverageInstancesUsedPerHour: 1.5, - } - toolCfg.MinPoolSize = 2.0 - - d := common.NewDropSummary() - result := applyFilters( - []common.Recommendation{rec}, - &toolCfg, - make(map[string][]InstanceEngineVersion), - make(map[string]MajorEngineVersionInfo), - "", - d, - ) - - assert.Empty(t, result, "below-min-pool-size rec should be filtered out") - assert.Equal(t, 1, d.Total(), "drop summary should record 1 drop") - assert.Contains(t, d.FormatOneLine(), common.DropMinPoolSize, - "drop summary should name the --min-pool-size category") -} - // TestApplyFilters_DropExtendedSupport verifies that a recommendation whose // entire instance count is on an engine version in extended support is // recorded in a non-nil DropSummary under DropExtendedSupport when @@ -535,291 +181,6 @@ func TestApplyFilters_DropExtendedSupport(t *testing.T) { "drop summary should name the --include-extended-support category") } -// TestApplyFilters_SavingsPlansRegionFilters covers the CLI half of #1582. -// Savings Plans recommendations never populate the top-level rec.Region -// (parser_sp.go stores the CE-supplied region in Details.Region instead), so -// filtering on the bare field dropped every SP recommendation whenever -// --include-regions was set, and leaked region-scoped EC2Instance SPs past -// --exclude-regions. The provider-side filters were fixed first; these cases -// pin the same semantics on the CLI path. -func TestApplyFilters_SavingsPlansRegionFilters(t *testing.T) { - ec2SP := func(region string) common.Recommendation { - return common.Recommendation{ - Provider: common.ProviderAWS, - Service: common.ServiceSavingsPlansEC2Instance, - CommitmentType: common.CommitmentSavingsPlan, - Count: 1, - Details: &common.SavingsPlanDetails{ - PlanType: "EC2Instance", - InstanceFamily: "m5", - Region: region, - }, - } - } - computeSP := func() common.Recommendation { - return common.Recommendation{ - Provider: common.ProviderAWS, - Service: common.ServiceSavingsPlansCompute, - CommitmentType: common.CommitmentSavingsPlan, - Count: 1, - Details: &common.SavingsPlanDetails{PlanType: "Compute"}, - } - } - - tests := []struct { - name string - rec common.Recommendation - includeRegions []string - excludeRegions []string - wantKept bool - }{ - { - name: "EC2Instance SP in the included region survives", - rec: ec2SP("us-east-1"), - includeRegions: []string{"us-east-1"}, - wantKept: true, - }, - { - name: "EC2Instance SP outside the included region is dropped", - rec: ec2SP("eu-west-1"), - includeRegions: []string{"us-east-1"}, - wantKept: false, - }, - { - name: "region-agnostic Compute SP survives an include filter", - rec: computeSP(), - includeRegions: []string{"us-east-1"}, - wantKept: true, - }, - { - name: "EC2Instance SP in an excluded region is dropped", - rec: ec2SP("eu-west-1"), - excludeRegions: []string{"eu-west-1"}, - wantKept: false, - }, - { - name: "region-agnostic Compute SP survives an exclude filter", - rec: computeSP(), - excludeRegions: []string{"eu-west-1"}, - wantKept: true, - }, - { - // Cost Explorer omitted SavingsPlansDetails.Region. An EC2Instance - // SP is region-scoped, so an unknown region must not be treated as - // region-agnostic and waved past an explicit region filter. - name: "EC2Instance SP with an unknown region is not over-included", - rec: ec2SP(""), - includeRegions: []string{"us-east-1"}, - wantKept: false, - }, - { - name: "reservation rec with an unknown region is still dropped", - rec: common.Recommendation{Service: common.ServiceRDS, CommitmentType: common.CommitmentReservedInstance, Count: 1}, - includeRegions: []string{"us-east-1"}, - wantKept: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := Config{ - IncludeRegions: tt.includeRegions, - ExcludeRegions: tt.excludeRegions, - } - want := 0 - if tt.wantKept { - want = 1 - } - got := applyFilters([]common.Recommendation{tt.rec}, &cfg, nil, nil, "", common.NewDropSummary()) - assert.Len(t, got, want, "region filter kept the wrong number of recommendations") - }) - } -} - -// applyFiltersPreExtraction is the pre-refactor applyFilters implementation, -// kept verbatim (from origin/main, before the recfilter.Filters.ApplyMinPoolSize -// extraction) as a differential oracle. It applies the --min-pool-size check -// inline, in the same loop and same order as processRecommendation, rather -// than as a separate pass over the full slice. -func applyFiltersPreExtraction(recs []common.Recommendation, cfg *Config, instanceVersions map[string][]InstanceEngineVersion, versionInfo map[string]MajorEngineVersionInfo, currentRegion string, drops *common.DropSummary) []common.Recommendation { - var filtered []common.Recommendation - var poolDropCount int - var poolDropInstances float64 - - for i := range recs { - if cfg.MinPoolSize > 0 && !shouldIncludePoolSize(&recs[i], cfg) { - poolDropInstances += recs[i].AverageInstancesUsedPerHour - label := fmt.Sprintf("%s/%s/%s", recs[i].Service, recs[i].Region, recs[i].ResourceType) - log.Printf("INFO: --min-pool-size=%.1f dropped %s (avg=%.2f < threshold)", cfg.MinPoolSize, label, recs[i].AverageInstancesUsedPerHour) - poolDropCount++ - drops.Add(common.DropMinPoolSize, 1) - continue - } - adjusted, include, dropReason := processRecommendation(&recs[i], cfg, instanceVersions, versionInfo, currentRegion) - if include { - filtered = append(filtered, adjusted) - } else if dropReason != "" { - drops.Add(dropReason, 1) - } - } - - if poolDropCount > 0 { - log.Printf("INFO: --min-pool-size dropped %d recommendation(s) (%.2f avg instances/hr total)", poolDropCount, poolDropInstances) - } - - return filtered -} - -// TestApplyFilters_MinPoolSizeMultiRegionMatchesPreExtractionBehaviour is a -// differential regression test against a reviewer's claim that extracting -// the --min-pool-size check into recfilter.Filters.ApplyMinPoolSize changed -// its ordering relative to the currentRegion guard in processRecommendation -// (inflating common.DropMinPoolSize in multi-region runs). It runs the -// current applyFilters and the pre-extraction oracle above side by side, -// once per region, over the same multi-region recommendation set, and -// asserts both the survivors and the drop accounting match exactly. -func TestApplyFilters_MinPoolSizeMultiRegionMatchesPreExtractionBehaviour(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg = Config{MinPoolSize: 2.0} - - regions := []string{"us-east-1", "eu-west-1", "ap-southeast-1"} - const distinctBelowThreshold = 3 // one below-threshold rec per region below - - // Fresh copies per call: applyFilters mutates nothing in place today, but - // the oracle and the refactored code must each see their own slice so a - // hypothetical future in-place adjustment on one side can't leak into the - // other's input and mask a real divergence. - makeRecs := func() []common.Recommendation { - return []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.micro", Count: 3, AverageInstancesUsedPerHour: 5.0}, - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 3, AverageInstancesUsedPerHour: 1.0}, // below threshold - {Region: "eu-west-1", ResourceType: "db.t3.micro", Count: 3, AverageInstancesUsedPerHour: 5.0}, - {Region: "eu-west-1", ResourceType: "db.t3.small", Count: 3, AverageInstancesUsedPerHour: 1.0}, // below threshold - {Region: "ap-southeast-1", ResourceType: "db.t3.micro", Count: 3, AverageInstancesUsedPerHour: 5.0}, - {Region: "ap-southeast-1", ResourceType: "db.t3.small", Count: 3, AverageInstancesUsedPerHour: 1.0}, // below threshold - {Region: "us-east-1", ResourceType: "db.t3.large", Count: 3, AverageInstancesUsedPerHour: 0}, // no-signal passthrough - { - Service: common.ServiceSavingsPlansCompute, - ResourceType: "ec2-instance", - Count: 10, - AverageInstancesUsedPerHour: 5.0, // account-level: bypasses the currentRegion guard - }, - } - } - - var totalDropMinPoolSizeNew, totalDropMinPoolSizeOld int - - for _, region := range regions { - t.Run(region, func(t *testing.T) { - dropsNew := common.NewDropSummary() - dropsOld := common.NewDropSummary() - - resultNew := applyFilters(makeRecs(), &toolCfg, make(map[string][]InstanceEngineVersion), make(map[string]MajorEngineVersionInfo), region, dropsNew) - resultOld := applyFiltersPreExtraction(makeRecs(), &toolCfg, make(map[string][]InstanceEngineVersion), make(map[string]MajorEngineVersionInfo), region, dropsOld) - - assert.Equal(t, resultOld, resultNew, - "region %s: refactored applyFilters diverged from the pre-extraction oracle -- the ApplyMinPoolSize extraction changed observable CLI filtering behavior", region) - assert.Equal(t, dropsOld.FormatOneLine(), dropsNew.FormatOneLine(), - "region %s: drop summaries diverged between pre- and post-extraction implementations", region) - assert.Equal(t, dropsOld.Total(), dropsNew.Total(), - "region %s: total drop counts diverged between pre- and post-extraction implementations", region) - - // The fixture is built so --min-pool-size is the ONLY drop reason - // either implementation can record, which is what lets the summed - // Total() below stand in for the min-pool count specifically. - // Asserting the exact per-pass count keeps that guarantee honest: - // if some other filter started dropping rows, Total() would exceed - // the below-threshold count and this would fail. - require.Contains(t, dropsNew.FormatOneLine(), common.DropMinPoolSize, - "region %s: fixture must exercise the --min-pool-size drop path", region) - require.Equal(t, distinctBelowThreshold, dropsNew.Total(), - "region %s: --min-pool-size must be the only drop reason this fixture records", region) - - totalDropMinPoolSizeNew += dropsNew.Total() - totalDropMinPoolSizeOld += dropsOld.Total() - }) - } - - require.Equal(t, totalDropMinPoolSizeOld, totalDropMinPoolSizeNew, - "old and new implementations must agree on the summed multi-region --min-pool-size drop count") - - // Pre-existing behavior, identical in both implementations (not introduced - // by the ApplyMinPoolSize extraction): each per-region call re-scans the - // FULL recommendation set passed to it, not a per-region subset, so a - // below-threshold recommendation from one region is re-counted as dropped - // during every other region's pass too. Summed across N region passes this - // is distinctBelowThreshold * N, not distinctBelowThreshold. This assertion - // pins today's (inflated) value rather than an aspirational deduplicated - // one -- see the test's summary report for the actual vs. distinct counts. - assert.Equal(t, distinctBelowThreshold*len(regions), totalDropMinPoolSizeNew, - "summed multi-region --min-pool-size drop count should match today's pre-existing inflation factor") -} - -// TestApplyFilters_SavingsPlansRegionsFlag pins that --regions narrows -// region-scoped (EC2 Instance) Savings Plans by Details.Region while -// account-level plans stay unfiltered, and that it ANDs with -// --include-regions / --exclude-regions. -func TestApplyFilters_SavingsPlansRegionsFlag(t *testing.T) { - sp := func(svc common.ServiceType, planType, region string) common.Recommendation { - return common.Recommendation{ - Provider: common.ProviderAWS, - Service: svc, - CommitmentType: common.CommitmentSavingsPlan, - Count: 1, - Details: &common.SavingsPlanDetails{PlanType: planType, Region: region}, - } - } - ec2 := func(region string) common.Recommendation { - return sp(common.ServiceSavingsPlansEC2Instance, "EC2Instance", region) - } - - tests := []struct { - name string - rec common.Recommendation - regions []string - include []string - exclude []string - want bool - }{ - {"EC2 SP in another region dropped", ec2("us-east-1"), []string{"eu-west-2"}, nil, nil, false}, - {"EC2 SP in listed region kept", ec2("eu-west-2"), []string{"eu-west-2"}, nil, nil, true}, - {"Compute SP kept", sp(common.ServiceSavingsPlansCompute, "Compute", ""), []string{"eu-west-2"}, nil, nil, true}, - {"SageMaker SP kept", sp(common.ServiceSavingsPlansSageMaker, "SageMaker", ""), []string{"eu-west-2"}, nil, nil, true}, - {"Database SP kept", sp(common.ServiceSavingsPlansDatabase, "Database", ""), []string{"eu-west-2"}, nil, nil, true}, - {"EC2 SP with unknown region dropped", ec2(""), []string{"eu-west-2"}, nil, nil, false}, - {"empty --regions leaves EC2 SP alone", ec2("us-east-1"), nil, nil, nil, true}, - {"regions AND include: outside include dropped", ec2("eu-west-2"), []string{"eu-west-2"}, []string{"us-east-1"}, nil, false}, - {"regions AND include: both match kept", ec2("eu-west-2"), []string{"eu-west-2", "us-east-1"}, []string{"eu-west-2"}, nil, true}, - {"regions AND exclude: excluded dropped", ec2("us-east-1"), []string{"eu-west-2", "us-east-1"}, nil, []string{"us-east-1"}, false}, - {"regions AND exclude: other kept", ec2("eu-west-2"), []string{"eu-west-2", "us-east-1"}, nil, []string{"us-east-1"}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := Config{Regions: tt.regions, IncludeRegions: tt.include, ExcludeRegions: tt.exclude} - got := applyFilters([]common.Recommendation{tt.rec}, &cfg, nil, nil, "", common.NewDropSummary()) - assert.Equal(t, tt.want, len(got) == 1) - }) - } - - t.Run("reservation rec is not narrowed by --regions here", func(t *testing.T) { - ri := common.Recommendation{Service: common.ServiceRDS, CommitmentType: common.CommitmentReservedInstance, Region: "us-east-1", Count: 1} - got := applyFilters([]common.Recommendation{ri}, &Config{Regions: []string{"eu-west-2"}}, nil, nil, "", common.NewDropSummary()) - assert.Len(t, got, 1) - }) - - t.Run("eu-west-2 scenario", func(t *testing.T) { - recs := []common.Recommendation{ec2("us-east-1"), ec2("eu-west-2"), sp(common.ServiceSavingsPlansCompute, "Compute", "")} - got := applyFilters(recs, &Config{Regions: []string{"eu-west-2"}}, nil, nil, "", common.NewDropSummary()) - if assert.Len(t, got, 2) { - assert.Equal(t, common.ServiceSavingsPlansEC2Instance, got[0].Service) - assert.Equal(t, "eu-west-2", got[0].Details.(*common.SavingsPlanDetails).Region) - assert.Equal(t, common.ServiceSavingsPlansCompute, got[1].Service) - } - }) -} - // TestApplyFilters_SavingsPlansRegionsFlagCSVModeUnchanged pins that // --regions does not narrow Savings Plan rows loaded from --input-csv: CSV // rows carry no Provider or CommitmentType, so they are used as supplied. @@ -874,35 +235,6 @@ savings-plans-ec2instance,,,1,1yr,no-upfront,EC2Instance,1.5,m5,us-east-1 } } -func TestShouldIncludeAccountIDOrNameExactMatch(t *testing.T) { - const id = "123456789012" - tests := []struct { - name string - accountName string - include []string - exclude []string - want bool - }{ - {"include dev does not select devops-prod", "devops-prod", []string{"dev"}, nil, false}, - {"exclude dev does not drop devops-prod", "devops-prod", nil, []string{"dev"}, true}, - {"include exact name different case", "DevOps-Prod", []string{"devops-prod"}, nil, true}, - {"exclude exact name different case", "devops-prod", nil, []string{"DEVOPS-PROD"}, false}, - {"include exact ID", "devops-prod", []string{id}, nil, true}, - {"exclude exact ID", "devops-prod", nil, []string{id}, false}, - {"include name with surrounding whitespace", "dev", []string{" dev\t"}, nil, true}, - {"exclude ID with surrounding whitespace", "dev", nil, []string{" " + id + " "}, false}, - {"include substring of ID does not match", "devops-prod", []string{"12345"}, nil, false}, - {"exclude substring of ID does not drop", "devops-prod", nil, []string{"9012"}, true}, - {"blank include never matches", "dev", []string{" "}, nil, false}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := &Config{IncludeAccounts: tt.include, ExcludeAccounts: tt.exclude} - assert.Equal(t, tt.want, shouldIncludeAccountIDOrName(id, tt.accountName, cfg)) - }) - } -} - func TestRequireExactExcludes(t *testing.T) { recs := []common.Recommendation{ { diff --git a/cmd/multi_service_from_csv_audit_test.go b/cmd/multi_service_from_csv_audit_test.go new file mode 100644 index 000000000..55100aa84 --- /dev/null +++ b/cmd/multi_service_from_csv_audit_test.go @@ -0,0 +1,164 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +// TestProcessPurchaseLoop_WritesAuditRecordForRealPurchase reproduces the +// other half of #1609 at the processPurchaseLoop level: a real (non-dry-run) +// purchase attempt must be audited as "success" or "error", never silently +// dropped. createServiceClient is not injectable (see the comment on +// TestApplyMinCountFloorAfterDuplicateAdjustment), so this exercises +// processPurchaseLoop directly with a mocked provider.ServiceClient rather +// than through runToolFromCSV -- the same technique +// TestProcessPurchaseLoopActualPurchase already uses to test this loop's +// purchase behavior without live AWS credentials. +func TestProcessPurchaseLoop_WritesAuditRecordForRealPurchase(t *testing.T) { + useCallerAccount(t) + tests := []struct { + result common.PurchaseResult + name string + wantStatus string + }{ + { + name: "success", + result: common.PurchaseResult{Success: true, CommitmentID: "test-purchase-id", Timestamp: time.Now()}, + wantStatus: "success", + }, + { + name: "error", + result: common.PurchaseResult{Success: false, Error: fmt.Errorf("API error: quota exceeded"), Timestamp: time.Now()}, + wantStatus: "error", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + t.Setenv("DISABLE_PURCHASE_DELAY", "true") + + recs := []common.Recommendation{ + {Service: common.ServiceRDS, ResourceType: "db.t3.small", Count: 2, EstimatedSavings: 100}, + } + result := tt.result + result.Recommendation = recs[0] + + mockClient := &MockServiceClient{} + mockClient.On("PurchaseCommitment", mock.Anything, recs[0], mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) + + processPurchaseLoop(ctx, recs, "us-east-1", false /* isDryRun */, mockClient, toolCfg, "test-run-id") + + data, readErr := os.ReadFile(toolCfg.AuditLog) // #nosec G304 -- test-owned tempdir path + require.NoError(t, readErr, "a real purchase attempt must write an audit log") + require.NotEmpty(t, data, "the audit log must not be empty -- an empty file would make the next length check pass vacuously") + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + require.Len(t, lines, 1, "one audit record per recommendation") + + var rec map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[0]), &rec)) + assert.Equal(t, "test-run-id", rec["run_id"]) + assert.Equal(t, common.PurchaseSourceCLI, rec["source"]) + assert.Equal(t, false, rec["dry_run"]) + assert.Equal(t, tt.wantStatus, rec["status"]) + + mockClient.AssertExpectations(t) + }) + } +} + +// TestRunToolFromCSV_ChecksAuditLogWritability reproduces the second half of +// #1609: the --input-csv path never verified the audit log was writable +// before touching AWS, unlike the non-CSV path (CheckAuditLogWritable in +// runToolMultiService). An unwritable audit-log directory must be rejected +// before the CSV is even read, on both dry-run and purchase invocations. +func TestRunToolFromCSV_ChecksAuditLogWritability(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + + csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account +rds,us-east-1,db.t3.small,postgres,2,1yr,all-upfront,111111111111 +`) + + toolCfg.CSVInput = csvPath + toolCfg.AuditLog = filepath.Join(t.TempDir(), "does-not-exist-dir", "audit.jsonl") + toolCfg.ActualPurchase = false + + err := runToolFromCSV(context.Background(), toolCfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "audit log") +} + +// TestRunToolFromCSV_ConfirmsOnceBeforeAnyRegion reproduces #1610 through the +// public --input-csv entry point. Before the fix, confirmation lived inside +// processPurchaseLoop and was asked once per (service, region), after that +// region's AWS config, service client and duplicate check; declining only +// canceled that region. Now prepareCSVPurchaseRun asks exactly once, against +// the whole post-filter set (both regions here: 5 instances), before any AWS +// call. stdin is not a terminal in tests, so ConfirmPurchase declines without +// blocking, which is the "operator declines" and non-interactive fail-closed +// path at once. +func TestRunToolFromCSV_ConfirmsOnceBeforeAnyRegion(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + isolateAWSEnv(t) + + var appOut, stdOut bytes.Buffer + oldLogger := AppLogger + AppLogger = log.New(&appOut, "", 0) + t.Cleanup(func() { AppLogger = oldLogger }) + oldWriter := log.Writer() + log.SetOutput(&stdOut) + t.Cleanup(func() { log.SetOutput(oldWriter) }) + + csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account +rds,us-east-1,db.t3.small,postgres,2,1yr,all-upfront,111111111111 +rds,us-west-2,db.t3.medium,postgres,3,1yr,all-upfront,123456789012 +`) + reportPath := filepath.Join(t.TempDir(), "report.csv") + auditPath := filepath.Join(t.TempDir(), "audit.jsonl") + + toolCfg.CSVInput = csvPath + toolCfg.CSVOutput = reportPath + toolCfg.AuditLog = auditPath + toolCfg.ActualPurchase = true + toolCfg.Coverage = 100.0 + toolCfg.TargetCoverage = 0 + toolCfg.MaxInstances = 0 + toolCfg.OverrideCount = 0 + + err := runToolFromCSV(context.Background(), toolCfg) + require.NoError(t, err) + + assert.Equal(t, 1, strings.Count(stdOut.String(), "stdin is not a terminal"), + "the purchase must be confirmed exactly once for the whole run, not per region") + assert.Contains(t, appOut.String(), "Purchase canceled.") + assert.NotContains(t, appOut.String(), "Region:", + "a declined confirmation must abort before any region is processed") + + _, statErr := os.Stat(reportPath) + assert.True(t, os.IsNotExist(statErr), "a declined run must write no purchase report") + data, readErr := os.ReadFile(auditPath) // #nosec G304 -- test-owned tempdir path + if readErr == nil { + assert.Empty(t, data, "a declined run attempts no purchase, so it writes no audit record") + } else { + assert.True(t, os.IsNotExist(readErr), "unexpected audit log read error: %v", readErr) + } +} diff --git a/cmd/multi_service_from_csv_test.go b/cmd/multi_service_from_csv_test.go new file mode 100644 index 000000000..3543c291d --- /dev/null +++ b/cmd/multi_service_from_csv_test.go @@ -0,0 +1,276 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunToolFromCSV(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + awsHits := isolateAWSEnv(t) + + // Column names match writeMultiServiceCSVReport's header (the real + // round-trip shape). The previous fixture used headers the parser + // silently ignored, so every row loaded with Count=0 and the whole run + // processed nothing while the NotPanics assertion stayed green. + csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account +rds,us-east-1,db.t3.small,postgres,2,1yr,all-upfront,111111111111 +elasticache,us-west-2,cache.t3.micro,redis,1,1yr,all-upfront,111111111111 +`) + + tests := []struct { + name string + coverage float64 + wantResourceTypes []string + wantCounts []string + }{ + { + // 100% coverage keeps both recommendations at their CSV counts. + name: "Dry run writes a report row per recommendation", + coverage: 100.0, + wantResourceTypes: []string{"db.t3.small", "cache.t3.micro"}, + wantCounts: []string{"2", "1"}, + }, + { + // 50% coverage halves db.t3.small (2 -> 1) and drops + // cache.t3.micro (1 -> 0). + name: "Coverage sizing is applied before the purchase loop", + coverage: 50.0, + wantResourceTypes: []string{"db.t3.small"}, + wantCounts: []string{"1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reportPath := filepath.Join(t.TempDir(), "report.csv") + toolCfg.CSVInput = csvPath + toolCfg.CSVOutput = reportPath + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.ActualPurchase = false + toolCfg.Coverage = tt.coverage + toolCfg.TargetCoverage = 0 + toolCfg.MaxInstances = 0 + toolCfg.OverrideCount = 0 + + err := runToolFromCSV(context.Background(), toolCfg) + require.NoError(t, err) + + header, rows := readPurchaseReport(t, reportPath) + assert.ElementsMatch(t, tt.wantResourceTypes, reportColumn(t, header, rows, "ResourceType")) + assert.ElementsMatch(t, tt.wantCounts, reportColumn(t, header, rows, "Count")) + for _, success := range reportColumn(t, header, rows, "Success") { + assert.Equal(t, "true", success, "dry-run purchases must be recorded as successful") + } + }) + } + // Without a request the env wiring in isolateAWSEnv would be vacuous. + assert.Positive(t, awsHits.Load(), "isolateAWSEnv's local endpoint must have served the duplicate checks") +} + +// TestRunToolFromCSV_DuplicateCheckFailureRefusesToPurchase reproduces #1941: +// on a real purchase run (--input-csv ... --purchase), a failed duplicate +// check (throttling, a describe-call AccessDenied, a transient 5xx) must not +// fall back to purchasing the un-deduplicated counts. isolateAWSEnv points +// the SDK at invalid credentials so GetExistingCommitments fails the same way +// a real AWS error would, and the region must be skipped entirely rather than +// purchased with the original (potentially duplicate) counts. Before the fix, +// adjustRecsForDuplicates' error was logged as a warning and adjustedRecs was +// reset to the original recs, so the region proceeded into the purchase loop +// unchanged. +func TestRunToolFromCSV_DuplicateCheckFailureRefusesToPurchase(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + isolateAWSEnv(t) + + csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account +rds,us-east-1,db.t3.small,postgres,2,1yr,all-upfront,111111111111 +`) + reportPath := filepath.Join(t.TempDir(), "report.csv") + auditPath := filepath.Join(t.TempDir(), "audit.jsonl") + + toolCfg.CSVInput = csvPath + toolCfg.CSVOutput = reportPath + toolCfg.AuditLog = auditPath + toolCfg.ActualPurchase = true // the money path: a duplicate-check failure here must fail closed + toolCfg.Coverage = 100.0 + toolCfg.TargetCoverage = 0 + toolCfg.MaxInstances = 0 + toolCfg.OverrideCount = 0 + + err := runToolFromCSV(context.Background(), toolCfg) + require.NoError(t, err) + + // The region is skipped before executePurchase or processPurchaseLoop + // ever run, so no PurchaseResult is produced and no report is written + // (writeMultiServiceCSVReport no-ops on an empty results slice). Before + // the fix, the un-deduplicated recommendation reached the purchase loop + // and a report row (attempting a real AWS purchase call) was produced. + _, statErr := os.Stat(reportPath) + assert.True(t, os.IsNotExist(statErr), "a failed duplicate check on a purchase run must skip the region entirely, not attempt to purchase the un-deduplicated counts") +} + +// TestRunToolFromCSV_NonExistentFile asserts the unreadable-input error path +// surfaces as an error (previously log.Fatalf, which made it untestable). +func TestRunToolFromCSV_NonExistentFile(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + + toolCfg.CSVInput = filepath.Join(t.TempDir(), "does-not-exist.csv") + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.ActualPurchase = false + + err := runToolFromCSV(context.Background(), toolCfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read CSV file") + assert.Contains(t, err.Error(), "failed to open CSV file") +} + +// TestRunToolFromCSV_EmptyFile asserts a CSV without a header row is rejected +// with a parse error rather than being silently treated as zero rows. +func TestRunToolFromCSV_EmptyFile(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + + toolCfg.CSVInput = writeTestRecommendationsCSV(t, "") + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.ActualPurchase = false + + err := runToolFromCSV(context.Background(), toolCfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read CSV file") + assert.Contains(t, err.Error(), "failed to read CSV header") +} + +func TestRunToolFromCSV_WithMaxInstances(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + isolateAWSEnv(t) + + // EstimatedSavings is populated because a binding --max-instances has to + // rank the rows it chooses between. A file without that column is refused + // rather than capped by name; that case is covered by + // TestCSVCapRefusesRowsWithoutARankingSignal. + csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,EstimatedSavings,Term,PaymentOption,Account +rds,us-east-1,db.t3.small,postgres,10,100.00,1yr,all-upfront,123456789012 +rds,us-east-1,db.t3.medium,mysql,10,200.00,1yr,all-upfront,123456789012 +rds,us-east-1,db.t3.large,postgres,10,300.00,1yr,all-upfront,123456789012 +`) + + reportPath := filepath.Join(t.TempDir(), "report.csv") + toolCfg.CSVInput = csvPath + toolCfg.CSVOutput = reportPath + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.ActualPurchase = false + toolCfg.Coverage = 100.0 + toolCfg.TargetCoverage = 0 + toolCfg.OverrideCount = 0 + toolCfg.MaxInstances = 15 // Should limit total instances to 15 + + err := runToolFromCSV(context.Background(), toolCfg) + require.NoError(t, err) + + header, rows := readPurchaseReport(t, reportPath) + total := 0 + for _, raw := range reportColumn(t, header, rows, "Count") { + n, convErr := strconv.Atoi(raw) + require.NoError(t, convErr) + total += n + } + assert.Positive(t, total, "some instances must survive the max-instances cap") + assert.LessOrEqual(t, total, 15, "purchased instance total must respect --max-instances") +} + +func TestRunToolFromCSV_WithOverrideCount(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + isolateAWSEnv(t) + + csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account +rds,us-east-1,db.t3.small,postgres,10,1yr,all-upfront,123456789012 +rds,us-east-1,db.t3.medium,mysql,5,1yr,all-upfront,123456789012 +`) + + reportPath := filepath.Join(t.TempDir(), "report.csv") + toolCfg.CSVInput = csvPath + toolCfg.CSVOutput = reportPath + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.ActualPurchase = false + toolCfg.Coverage = 100.0 + toolCfg.TargetCoverage = 0 + toolCfg.MaxInstances = 0 + toolCfg.OverrideCount = 3 // Override each recommendation to count=3 + + err := runToolFromCSV(context.Background(), toolCfg) + require.NoError(t, err) + + header, rows := readPurchaseReport(t, reportPath) + counts := reportColumn(t, header, rows, "Count") + require.Len(t, counts, 2, "both recommendations must reach the purchase loop") + for _, raw := range counts { + assert.Equal(t, "3", raw, "--override-count must replace every recommendation count") + } +} + +// TestRunToolFromCSV_WritesAuditRecordsOnDryRun reproduces #1609 through the +// public --input-csv entry point: before the fix, processPurchaseLoop never +// wrote an audit record at all, on a dry run or a real purchase. A dry run is +// used here (rather than ActualPurchase=true with invalid credentials) +// because #1941 made the duplicate check fail closed on a real purchase run: +// with no valid AWS credentials the check now refuses the region before ever +// reaching a purchase attempt, and writes no audit record at all by design +// (see TestCheckDuplicates_ErrorOnPurchaseRun_DropsRecsRatherThanFallingBack) +// -- so there is no way to drive an un-mocked real-purchase attempt through +// this entry point in a test. A dry run still exercises the exact wiring +// #1609 fixes (prepareCSVPurchaseRun's runID -> processPurchaseLoop -> +// writePurchaseAuditRecord), since the duplicate check only warns and +// continues on a dry run rather than refusing. +// TestProcessPurchaseLoop_WritesAuditRecordForRealPurchase below covers the +// "success"/"error" statuses a real purchase attempt gets audited with. +func TestRunToolFromCSV_WritesAuditRecordsOnDryRun(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + isolateAWSEnv(t) + + csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account +rds,us-east-1,db.t3.small,postgres,2,1yr,all-upfront,111111111111 +`) + auditPath := filepath.Join(t.TempDir(), "audit.jsonl") + + toolCfg.CSVInput = csvPath + toolCfg.CSVOutput = filepath.Join(t.TempDir(), "report.csv") + toolCfg.AuditLog = auditPath + toolCfg.ActualPurchase = false + toolCfg.Coverage = 100.0 + toolCfg.TargetCoverage = 0 + toolCfg.MaxInstances = 0 + toolCfg.OverrideCount = 0 + + err := runToolFromCSV(context.Background(), toolCfg) + require.NoError(t, err) + + data, readErr := os.ReadFile(auditPath) // #nosec G304 -- test-owned tempdir path + require.NoError(t, readErr, "a purchase run through --input-csv must write an audit log") + require.NotEmpty(t, data, "the audit log must not be empty -- an empty file would make the next length check pass vacuously") + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + require.Len(t, lines, 1, "one audit record per recommendation") + + var rec map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[0]), &rec)) + assert.NotEmpty(t, rec["run_id"], "the audit record must carry a run ID grouping the CSV run") + assert.Equal(t, common.PurchaseSourceCLI, rec["source"]) + assert.Equal(t, "db.t3.small", rec["resource_type"]) + assert.Equal(t, true, rec["dry_run"]) + assert.Equal(t, "skipped", rec["status"]) +} diff --git a/cmd/multi_service_helpers_test.go b/cmd/multi_service_helpers_test.go index 8e54b640a..4202c7a3d 100644 --- a/cmd/multi_service_helpers_test.go +++ b/cmd/multi_service_helpers_test.go @@ -7,162 +7,10 @@ import ( "time" "github.com/LeanerCloud/cloud-commitments-go/pkg/common" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/service/ec2" - "github.com/aws/aws-sdk-go-v2/service/ec2/types" - "github.com/aws/aws-sdk-go-v2/service/organizations" - orgtypes "github.com/aws/aws-sdk-go-v2/service/organizations/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) -func TestGetAllAWSRegions(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - mockOutput *ec2.DescribeRegionsOutput - mockError error - expectRegions []string - expectError bool - }{ - { - name: "Success with multiple regions", - mockOutput: &ec2.DescribeRegionsOutput{ - Regions: []types.Region{ - {RegionName: aws.String("us-east-1")}, - {RegionName: aws.String("eu-west-1")}, - {RegionName: aws.String("ap-south-1")}, - }, - }, - expectRegions: []string{"ap-south-1", "eu-west-1", "us-east-1"}, // Sorted - expectError: false, - }, - { - name: "Error from AWS API", - mockOutput: nil, - mockError: errors.New("AWS API error"), - expectRegions: nil, - expectError: true, - }, - { - name: "Empty regions list", - mockOutput: &ec2.DescribeRegionsOutput{ - Regions: []types.Region{}, - }, - expectRegions: []string{}, - expectError: false, - }, - { - name: "Regions with nil names", - mockOutput: &ec2.DescribeRegionsOutput{ - Regions: []types.Region{ - {RegionName: aws.String("us-east-1")}, - {RegionName: nil}, - {RegionName: aws.String("eu-west-1")}, - }, - }, - expectRegions: []string{"eu-west-1", "us-east-1"}, // Sorted, nil excluded - expectError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockEC2 := &MockEC2Client{} - mockEC2.On("DescribeRegions", ctx, mock.Anything).Return(tt.mockOutput, tt.mockError) - - // Use the new interface-based function - regions, err := getAllAWSRegionsWithClient(ctx, mockEC2) - - if tt.expectError { - assert.Error(t, err) - assert.Nil(t, regions) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.expectRegions, regions) - } - - mockEC2.AssertExpectations(t) - }) - } - - t.Run("SDK client path against the fixture endpoint", func(t *testing.T) { - cfg, awsStub := newAWSFixtureConfig(t) - regions, err := getAllAWSRegions(ctx, cfg) - - require.NoError(t, err) - assert.Equal(t, []string{"eu-west-1", "us-west-2"}, regions, "regions are sorted") - awsStub.requireRequests(t, map[string]int{"ec2.us-east-1.amazonaws.com DescribeRegions": 1}) - }) -} - -func TestDiscoverRegionsForService(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - service common.ServiceType - mockReturns []common.Recommendation - expectedRegions []string - expectError bool - }{ - { - name: "Multiple unique regions", - service: common.ServiceRDS, - mockReturns: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.micro"}, - {Region: "us-west-2", ResourceType: "db.t3.small"}, - {Region: "eu-west-1", ResourceType: "db.t3.medium"}, - }, - expectedRegions: []string{"eu-west-1", "us-east-1", "us-west-2"}, - }, - { - name: "Duplicate regions", - service: common.ServiceEC2, - mockReturns: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "t3.micro"}, - {Region: "us-east-1", ResourceType: "t3.small"}, - {Region: "us-west-2", ResourceType: "t3.medium"}, - }, - expectedRegions: []string{"us-east-1", "us-west-2"}, - }, - { - name: "No recommendations", - service: common.ServiceElastiCache, - mockReturns: []common.Recommendation{}, - expectedRegions: []string{}, - }, - { - name: "Recommendations with empty regions filtered", - service: common.ServiceRedshift, - mockReturns: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "ra3.xlplus"}, - {Region: "", ResourceType: "ra3.4xlarge"}, - {Region: "us-west-2", ResourceType: "ra3.16xlarge"}, - }, - expectedRegions: []string{"us-east-1", "us-west-2"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockClient := &MockRecommendationsClient{} - mockClient.On("GetRecommendations", ctx, mock.MatchedBy(func(p *common.RecommendationParams) bool { - return p.Service == tt.service && p.Region == "" - })).Return(tt.mockReturns, nil) - - regions, err := discoverRegionsForService(ctx, mockClient, tt.service, Config{TermYears: 1}) - - assert.NoError(t, err) - assert.Equal(t, tt.expectedRegions, regions) - - mockClient.AssertExpectations(t) - }) - } -} - func TestFormatServices(t *testing.T) { tests := []struct { name string @@ -599,291 +447,6 @@ func TestPrintPaymentAndTerm(t *testing.T) { //nolint:unparam // param intention printPaymentAndTerm(cfg) } -func TestDetermineServicesToProcess_AllServices(t *testing.T) { - cfg := Config{ - AllServices: true, - } - - result, err := determineServicesToProcess(cfg) - assert.NoError(t, err) - - // Should contain all supported services - assert.Contains(t, result, common.ServiceRDS) - assert.Contains(t, result, common.ServiceElastiCache) - assert.Contains(t, result, common.ServiceEC2) - assert.Contains(t, result, common.ServiceOpenSearch) - assert.Contains(t, result, common.ServiceRedshift) - assert.Contains(t, result, common.ServiceMemoryDB) -} - -func TestDetermineServicesToProcess_SpecificServices(t *testing.T) { - cfg := Config{ - AllServices: false, - Services: []string{"rds", "elasticache"}, - } - - result, err := determineServicesToProcess(cfg) - assert.NoError(t, err) - - assert.Equal(t, 2, len(result)) - assert.Contains(t, result, common.ServiceRDS) - assert.Contains(t, result, common.ServiceElastiCache) -} - -func TestPopulateAccountNames(t *testing.T) { - ctx := context.Background() - - tests := []struct { - name string - recommendations []common.Recommendation - setupMock func(m *MockOrganizationsClient) - expectedNames []string - }{ - { - name: "Populates account names from IDs", - recommendations: []common.Recommendation{ - {Account: "123456789012", AccountName: ""}, - {Account: "210987654321", AccountName: ""}, - }, - setupMock: func(m *MockOrganizationsClient) { - m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ - AccountId: aws.String("123456789012"), - }).Return(&organizations.DescribeAccountOutput{ - Account: &orgtypes.Account{ - Name: aws.String("Production"), - }, - }, nil).Once() - m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ - AccountId: aws.String("210987654321"), - }).Return(&organizations.DescribeAccountOutput{ - Account: &orgtypes.Account{ - Name: aws.String("Development"), - }, - }, nil).Once() - }, - expectedNames: []string{"Production", "Development"}, - }, - { - name: "Handles empty account IDs", - recommendations: []common.Recommendation{ - {Account: "", AccountName: ""}, - {Account: "123456789012", AccountName: ""}, - }, - setupMock: func(m *MockOrganizationsClient) { - m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ - AccountId: aws.String("123456789012"), - }).Return(&organizations.DescribeAccountOutput{ - Account: &orgtypes.Account{ - Name: aws.String("Production"), - }, - }, nil).Once() - }, - expectedNames: []string{"", "Production"}, - }, - { - name: "Uses cached values for repeated accounts", - recommendations: []common.Recommendation{ - {Account: "123456789012", AccountName: ""}, - {Account: "123456789012", AccountName: ""}, - {Account: "123456789012", AccountName: ""}, - }, - setupMock: func(m *MockOrganizationsClient) { - // Should only be called once due to caching - m.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ - AccountId: aws.String("123456789012"), - }).Return(&organizations.DescribeAccountOutput{ - Account: &orgtypes.Account{ - Name: aws.String("Production"), - }, - }, nil).Once() - }, - expectedNames: []string{"Production", "Production", "Production"}, - }, - { - name: "Handles empty recommendations", - recommendations: []common.Recommendation{}, - setupMock: func(m *MockOrganizationsClient) { - // No calls expected - }, - expectedNames: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockOrg := &MockOrganizationsClient{} - tt.setupMock(mockOrg) - - cache := &TestAccountAliasCache{ - cache: make(map[string]string), - orgClient: mockOrg, - } - - // Manually populate account names using our test cache - for i := range tt.recommendations { - if tt.recommendations[i].Account != "" { - tt.recommendations[i].AccountName = cache.GetAccountAlias(ctx, tt.recommendations[i].Account) - } - } - - assert.Equal(t, len(tt.expectedNames), len(tt.recommendations)) - for i, rec := range tt.recommendations { - assert.Equal(t, tt.expectedNames[i], rec.AccountName) - } - - mockOrg.AssertExpectations(t) - }) - } -} - -// TestPopulateAccountNamesLogic tests the logic of populateAccountNames -// by verifying it populates the AccountName field correctly. -func TestPopulateAccountNamesLogic(t *testing.T) { - ctx := context.Background() - - t.Run("Correctly populates account names", func(t *testing.T) { - mockOrg := &MockOrganizationsClient{} - mockOrg.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ - AccountId: aws.String("123456789012"), - }).Return(&organizations.DescribeAccountOutput{ - Account: &orgtypes.Account{ - Name: aws.String("Production"), - }, - }, nil).Once() - - cache := &TestAccountAliasCache{ - cache: make(map[string]string), - orgClient: mockOrg, - } - - recs := []common.Recommendation{ - {Account: "123456789012", AccountName: ""}, - } - - // Simulate what populateAccountNames does - call GetAccountAlias for each rec - for i := range recs { - if recs[i].Account != "" { - recs[i].AccountName = cache.GetAccountAlias(ctx, recs[i].Account) - } - } - - assert.Equal(t, "Production", recs[0].AccountName) - mockOrg.AssertExpectations(t) - }) - - t.Run("Skips empty account IDs", func(t *testing.T) { - cache := &TestAccountAliasCache{ - cache: make(map[string]string), - orgClient: &MockOrganizationsClient{}, // No calls expected - } - - recs := []common.Recommendation{ - {Account: "", AccountName: ""}, - {Account: "", AccountName: "initial"}, - } - - // Simulate what populateAccountNames does - for i := range recs { - if recs[i].Account != "" { - recs[i].AccountName = cache.GetAccountAlias(ctx, recs[i].Account) - } - } - - // Empty accounts should not be modified (or return empty from GetAccountAlias) - assert.Equal(t, "", recs[0].AccountName) - assert.Equal(t, "initial", recs[1].AccountName) - }) - - t.Run("Handles multiple accounts with caching", func(t *testing.T) { - mockOrg := &MockOrganizationsClient{} - // Should only call once per unique account - mockOrg.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ - AccountId: aws.String("111222333444"), - }).Return(&organizations.DescribeAccountOutput{ - Account: &orgtypes.Account{ - Name: aws.String("Dev"), - }, - }, nil).Once() - mockOrg.On("DescribeAccount", ctx, &organizations.DescribeAccountInput{ - AccountId: aws.String("555666777888"), - }).Return(&organizations.DescribeAccountOutput{ - Account: &orgtypes.Account{ - Name: aws.String("Staging"), - }, - }, nil).Once() - - cache := &TestAccountAliasCache{ - cache: make(map[string]string), - orgClient: mockOrg, - } - - recs := []common.Recommendation{ - {Account: "111222333444", AccountName: ""}, - {Account: "111222333444", AccountName: ""}, // Same account - should use cache - {Account: "555666777888", AccountName: ""}, - } - - // Simulate what populateAccountNames does - for i := range recs { - if recs[i].Account != "" { - recs[i].AccountName = cache.GetAccountAlias(ctx, recs[i].Account) - } - } - - assert.Equal(t, "Dev", recs[0].AccountName) - assert.Equal(t, "Dev", recs[1].AccountName) - assert.Equal(t, "Staging", recs[2].AccountName) - mockOrg.AssertExpectations(t) - }) -} - -func TestDetermineServicesToProcess_DefaultAndInvalid(t *testing.T) { - for _, services := range [][]string{nil, {}} { - result, err := determineServicesToProcess(Config{Services: services}) - assert.NoError(t, err) - assert.Equal(t, []common.ServiceType{common.ServiceRDS}, result) - } - result, err := determineServicesToProcess(Config{Services: []string{"rds", "elasticahe"}}) - assert.ErrorContains(t, err, "elasticahe") - assert.Nil(t, result) -} - -func TestDetermineServicesToProcess_SavingsPlanTypeFilters(t *testing.T) { - allSP := []common.ServiceType{ - common.ServiceSavingsPlansCompute, common.ServiceSavingsPlansEC2Instance, - common.ServiceSavingsPlansSageMaker, common.ServiceSavingsPlansDatabase, - } - tests := []struct { - name string - cfg Config - want []common.ServiceType - wantErr string - }{ - {"no filter keeps all four", Config{Services: []string{"savingsplans"}}, allSP, ""}, - {"include narrows, case-insensitive", Config{Services: []string{"savingsplans"}, IncludeSPTypes: []string{"EC2Instance"}}, - []common.ServiceType{common.ServiceSavingsPlansEC2Instance}, ""}, - {"exclude drops", Config{Services: []string{"savingsplans"}, ExcludeSPTypes: []string{"sagemaker", "Database"}}, - []common.ServiceType{common.ServiceSavingsPlansCompute, common.ServiceSavingsPlansEC2Instance}, ""}, - {"include then exclude", Config{Services: []string{"savingsplans"}, IncludeSPTypes: []string{"compute", "ec2instance"}, ExcludeSPTypes: []string{"compute"}}, - []common.ServiceType{common.ServiceSavingsPlansEC2Instance}, ""}, - {"non-SP services untouched", Config{Services: []string{"rds", "savingsplans"}, IncludeSPTypes: []string{"compute"}}, - []common.ServiceType{common.ServiceRDS, common.ServiceSavingsPlansCompute}, ""}, - {"unknown include fails loud", Config{Services: []string{"savingsplans"}, IncludeSPTypes: []string{"ec2"}}, nil, `unknown Savings Plan type "ec2"`}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - got, err := determineServicesToProcess(tc.cfg) - if tc.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tc.wantErr) - return - } - require.NoError(t, err) - assert.Equal(t, tc.want, got) - }) - } -} - // Moved from the deleted TestProcessService_WithOverrideCount: the override is // applied by applyCoverageAndOverrides, which the live pipeline also calls. func TestApplyCoverageAndOverrides_OverrideCount(t *testing.T) { diff --git a/cmd/multi_service_max_instances_csv_order_test.go b/cmd/multi_service_max_instances_csv_order_test.go new file mode 100644 index 000000000..843d8b207 --- /dev/null +++ b/cmd/multi_service_max_instances_csv_order_test.go @@ -0,0 +1,196 @@ +package main + +import ( + "context" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCSVCapRefusesRowsWithoutARankingSignal covers the absent-vs-zero trap. +// +// A CSV with no EstimatedSavings column, or with a blank cell in it, loads +// every affected row at zero savings. Nothing downstream can tell that apart +// from a row genuinely worth $0, so a binding cap would rank on a value that +// is not there and silently fall through to the Service|Region|ResourceType +// tie-break, buying by instance-type name while stdout promises it is buying +// by savings. The run is refused instead. +// +// Both directions are covered: the two refusal cases, a case proving the +// refusal is scoped to a cap that actually binds, and a legitimate file that +// still ranks and caps normally. +func TestCSVCapRefusesRowsWithoutARankingSignal(t *testing.T) { + isolateAWSEnv(t) + + t.Run("no EstimatedSavings column at all", func(t *testing.T) { + // Worst alphabetically first, so a name-ordered cap is visible: it + // keeps db.aaa.large and drops db.zzz.large regardless of file order. + recs := csvRecsFromHeader(t, csvNoSavingsHeader, + `rds,us-east-1,db.zzz.large,postgres,6,1yr,all-upfront,123456789012 +rds,us-east-1,db.aaa.large,postgres,6,1yr,all-upfront,123456789012 +`) + + var err error + captureAppOutput(t, func() { + _, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: 6}) + }) + + require.Error(t, err, "a binding cap with nothing to rank on must refuse, not pick by name") + assert.Contains(t, err.Error(), "EstimatedSavings") + assert.Contains(t, err.Error(), "db.zzz.large", "the error must name the rows it cannot rank") + assert.Contains(t, err.Error(), "db.aaa.large") + }) + + t.Run("one blank EstimatedSavings cell among populated rows", func(t *testing.T) { + // The partial case is the worse one: the blank row loads as $0, ranks + // last, and is dropped first, with nothing saying the value was absent + // rather than genuinely zero. + recs := csvRecsFrom(t, `rds,us-east-1,db.t3.medium,postgres,6,500.00,1yr,all-upfront,123456789012 +rds,us-east-1,db.t3.small,postgres,6,,1yr,all-upfront,123456789012 +`) + + var err error + captureAppOutput(t, func() { + _, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: 10}) + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "db.t3.small", "the blank row must be named") + assert.NotContains(t, err.Error(), "db.t3.medium", "a row with a real savings value is rankable and must not be blamed") + }) + + t.Run("no savings column but the cap does not bind", func(t *testing.T) { + // Nothing has to be chosen between, so nothing has to be ranked. A + // guard that over-blocks is as wrong as one that never fires. + recs := csvRecsFromHeader(t, csvNoSavingsHeader, + `rds,us-east-1,db.zzz.large,postgres,6,1yr,all-upfront,123456789012 +rds,us-east-1,db.aaa.large,postgres,6,1yr,all-upfront,123456789012 +`) + + var ( + got []common.Recommendation + err error + ) + captureAppOutput(t, func() { + got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: 12}) + }) + + require.NoError(t, err) + assert.Len(t, got, 2) + assert.Equal(t, 12, CalculateTotalInstances(got)) + }) + + t.Run("populated savings column still ranks and caps", func(t *testing.T) { + recs := csvRecsFrom(t, `rds,us-east-1,db.t3.small,postgres,6,10.00,1yr,all-upfront,123456789012 +rds,us-east-1,db.t3.medium,postgres,6,500.00,1yr,all-upfront,123456789012 +`) + + var ( + got []common.Recommendation + err error + ) + captureAppOutput(t, func() { + got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: 6}) + }) + + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "db.t3.medium", got[0].ResourceType) + assert.Equal(t, 6, got[0].Count) + }) +} + +// TestApplyMinCountFloorAfterDuplicateAdjustment covers the floor's second +// call site, the one inside runToolFromCSV's purchase loop. +// +// adjustRecsForDuplicates subtracts recent matching commitments from Count +// after filterAndAdjustRecommendations has already applied the floor, so a row +// that cleared --min-count 5 at Count 6 with 5 existing commitments arrives at +// the purchase loop at Count 1. Without a second application the operator's +// floor is defeated on the very path #1741 is about. +// +// This exercises applyMinCountFloor on the post-deduction counts rather than +// through runToolFromCSV, because createServiceClient is not injectable: there +// is no seam to drive a fake GetExistingCommitments through the CSV entry +// point, and with invalid credentials adjustRecsForDuplicates errors and falls +// back to the unadjusted slice. +func TestApplyMinCountFloorAfterDuplicateAdjustment(t *testing.T) { + const minCount = 5 + + // Counts as adjustRecsForDuplicates leaves them: the first row had 6 and + // 5 existing commitments deducted, the second was untouched. + adjusted := []common.Recommendation{ + {Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.large", Count: 1, EstimatedSavings: 500}, + {Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.medium", Count: 6, EstimatedSavings: 100}, + } + + // Snapshot before the first call. applyMinCountFloor returns its input slice + // untouched at a floor of 0, and scorer.Score may reorder adjusted below, so + // asserting against adjusted itself would compare the result to itself. + original := append([]common.Recommendation(nil), adjusted...) + + var got []common.Recommendation + out := captureAppOutput(t, func() { + got = applyMinCountFloor(adjusted, minCount) + }) + + require.Len(t, got, 1, "the row the deduction left at 1 is below --min-count 5 and must not be purchased") + assert.Equal(t, "db.t3.medium", got[0].ResourceType) + assert.Equal(t, 6, got[0].Count) + assert.Contains(t, out, "db.t3.large", "the drop must be named") + assert.Contains(t, out, "--min-count") + + // A floor of 0 disables the flag: every row survives, in the order given. + var unfiltered []common.Recommendation + quiet := captureAppOutput(t, func() { + unfiltered = applyMinCountFloor(adjusted, 0) + }) + assert.Equal(t, original, unfiltered, "--min-count 0 must not drop or reorder anything") + assert.Empty(t, quiet) +} + +// TestCSVCapOrderIsIndependentOfFileOrder pins that equal-rate rows are not +// separated by where they sit in the file. +// +// --min-count 0 skips the scorer, so the per-instance sort is the only +// ordering the cap sees. If it left equal rates in input order, file order +// would still decide which of two equally-valuable rows the budget buys, which +// is the defect #1741 reported, surviving one layer down. +func TestCSVCapOrderIsIndependentOfFileOrder(t *testing.T) { + isolateAWSEnv(t) + + // Two rows at the identical rate ($100 for 6 instances), listed + // worst-alphabetically-first. + rows := []string{ + "rds,us-east-1,db.t3.zulu,postgres,6,100.00,1yr,all-upfront,123456789012\n", + "rds,us-east-1,db.t3.alpha,postgres,6,100.00,1yr,all-upfront,123456789012\n", + } + + selection := func(t *testing.T, rows []string) []string { + t.Helper() + recs := csvRecsFrom(t, rows[0]+rows[1]) + var ( + got []common.Recommendation + err error + ) + captureAppOutput(t, func() { + got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: 6}) + }) + require.NoError(t, err) + + names := make([]string, 0, len(got)) + for i := range got { + names = append(names, got[i].ResourceType) + } + return names + } + + forward := selection(t, rows) + reversed := selection(t, []string{rows[1], rows[0]}) + + assert.Equal(t, []string{"db.t3.alpha"}, forward, + "equal rates must tie-break on Service|Region|ResourceType, not on file position") + assert.Equal(t, forward, reversed, "reversing the file must not change what the cap buys") +} diff --git a/cmd/multi_service_max_instances_csv_test.go b/cmd/multi_service_max_instances_csv_test.go new file mode 100644 index 000000000..9f830aa1b --- /dev/null +++ b/cmd/multi_service_max_instances_csv_test.go @@ -0,0 +1,302 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// csvFixtureHeader matches writeMultiServiceCSVReport's column names for the +// fields these tests exercise, so the fixtures parse the way a real +// round-tripped report does. +const csvFixtureHeader = "Service,Region,ResourceType,Engine,Count,EstimatedSavings,Term,PaymentOption,Account\n" + +// csvNoSavingsHeader is csvFixtureHeader without the EstimatedSavings column, +// the shape of a hand-written minimal CSV. getCSVField returns "" for a column +// that is not in the header and parseCSVFloat leaves the field at zero, so +// every row of such a file loads with EstimatedSavings == 0. +const csvNoSavingsHeader = "Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account\n" + +// csvRecsFrom writes rows to a temporary recommendations CSV, loads them back +// through the production parser, and returns the recommendations exactly as a +// --input-csv run sees them. +// +// Building the fixture through loadRecommendationsFromCSV rather than by hand +// keeps these tests honest about what a CSV row actually carries: neither +// writeMultiServiceCSVReport nor parseCSVRecord knows a savings-percentage +// column, so every loaded row has SavingsPercentage == 0 and any +// savings-ordered selection has to resolve on EstimatedSavings. A hand-built +// fixture could quietly set SavingsPercentage and prove nothing about the real +// input. +func csvRecsFrom(t *testing.T, rows string) []common.Recommendation { + t.Helper() + return csvRecsFromHeader(t, csvFixtureHeader, rows) +} + +// csvRecsFromHeader is csvRecsFrom over an arbitrary header, for fixtures that +// exercise a CSV missing a column the selection depends on. +func csvRecsFromHeader(t *testing.T, header, rows string) []common.Recommendation { + t.Helper() + recs, err := loadRecommendationsFromCSV(writeTestRecommendationsCSV(t, header+rows)) + require.NoError(t, err) + require.NotEmpty(t, recs) + for i := range recs { + require.Zero(t, recs[i].SavingsPercentage, + "SavingsPercentage must stay zero here; the ordering under test ranks on EstimatedSavings / Count") + } + return recs +} + +// TestCSVCapKeepsHighestSavingsNotFileOrder is the selection-order regression +// for #1741. +// +// filterAndAdjustRecommendations used to hand the load-ordered slice straight +// to ApplyInstanceLimit, which consumes its input in slice order and drops the +// tail, so whichever rows appeared first in the file spent the whole budget. +// Both the old and the new behavior respect the cap total, so only a fixture +// whose best-value rows are *not* first can tell them apart: the $10 row leads +// the file and the $500 row sits in the middle. +func TestCSVCapKeepsHighestSavingsNotFileOrder(t *testing.T) { + isolateAWSEnv(t) + const ( + maxInstances = 10 + countPerRow = 6 + ) + + recs := csvRecsFrom(t, `rds,us-east-1,db.t3.small,postgres,6,10.00,1yr,all-upfront,123456789012 +rds,us-east-1,db.t3.medium,postgres,6,500.00,1yr,all-upfront,123456789012 +rds,us-east-1,db.t3.large,postgres,6,100.00,1yr,all-upfront,123456789012 +`) + + var ( + got []common.Recommendation + err error + ) + out := captureAppOutput(t, func() { + got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: maxInstances}) + }) + require.NoError(t, err) + + // Asserted only to keep the fixture honest: a file-ordered cap satisfies + // the total too, so the total on its own proves nothing here. + require.Equal(t, maxInstances, CalculateTotalInstances(got)) + + // The property under test is the selection ORDER. The budget must go to + // the highest-savings rows, best first, not to the rows the file happened + // to list first. + require.Len(t, got, 2) + assert.Equal(t, "db.t3.medium", got[0].ResourceType, + "the cap must consume savings order, not file order; got %s first", got[0].ResourceType) + assert.InDelta(t, 500.00, got[0].EstimatedSavings, 0.001) + assert.Equal(t, countPerRow, got[0].Count) + assert.Equal(t, "db.t3.large", got[1].ResourceType) + assert.Equal(t, maxInstances-countPerRow, got[1].Count) + // This row is truncated, so its savings are the file's figure scaled by + // the share of the row the budget actually buys (#1830). Asserting the + // unscaled 100.00 here is what the bug looked like. + assert.InDelta(t, 100.00*float64(maxInstances-countPerRow)/float64(countPerRow), + got[1].EstimatedSavings, 0.001) + + // Nothing shrinks silently: the row the cap dropped is named. + assert.Contains(t, out, "db.t3.small") +} + +// TestCSVCapDropsTruncationBelowMinCount pins that the CSV path cannot deliver +// a purchase smaller than the operator's --min-count floor, the property #1608 +// and #1725 established on the recommendation-driven path. +// +// Fixture: two rows of 6 instances, cap 10, floor 5. The budget leaves room +// for 6 + 4, and 4 is under the floor, so the second row is dropped rather +// than purchased short. The run buys 6. +func TestCSVCapDropsTruncationBelowMinCount(t *testing.T) { + isolateAWSEnv(t) + const ( + maxInstances = 10 + minCount = 5 + countPerRow = 6 + ) + + recs := csvRecsFrom(t, `rds,us-east-1,db.t3.large,postgres,6,100.00,1yr,all-upfront,123456789012 +rds,us-east-1,db.t3.medium,postgres,6,500.00,1yr,all-upfront,123456789012 +`) + + var ( + got []common.Recommendation + err error + ) + out := captureAppOutput(t, func() { + got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: maxInstances, MinCount: minCount}) + }) + require.NoError(t, err) + + for i := range got { + assert.GreaterOrEqual(t, got[i].Count, minCount, + "--min-count %d was set, but %s would be purchased at count=%d", + minCount, got[i].ResourceType, got[i].Count) + } + + require.Len(t, got, 1) + assert.Equal(t, "db.t3.medium", got[0].ResourceType) + assert.Equal(t, countPerRow, got[0].Count) + assert.Equal(t, countPerRow, CalculateTotalInstances(got)) + assert.Contains(t, out, "below --min-count 5") +} + +// TestCSVMinCountDropsRowsUnderTheFloor pins the floor with no cap in play: a +// CSV row that is already under --min-count is dropped, not purchased. The +// higher-value row is the one under the floor, so a selection that sorts by +// savings without gating on the floor still buys it. +func TestCSVMinCountDropsRowsUnderTheFloor(t *testing.T) { + isolateAWSEnv(t) + const minCount = 5 + + recs := csvRecsFrom(t, `rds,us-east-1,db.t3.small,postgres,2,500.00,1yr,all-upfront,123456789012 +rds,us-east-1,db.t3.medium,postgres,6,100.00,1yr,all-upfront,123456789012 +`) + + var ( + got []common.Recommendation + err error + ) + out := captureAppOutput(t, func() { + got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MinCount: minCount}) + }) + require.NoError(t, err) + + require.Len(t, got, 1, "the row at count=2 is below --min-count 5 and must not be purchased") + assert.Equal(t, "db.t3.medium", got[0].ResourceType) + assert.Equal(t, 6, got[0].Count) + assert.Contains(t, out, "--min-count dropped") + assert.Contains(t, out, "db.t3.small") +} + +// TestRunToolFromCSVEnforcesMinCountAndCap drives the guards end to end +// through the real --input-csv entry point and asserts on the purchase report, +// in both directions: the guards bind when they should, and a legitimate run +// under the same flags still purchases every row at its full count. +func TestRunToolFromCSVEnforcesMinCountAndCap(t *testing.T) { + origCfg := toolCfg + t.Cleanup(func() { toolCfg = origCfg }) + isolateAWSEnv(t) + + // Worst-value row first, so file order and savings order disagree. + csvPath := writeTestRecommendationsCSV(t, csvFixtureHeader+ + `rds,us-east-1,db.t3.small,postgres,6,10.00,1yr,all-upfront,123456789012 +rds,us-east-1,db.t3.medium,postgres,6,500.00,1yr,all-upfront,123456789012 +rds,us-east-1,db.t3.large,postgres,6,100.00,1yr,all-upfront,123456789012 +`) + + tests := []struct { + name string + wantRows []string + maxInstances int32 + minCount int + }{ + { + // Budget 10 across rows of 6: the best row takes 6, the next is + // truncated to 4, and 4 is under the floor, so it is dropped. + // A file-ordered cap would instead buy db.t3.small at 6. + name: "cap and floor bind", + maxInstances: 10, + minCount: 5, + wantRows: []string{"db.t3.medium@6"}, + }, + { + // Same floor, cap above the natural total: nothing is dropped and + // every row is purchased at its CSV count. A guard that + // over-blocks is as wrong as one that never fires. + name: "legitimate run purchases every row", + maxInstances: 100, + minCount: 5, + wantRows: []string{"db.t3.small@6", "db.t3.medium@6", "db.t3.large@6"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reportPath := filepath.Join(t.TempDir(), "report.csv") + toolCfg.CSVInput = csvPath + toolCfg.CSVOutput = reportPath + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.ActualPurchase = false + toolCfg.Coverage = 100.0 + toolCfg.TargetCoverage = 0 + toolCfg.OverrideCount = 0 + toolCfg.MaxInstances = tt.maxInstances + toolCfg.MinCount = tt.minCount + + require.NoError(t, runToolFromCSV(context.Background(), toolCfg)) + + header, rows := readPurchaseReport(t, reportPath) + assert.ElementsMatch(t, tt.wantRows, reportRowPairs(t, header, rows)) + }) + } +} + +// reportRowPairs pairs each purchase-report row's ResourceType with its Count. +// Asserting the two columns independently would pass a result that put the +// right counts against the wrong rows, which is precisely what a wrong +// selection order produces. +func reportRowPairs(t *testing.T, header []string, rows [][]string) []string { + t.Helper() + types := reportColumn(t, header, rows, "ResourceType") + counts := reportColumn(t, header, rows, "Count") + require.Len(t, counts, len(types)) + + pairs := make([]string, 0, len(types)) + for i := range types { + pairs = append(pairs, types[i]+"@"+counts[i]) + } + return pairs +} + +// TestCSVCapRanksBySavingsPerInstance pins the ranking key the cap consumes. +// +// --max-instances is a budget in instances, so the rows worth keeping are the +// ones returning the most savings per instance bought. Ranking on the +// EstimatedSavings column directly ranks a whole-row dollar total against a +// per-instance budget: the fixture's bulk row is larger in total ($600) and +// worse per instance ($6) than the rich row ($500 total, $83 each), so a +// total-ranked cap spends the entire 10-instance budget on bulk for about $60 +// of real value and drops rich outright. +// +// Every earlier fixture in this file uses equal counts, where ranking by total +// and ranking by rate are indistinguishable. The counts here are deliberately +// unequal, which is the only way to tell the two rules apart. +func TestCSVCapRanksBySavingsPerInstance(t *testing.T) { + isolateAWSEnv(t) + const maxInstances = 10 + + recs := csvRecsFrom(t, `rds,us-east-1,db.bulk.large,postgres,100,600.00,1yr,all-upfront,123456789012 +rds,us-east-1,db.rich.large,postgres,6,500.00,1yr,all-upfront,123456789012 +`) + + var ( + got []common.Recommendation + err error + ) + out := captureAppOutput(t, func() { + got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: maxInstances}) + }) + require.NoError(t, err) + + require.Equal(t, maxInstances, CalculateTotalInstances(got), + "the cap total is respected by both the right and the wrong ranking, and is asserted only to keep the fixture honest") + + // The substance: ORDER, and the count each row is bought at. The + // best-value row must be taken whole before the bulk row gets the + // remainder. + require.Len(t, got, 2) + assert.Equal(t, "db.rich.large", got[0].ResourceType, + "the budget must go to the highest savings-per-instance row first; got %s", got[0].ResourceType) + assert.Equal(t, 6, got[0].Count) + assert.Equal(t, "db.bulk.large", got[1].ResourceType) + assert.Equal(t, maxInstances-6, got[1].Count, "the bulk row takes only the remaining budget") + + assert.Contains(t, out, "savings-per-instance", + "the cap must name the ranking rule it applied, not claim a savings percentage a CSV row does not carry") +} diff --git a/cmd/multi_service_max_instances_test.go b/cmd/multi_service_max_instances_test.go index 3ffdf31e8..0d09f20d6 100644 --- a/cmd/multi_service_max_instances_test.go +++ b/cmd/multi_service_max_instances_test.go @@ -2,7 +2,6 @@ package main import ( "context" - "path/filepath" "testing" "github.com/LeanerCloud/cloud-commitments-go/pkg/common" @@ -429,480 +428,3 @@ func TestReportInstanceLimitNamesEveryChange(t *testing.T) { } // ==================== --input-csv path (#1741) ==================== - -// csvFixtureHeader matches writeMultiServiceCSVReport's column names for the -// fields these tests exercise, so the fixtures parse the way a real -// round-tripped report does. -const csvFixtureHeader = "Service,Region,ResourceType,Engine,Count,EstimatedSavings,Term,PaymentOption,Account\n" - -// csvNoSavingsHeader is csvFixtureHeader without the EstimatedSavings column, -// the shape of a hand-written minimal CSV. getCSVField returns "" for a column -// that is not in the header and parseCSVFloat leaves the field at zero, so -// every row of such a file loads with EstimatedSavings == 0. -const csvNoSavingsHeader = "Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account\n" - -// csvRecsFrom writes rows to a temporary recommendations CSV, loads them back -// through the production parser, and returns the recommendations exactly as a -// --input-csv run sees them. -// -// Building the fixture through loadRecommendationsFromCSV rather than by hand -// keeps these tests honest about what a CSV row actually carries: neither -// writeMultiServiceCSVReport nor parseCSVRecord knows a savings-percentage -// column, so every loaded row has SavingsPercentage == 0 and any -// savings-ordered selection has to resolve on EstimatedSavings. A hand-built -// fixture could quietly set SavingsPercentage and prove nothing about the real -// input. -func csvRecsFrom(t *testing.T, rows string) []common.Recommendation { - t.Helper() - return csvRecsFromHeader(t, csvFixtureHeader, rows) -} - -// csvRecsFromHeader is csvRecsFrom over an arbitrary header, for fixtures that -// exercise a CSV missing a column the selection depends on. -func csvRecsFromHeader(t *testing.T, header, rows string) []common.Recommendation { - t.Helper() - recs, err := loadRecommendationsFromCSV(writeTestRecommendationsCSV(t, header+rows)) - require.NoError(t, err) - require.NotEmpty(t, recs) - for i := range recs { - require.Zero(t, recs[i].SavingsPercentage, - "SavingsPercentage must stay zero here; the ordering under test ranks on EstimatedSavings / Count") - } - return recs -} - -// TestCSVCapKeepsHighestSavingsNotFileOrder is the selection-order regression -// for #1741. -// -// filterAndAdjustRecommendations used to hand the load-ordered slice straight -// to ApplyInstanceLimit, which consumes its input in slice order and drops the -// tail, so whichever rows appeared first in the file spent the whole budget. -// Both the old and the new behavior respect the cap total, so only a fixture -// whose best-value rows are *not* first can tell them apart: the $10 row leads -// the file and the $500 row sits in the middle. -func TestCSVCapKeepsHighestSavingsNotFileOrder(t *testing.T) { - isolateAWSEnv(t) - const ( - maxInstances = 10 - countPerRow = 6 - ) - - recs := csvRecsFrom(t, `rds,us-east-1,db.t3.small,postgres,6,10.00,1yr,all-upfront,123456789012 -rds,us-east-1,db.t3.medium,postgres,6,500.00,1yr,all-upfront,123456789012 -rds,us-east-1,db.t3.large,postgres,6,100.00,1yr,all-upfront,123456789012 -`) - - var ( - got []common.Recommendation - err error - ) - out := captureAppOutput(t, func() { - got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: maxInstances}) - }) - require.NoError(t, err) - - // Asserted only to keep the fixture honest: a file-ordered cap satisfies - // the total too, so the total on its own proves nothing here. - require.Equal(t, maxInstances, CalculateTotalInstances(got)) - - // The property under test is the selection ORDER. The budget must go to - // the highest-savings rows, best first, not to the rows the file happened - // to list first. - require.Len(t, got, 2) - assert.Equal(t, "db.t3.medium", got[0].ResourceType, - "the cap must consume savings order, not file order; got %s first", got[0].ResourceType) - assert.InDelta(t, 500.00, got[0].EstimatedSavings, 0.001) - assert.Equal(t, countPerRow, got[0].Count) - assert.Equal(t, "db.t3.large", got[1].ResourceType) - assert.Equal(t, maxInstances-countPerRow, got[1].Count) - // This row is truncated, so its savings are the file's figure scaled by - // the share of the row the budget actually buys (#1830). Asserting the - // unscaled 100.00 here is what the bug looked like. - assert.InDelta(t, 100.00*float64(maxInstances-countPerRow)/float64(countPerRow), - got[1].EstimatedSavings, 0.001) - - // Nothing shrinks silently: the row the cap dropped is named. - assert.Contains(t, out, "db.t3.small") -} - -// TestCSVCapDropsTruncationBelowMinCount pins that the CSV path cannot deliver -// a purchase smaller than the operator's --min-count floor, the property #1608 -// and #1725 established on the recommendation-driven path. -// -// Fixture: two rows of 6 instances, cap 10, floor 5. The budget leaves room -// for 6 + 4, and 4 is under the floor, so the second row is dropped rather -// than purchased short. The run buys 6. -func TestCSVCapDropsTruncationBelowMinCount(t *testing.T) { - isolateAWSEnv(t) - const ( - maxInstances = 10 - minCount = 5 - countPerRow = 6 - ) - - recs := csvRecsFrom(t, `rds,us-east-1,db.t3.large,postgres,6,100.00,1yr,all-upfront,123456789012 -rds,us-east-1,db.t3.medium,postgres,6,500.00,1yr,all-upfront,123456789012 -`) - - var ( - got []common.Recommendation - err error - ) - out := captureAppOutput(t, func() { - got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: maxInstances, MinCount: minCount}) - }) - require.NoError(t, err) - - for i := range got { - assert.GreaterOrEqual(t, got[i].Count, minCount, - "--min-count %d was set, but %s would be purchased at count=%d", - minCount, got[i].ResourceType, got[i].Count) - } - - require.Len(t, got, 1) - assert.Equal(t, "db.t3.medium", got[0].ResourceType) - assert.Equal(t, countPerRow, got[0].Count) - assert.Equal(t, countPerRow, CalculateTotalInstances(got)) - assert.Contains(t, out, "below --min-count 5") -} - -// TestCSVMinCountDropsRowsUnderTheFloor pins the floor with no cap in play: a -// CSV row that is already under --min-count is dropped, not purchased. The -// higher-value row is the one under the floor, so a selection that sorts by -// savings without gating on the floor still buys it. -func TestCSVMinCountDropsRowsUnderTheFloor(t *testing.T) { - isolateAWSEnv(t) - const minCount = 5 - - recs := csvRecsFrom(t, `rds,us-east-1,db.t3.small,postgres,2,500.00,1yr,all-upfront,123456789012 -rds,us-east-1,db.t3.medium,postgres,6,100.00,1yr,all-upfront,123456789012 -`) - - var ( - got []common.Recommendation - err error - ) - out := captureAppOutput(t, func() { - got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MinCount: minCount}) - }) - require.NoError(t, err) - - require.Len(t, got, 1, "the row at count=2 is below --min-count 5 and must not be purchased") - assert.Equal(t, "db.t3.medium", got[0].ResourceType) - assert.Equal(t, 6, got[0].Count) - assert.Contains(t, out, "--min-count dropped") - assert.Contains(t, out, "db.t3.small") -} - -// TestRunToolFromCSVEnforcesMinCountAndCap drives the guards end to end -// through the real --input-csv entry point and asserts on the purchase report, -// in both directions: the guards bind when they should, and a legitimate run -// under the same flags still purchases every row at its full count. -func TestRunToolFromCSVEnforcesMinCountAndCap(t *testing.T) { - origCfg := toolCfg - t.Cleanup(func() { toolCfg = origCfg }) - isolateAWSEnv(t) - - // Worst-value row first, so file order and savings order disagree. - csvPath := writeTestRecommendationsCSV(t, csvFixtureHeader+ - `rds,us-east-1,db.t3.small,postgres,6,10.00,1yr,all-upfront,123456789012 -rds,us-east-1,db.t3.medium,postgres,6,500.00,1yr,all-upfront,123456789012 -rds,us-east-1,db.t3.large,postgres,6,100.00,1yr,all-upfront,123456789012 -`) - - tests := []struct { - name string - wantRows []string - maxInstances int32 - minCount int - }{ - { - // Budget 10 across rows of 6: the best row takes 6, the next is - // truncated to 4, and 4 is under the floor, so it is dropped. - // A file-ordered cap would instead buy db.t3.small at 6. - name: "cap and floor bind", - maxInstances: 10, - minCount: 5, - wantRows: []string{"db.t3.medium@6"}, - }, - { - // Same floor, cap above the natural total: nothing is dropped and - // every row is purchased at its CSV count. A guard that - // over-blocks is as wrong as one that never fires. - name: "legitimate run purchases every row", - maxInstances: 100, - minCount: 5, - wantRows: []string{"db.t3.small@6", "db.t3.medium@6", "db.t3.large@6"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - reportPath := filepath.Join(t.TempDir(), "report.csv") - toolCfg.CSVInput = csvPath - toolCfg.CSVOutput = reportPath - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.ActualPurchase = false - toolCfg.Coverage = 100.0 - toolCfg.TargetCoverage = 0 - toolCfg.OverrideCount = 0 - toolCfg.MaxInstances = tt.maxInstances - toolCfg.MinCount = tt.minCount - - require.NoError(t, runToolFromCSV(context.Background(), toolCfg)) - - header, rows := readPurchaseReport(t, reportPath) - assert.ElementsMatch(t, tt.wantRows, reportRowPairs(t, header, rows)) - }) - } -} - -// reportRowPairs pairs each purchase-report row's ResourceType with its Count. -// Asserting the two columns independently would pass a result that put the -// right counts against the wrong rows, which is precisely what a wrong -// selection order produces. -func reportRowPairs(t *testing.T, header []string, rows [][]string) []string { - t.Helper() - types := reportColumn(t, header, rows, "ResourceType") - counts := reportColumn(t, header, rows, "Count") - require.Len(t, counts, len(types)) - - pairs := make([]string, 0, len(types)) - for i := range types { - pairs = append(pairs, types[i]+"@"+counts[i]) - } - return pairs -} - -// TestCSVCapRanksBySavingsPerInstance pins the ranking key the cap consumes. -// -// --max-instances is a budget in instances, so the rows worth keeping are the -// ones returning the most savings per instance bought. Ranking on the -// EstimatedSavings column directly ranks a whole-row dollar total against a -// per-instance budget: the fixture's bulk row is larger in total ($600) and -// worse per instance ($6) than the rich row ($500 total, $83 each), so a -// total-ranked cap spends the entire 10-instance budget on bulk for about $60 -// of real value and drops rich outright. -// -// Every earlier fixture in this file uses equal counts, where ranking by total -// and ranking by rate are indistinguishable. The counts here are deliberately -// unequal, which is the only way to tell the two rules apart. -func TestCSVCapRanksBySavingsPerInstance(t *testing.T) { - isolateAWSEnv(t) - const maxInstances = 10 - - recs := csvRecsFrom(t, `rds,us-east-1,db.bulk.large,postgres,100,600.00,1yr,all-upfront,123456789012 -rds,us-east-1,db.rich.large,postgres,6,500.00,1yr,all-upfront,123456789012 -`) - - var ( - got []common.Recommendation - err error - ) - out := captureAppOutput(t, func() { - got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: maxInstances}) - }) - require.NoError(t, err) - - require.Equal(t, maxInstances, CalculateTotalInstances(got), - "the cap total is respected by both the right and the wrong ranking, and is asserted only to keep the fixture honest") - - // The substance: ORDER, and the count each row is bought at. The - // best-value row must be taken whole before the bulk row gets the - // remainder. - require.Len(t, got, 2) - assert.Equal(t, "db.rich.large", got[0].ResourceType, - "the budget must go to the highest savings-per-instance row first; got %s", got[0].ResourceType) - assert.Equal(t, 6, got[0].Count) - assert.Equal(t, "db.bulk.large", got[1].ResourceType) - assert.Equal(t, maxInstances-6, got[1].Count, "the bulk row takes only the remaining budget") - - assert.Contains(t, out, "savings-per-instance", - "the cap must name the ranking rule it applied, not claim a savings percentage a CSV row does not carry") -} - -// TestCSVCapRefusesRowsWithoutARankingSignal covers the absent-vs-zero trap. -// -// A CSV with no EstimatedSavings column, or with a blank cell in it, loads -// every affected row at zero savings. Nothing downstream can tell that apart -// from a row genuinely worth $0, so a binding cap would rank on a value that -// is not there and silently fall through to the Service|Region|ResourceType -// tie-break, buying by instance-type name while stdout promises it is buying -// by savings. The run is refused instead. -// -// Both directions are covered: the two refusal cases, a case proving the -// refusal is scoped to a cap that actually binds, and a legitimate file that -// still ranks and caps normally. -func TestCSVCapRefusesRowsWithoutARankingSignal(t *testing.T) { - isolateAWSEnv(t) - - t.Run("no EstimatedSavings column at all", func(t *testing.T) { - // Worst alphabetically first, so a name-ordered cap is visible: it - // keeps db.aaa.large and drops db.zzz.large regardless of file order. - recs := csvRecsFromHeader(t, csvNoSavingsHeader, - `rds,us-east-1,db.zzz.large,postgres,6,1yr,all-upfront,123456789012 -rds,us-east-1,db.aaa.large,postgres,6,1yr,all-upfront,123456789012 -`) - - var err error - captureAppOutput(t, func() { - _, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: 6}) - }) - - require.Error(t, err, "a binding cap with nothing to rank on must refuse, not pick by name") - assert.Contains(t, err.Error(), "EstimatedSavings") - assert.Contains(t, err.Error(), "db.zzz.large", "the error must name the rows it cannot rank") - assert.Contains(t, err.Error(), "db.aaa.large") - }) - - t.Run("one blank EstimatedSavings cell among populated rows", func(t *testing.T) { - // The partial case is the worse one: the blank row loads as $0, ranks - // last, and is dropped first, with nothing saying the value was absent - // rather than genuinely zero. - recs := csvRecsFrom(t, `rds,us-east-1,db.t3.medium,postgres,6,500.00,1yr,all-upfront,123456789012 -rds,us-east-1,db.t3.small,postgres,6,,1yr,all-upfront,123456789012 -`) - - var err error - captureAppOutput(t, func() { - _, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: 10}) - }) - - require.Error(t, err) - assert.Contains(t, err.Error(), "db.t3.small", "the blank row must be named") - assert.NotContains(t, err.Error(), "db.t3.medium", "a row with a real savings value is rankable and must not be blamed") - }) - - t.Run("no savings column but the cap does not bind", func(t *testing.T) { - // Nothing has to be chosen between, so nothing has to be ranked. A - // guard that over-blocks is as wrong as one that never fires. - recs := csvRecsFromHeader(t, csvNoSavingsHeader, - `rds,us-east-1,db.zzz.large,postgres,6,1yr,all-upfront,123456789012 -rds,us-east-1,db.aaa.large,postgres,6,1yr,all-upfront,123456789012 -`) - - var ( - got []common.Recommendation - err error - ) - captureAppOutput(t, func() { - got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: 12}) - }) - - require.NoError(t, err) - assert.Len(t, got, 2) - assert.Equal(t, 12, CalculateTotalInstances(got)) - }) - - t.Run("populated savings column still ranks and caps", func(t *testing.T) { - recs := csvRecsFrom(t, `rds,us-east-1,db.t3.small,postgres,6,10.00,1yr,all-upfront,123456789012 -rds,us-east-1,db.t3.medium,postgres,6,500.00,1yr,all-upfront,123456789012 -`) - - var ( - got []common.Recommendation - err error - ) - captureAppOutput(t, func() { - got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: 6}) - }) - - require.NoError(t, err) - require.Len(t, got, 1) - assert.Equal(t, "db.t3.medium", got[0].ResourceType) - assert.Equal(t, 6, got[0].Count) - }) -} - -// TestApplyMinCountFloorAfterDuplicateAdjustment covers the floor's second -// call site, the one inside runToolFromCSV's purchase loop. -// -// adjustRecsForDuplicates subtracts recent matching commitments from Count -// after filterAndAdjustRecommendations has already applied the floor, so a row -// that cleared --min-count 5 at Count 6 with 5 existing commitments arrives at -// the purchase loop at Count 1. Without a second application the operator's -// floor is defeated on the very path #1741 is about. -// -// This exercises applyMinCountFloor on the post-deduction counts rather than -// through runToolFromCSV, because createServiceClient is not injectable: there -// is no seam to drive a fake GetExistingCommitments through the CSV entry -// point, and with invalid credentials adjustRecsForDuplicates errors and falls -// back to the unadjusted slice. -func TestApplyMinCountFloorAfterDuplicateAdjustment(t *testing.T) { - const minCount = 5 - - // Counts as adjustRecsForDuplicates leaves them: the first row had 6 and - // 5 existing commitments deducted, the second was untouched. - adjusted := []common.Recommendation{ - {Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.large", Count: 1, EstimatedSavings: 500}, - {Service: common.ServiceRDS, Region: "us-east-1", ResourceType: "db.t3.medium", Count: 6, EstimatedSavings: 100}, - } - - // Snapshot before the first call. applyMinCountFloor returns its input slice - // untouched at a floor of 0, and scorer.Score may reorder adjusted below, so - // asserting against adjusted itself would compare the result to itself. - original := append([]common.Recommendation(nil), adjusted...) - - var got []common.Recommendation - out := captureAppOutput(t, func() { - got = applyMinCountFloor(adjusted, minCount) - }) - - require.Len(t, got, 1, "the row the deduction left at 1 is below --min-count 5 and must not be purchased") - assert.Equal(t, "db.t3.medium", got[0].ResourceType) - assert.Equal(t, 6, got[0].Count) - assert.Contains(t, out, "db.t3.large", "the drop must be named") - assert.Contains(t, out, "--min-count") - - // A floor of 0 disables the flag: every row survives, in the order given. - var unfiltered []common.Recommendation - quiet := captureAppOutput(t, func() { - unfiltered = applyMinCountFloor(adjusted, 0) - }) - assert.Equal(t, original, unfiltered, "--min-count 0 must not drop or reorder anything") - assert.Empty(t, quiet) -} - -// TestCSVCapOrderIsIndependentOfFileOrder pins that equal-rate rows are not -// separated by where they sit in the file. -// -// --min-count 0 skips the scorer, so the per-instance sort is the only -// ordering the cap sees. If it left equal rates in input order, file order -// would still decide which of two equally-valuable rows the budget buys, which -// is the defect #1741 reported, surviving one layer down. -func TestCSVCapOrderIsIndependentOfFileOrder(t *testing.T) { - isolateAWSEnv(t) - - // Two rows at the identical rate ($100 for 6 instances), listed - // worst-alphabetically-first. - rows := []string{ - "rds,us-east-1,db.t3.zulu,postgres,6,100.00,1yr,all-upfront,123456789012\n", - "rds,us-east-1,db.t3.alpha,postgres,6,100.00,1yr,all-upfront,123456789012\n", - } - - selection := func(t *testing.T, rows []string) []string { - t.Helper() - recs := csvRecsFrom(t, rows[0]+rows[1]) - var ( - got []common.Recommendation - err error - ) - captureAppOutput(t, func() { - got, err = filterAndAdjustRecommendations(context.Background(), recs, 100.0, Config{MaxInstances: 6}) - }) - require.NoError(t, err) - - names := make([]string, 0, len(got)) - for i := range got { - names = append(names, got[i].ResourceType) - } - return names - } - - forward := selection(t, rows) - reversed := selection(t, []string{rows[1], rows[0]}) - - assert.Equal(t, []string{"db.t3.alpha"}, forward, - "equal rates must tie-break on Service|Region|ResourceType, not on file position") - assert.Equal(t, forward, reversed, "reversing the file must not change what the cap buys") -} diff --git a/cmd/multi_service_process_service_test.go b/cmd/multi_service_process_service_test.go new file mode 100644 index 000000000..aa30ca01d --- /dev/null +++ b/cmd/multi_service_process_service_test.go @@ -0,0 +1,190 @@ +package main + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestRunToolMultiService_Validation(t *testing.T) { + // Save original values + origCfg := toolCfg + + // Restore after test + defer func() { + toolCfg = origCfg + }() + + tests := []struct { + setupVars func() + name string + expectPanic bool + }{ + { + name: "Valid input - all services", + setupVars: func() { + toolCfg.Coverage = 75.0 + toolCfg.PaymentOption = "partial-upfront" + toolCfg.TermYears = 3 + toolCfg.AllServices = true + toolCfg.Services = nil + }, + expectPanic: false, + }, + { + name: "Valid input - specific services", + setupVars: func() { + toolCfg.Coverage = 50.0 + toolCfg.PaymentOption = "no-upfront" + toolCfg.TermYears = 1 + toolCfg.AllServices = false + toolCfg.Services = []string{"rds", "ec2"} + }, + expectPanic: false, + }, + { + name: "Invalid coverage - too high", + setupVars: func() { + toolCfg.Coverage = 150.0 + toolCfg.PaymentOption = "partial-upfront" + toolCfg.TermYears = 3 + }, + expectPanic: true, + }, + { + name: "Invalid coverage - negative", + setupVars: func() { + toolCfg.Coverage = -10.0 + toolCfg.PaymentOption = "all-upfront" + toolCfg.TermYears = 1 + }, + expectPanic: true, + }, + { + name: "Invalid payment option", + setupVars: func() { + toolCfg.Coverage = 80.0 + toolCfg.PaymentOption = "invalid-payment" + toolCfg.TermYears = 3 + }, + expectPanic: true, + }, + { + name: "Invalid term years", + setupVars: func() { + toolCfg.Coverage = 80.0 + toolCfg.PaymentOption = "partial-upfront" + toolCfg.TermYears = 2 // Only 1 or 3 allowed + }, + expectPanic: true, + }, + { + name: "Default to RDS when no services", + setupVars: func() { + toolCfg.Coverage = 80.0 + toolCfg.PaymentOption = "all-upfront" + toolCfg.TermYears = 3 + toolCfg.AllServices = false + toolCfg.Services = nil + }, + expectPanic: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupVars() + + if tt.expectPanic { + // Can't easily test log.Fatalf, so we skip execution + // In production code, would use dependency injection + return + } + + // For non-panic tests, verify the setup is valid + assert.GreaterOrEqual(t, toolCfg.Coverage, 0.0) + assert.LessOrEqual(t, toolCfg.Coverage, 100.0) + assert.Contains(t, []string{"all-upfront", "partial-upfront", "no-upfront"}, toolCfg.PaymentOption) + assert.Contains(t, []int{1, 3}, toolCfg.TermYears) + }) + } +} + +func TestProcessService_EdgeCases(t *testing.T) { + // Save original values + origCfg := toolCfg + + defer func() { + toolCfg = origCfg + }() + + // Set test values + toolCfg.PaymentOption = "partial-upfront" + toolCfg.TermYears = 3 + + tests := []struct { + name string + setupFunc func() + service common.ServiceType + isDryRun bool + expectRecs int + }{ + { + name: "With explicit regions", + setupFunc: func() { + toolCfg.Regions = []string{"us-east-1"} + toolCfg.Coverage = 100.0 + }, + service: common.ServiceRDS, + isDryRun: true, + expectRecs: 0, // Would need mock to return actual recs + }, + { + name: "No regions triggers discovery", + setupFunc: func() { + toolCfg.Regions = []string{} + toolCfg.Coverage = 75.0 + }, + service: common.ServiceEC2, + isDryRun: false, + expectRecs: 0, // Would need mock + }, + { + name: "Zero coverage", + setupFunc: func() { + toolCfg.Regions = []string{"us-west-2"} + toolCfg.Coverage = 0.0 + }, + service: common.ServiceElastiCache, + isDryRun: true, + expectRecs: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setupFunc() + + // Verify test configuration was applied correctly + // Note: Full integration tests would require AWS credentials or mocks + // These tests verify the configuration setup is working as expected + + switch tt.name { + case "With explicit regions": + assert.Equal(t, []string{"us-east-1"}, toolCfg.Regions) + assert.Equal(t, 100.0, toolCfg.Coverage) + case "No regions triggers discovery": + assert.Empty(t, toolCfg.Regions) + assert.Equal(t, 75.0, toolCfg.Coverage) + case "Zero coverage": + assert.Equal(t, []string{"us-west-2"}, toolCfg.Regions) + assert.Equal(t, 0.0, toolCfg.Coverage) + } + + // Verify the test case service is valid + assert.NotEmpty(t, tt.service, "service should not be empty") + assert.GreaterOrEqual(t, tt.expectRecs, 0, "expected recommendations should be non-negative") + }) + } +} diff --git a/cmd/multi_service_purchase_loop_test.go b/cmd/multi_service_purchase_loop_test.go new file mode 100644 index 000000000..40f8413a0 --- /dev/null +++ b/cmd/multi_service_purchase_loop_test.go @@ -0,0 +1,296 @@ +package main + +import ( + "context" + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestProcessPurchaseLoopPurchaseFailure(t *testing.T) { + useCallerAccount(t) + ctx := context.Background() + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.Coverage = 80.0 + + recs := []common.Recommendation{ + {Service: common.ServiceRDS, ResourceType: "db.t3.large", Count: 1, EstimatedSavings: 500}, + } + + mockClient := &MockServiceClient{} + // Simulate a purchase failure + failureResult := common.PurchaseResult{ + Recommendation: recs[0], + Success: false, + CommitmentID: "", + Error: fmt.Errorf("API error: quota exceeded"), + Timestamp: time.Now(), + } + mockClient.On("PurchaseCommitment", mock.Anything, recs[0], mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(failureResult, nil) + + t.Setenv("DISABLE_PURCHASE_DELAY", "true") + + results, _ := processPurchaseLoop(ctx, recs, "ap-south-1", false, mockClient, toolCfg, "test-run") + + assert.Len(t, results, 1) + assert.False(t, results[0].Success) + assert.NotNil(t, results[0].Error) + assert.Contains(t, results[0].Error.Error(), "quota exceeded") + + mockClient.AssertExpectations(t) +} + +func TestProcessPurchaseLoopEmptyRecommendations(t *testing.T) { + ctx := context.Background() + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + + toolCfg.Coverage = 100.0 + + mockClient := &MockServiceClient{} + + results, _ := processPurchaseLoop(ctx, []common.Recommendation{}, "us-east-1", false, mockClient, toolCfg, "test-run") + + assert.Empty(t, results) + mockClient.AssertNotCalled(t, "PurchaseCommitment", mock.Anything, mock.Anything, mock.Anything) +} + +func TestProcessServicePurchasesUserCancellation(t *testing.T) { + useCallerAccount(t) + ctx := context.Background() + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.Coverage = 85.0 + + recs := []common.Recommendation{ + {Service: common.ServiceElastiCache, ResourceType: "cache.r6g.large", Count: 2, EstimatedSavings: 200}, + } + + mockClient := &MockServiceClient{} + result := common.PurchaseResult{ + Recommendation: recs[0], + Success: true, + CommitmentID: "cache-purchase-123", + Timestamp: time.Now(), + } + mockClient.On("PurchaseCommitment", mock.Anything, recs[0], mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) + + t.Setenv("DISABLE_PURCHASE_DELAY", "true") + + results, _ := processPurchaseLoop(ctx, recs, "us-west-1", false, mockClient, toolCfg, "test-run") + + assert.Len(t, results, 1) + assert.True(t, results[0].Success) + assert.Equal(t, "cache-purchase-123", results[0].CommitmentID) + + mockClient.AssertExpectations(t) +} + +func TestProcessServicePurchasesDryRunMultiple(t *testing.T) { + useCallerAccount(t) + ctx := context.Background() + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.Coverage = 100.0 + + recs := []common.Recommendation{ + {Service: common.ServiceRDS, ResourceType: "db.r5.xlarge", Count: 5, EstimatedSavings: 1000}, + {Service: common.ServiceRDS, ResourceType: "db.r5.2xlarge", Count: 3, EstimatedSavings: 800}, + {Service: common.ServiceRDS, ResourceType: "db.r5.4xlarge", Count: 1, EstimatedSavings: 500}, + } + + mockClient := &MockServiceClient{} + // Dry run should not call PurchaseCommitment + + results, _ := processPurchaseLoop(ctx, recs, "ap-northeast-1", true, mockClient, toolCfg, "test-run") + + assert.Len(t, results, 3) + for i, result := range results { + assert.True(t, result.Success) + assert.True(t, result.DryRun) + assert.Contains(t, result.CommitmentID, "dryrun") + assert.Nil(t, result.Error) + assert.Equal(t, recs[i].ResourceType, result.Recommendation.ResourceType) + } + + mockClient.AssertNotCalled(t, "PurchaseCommitment", mock.Anything, mock.Anything, mock.Anything) +} + +// ==================== New Extracted Function Tests ==================== + +func TestExecutePurchaseWithEmptyPurchaseID(t *testing.T) { + ctx := context.Background() + // Save original values + origCfg := toolCfg + + defer func() { + toolCfg = origCfg + }() + + toolCfg.Coverage = 85.0 + + rec := common.Recommendation{ + Service: common.ServiceElastiCache, + ResourceType: "cache.r5.large", + Count: 3, + } + + mockClient := &MockServiceClient{} + // Return result without PurchaseID + expectedResult := common.PurchaseResult{ + Recommendation: rec, + Success: true, + CommitmentID: "", // Empty ID - should be generated + Error: nil, + Timestamp: time.Now(), + } + mockClient.On("PurchaseCommitment", mock.Anything, rec, mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(expectedResult, nil) + + // Logger output disabled for testing + + result := executePurchase(ctx, rec, "ap-southeast-1", 2, mockClient, toolCfg) + + assert.True(t, result.Success) + assert.NotEmpty(t, result.CommitmentID) // Should have generated ID + assert.Contains(t, result.CommitmentID, "ap-southeast-1") + + mockClient.AssertExpectations(t) +} + +func TestProcessPurchaseLoopDryRun(t *testing.T) { + useCallerAccount(t) + ctx := context.Background() + // Save original values + origCfg := toolCfg + + defer func() { + toolCfg = origCfg + }() + + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.Coverage = 75.0 + + recs := []common.Recommendation{ + {Service: common.ServiceRDS, ResourceType: "db.t3.small", Count: 2, SourceRecommendation: "Test 1"}, + {Service: common.ServiceRDS, ResourceType: "db.t3.medium", Count: 3, SourceRecommendation: "Test 2"}, + } + + mockClient := &MockServiceClient{} + + // Logger output disabled for testing + + results, _ := processPurchaseLoop(ctx, recs, "us-east-1", true, mockClient, toolCfg, "test-run") + + assert.Len(t, results, 2) + for _, result := range results { + assert.True(t, result.Success) + assert.Nil(t, result.Error) // Dry runs are successful, so no error + assert.True(t, result.DryRun) + assert.Contains(t, result.CommitmentID, "dryrun") + } + + // Mock should not be called in dry run mode + mockClient.AssertNotCalled(t, "PurchaseCommitment", mock.Anything, mock.Anything, mock.Anything) +} + +func TestProcessPurchaseLoopActualPurchase(t *testing.T) { + useCallerAccount(t) + ctx := context.Background() + // Save original values + origCfg := toolCfg + + defer func() { + toolCfg = origCfg + }() + + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.Coverage = 80.0 + + recs := []common.Recommendation{ + {Service: common.ServiceEC2, ResourceType: "t3.small", Count: 1, SourceRecommendation: "EC2 Test 1", EstimatedSavings: 100, Details: &common.ComputeDetails{InstanceType: "t3.small", Platform: "Linux/UNIX", Tenancy: "default", Scope: "regional"}}, + {Service: common.ServiceEC2, ResourceType: "t3.medium", Count: 2, SourceRecommendation: "EC2 Test 2", EstimatedSavings: 200, Details: &common.ComputeDetails{InstanceType: "t3.medium", Platform: "Linux/UNIX", Tenancy: "default", Scope: "regional"}}, + } + + mockClient := &MockServiceClient{} + for i, rec := range recs { + result := common.PurchaseResult{ + Recommendation: rec, + Success: true, + CommitmentID: fmt.Sprintf("purchase-id-%d", i), + Error: nil, + Timestamp: time.Now(), + } + mockClient.On("PurchaseCommitment", mock.Anything, rec, mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) + } + + // Logger output disabled for testing + + // Disable purchase delay for testing + t.Setenv("DISABLE_PURCHASE_DELAY", "true") + + results, _ := processPurchaseLoop(ctx, recs, "eu-west-1", false, mockClient, toolCfg, "test-run") + + assert.Len(t, results, 2) + for i, result := range results { + assert.True(t, result.Success) + assert.Equal(t, fmt.Sprintf("purchase-id-%d", i), result.CommitmentID) + } + + mockClient.AssertExpectations(t) +} + +func TestProcessPurchaseLoopWithConfirmation(t *testing.T) { + useCallerAccount(t) + ctx := context.Background() + // Save original values + origCfg := toolCfg + + defer func() { + toolCfg = origCfg + }() + + toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") + toolCfg.Coverage = 80.0 + + recs := []common.Recommendation{ + {Service: common.ServiceRDS, ResourceType: "db.r5.large", Count: 5, SourceRecommendation: "Expensive", EstimatedSavings: 1000}, + } + + mockClient := &MockServiceClient{} + // processPurchaseLoop no longer confirms internally (confirmPurchaseRun + // runs once in runToolFromCSV before this loop is reached), so it always + // proceeds straight to the purchase call. + result := common.PurchaseResult{ + Recommendation: recs[0], + Success: true, + CommitmentID: "confirmed-purchase-123", + Error: nil, + Timestamp: time.Now(), + } + mockClient.On("PurchaseCommitment", mock.Anything, recs[0], mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) + + // Logger output disabled for testing + + // Disable purchase delay for testing + t.Setenv("DISABLE_PURCHASE_DELAY", "true") + + results, _ := processPurchaseLoop(ctx, recs, "us-west-2", false, mockClient, toolCfg, "test-run") + + assert.Len(t, results, 1) + assert.True(t, results[0].Success) + assert.Equal(t, "confirmed-purchase-123", results[0].CommitmentID) + + mockClient.AssertExpectations(t) +} diff --git a/cmd/multi_service_regions_test.go b/cmd/multi_service_regions_test.go new file mode 100644 index 000000000..ec61597fd --- /dev/null +++ b/cmd/multi_service_regions_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "errors" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestGetAllAWSRegions(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + mockOutput *ec2.DescribeRegionsOutput + mockError error + expectRegions []string + expectError bool + }{ + { + name: "Success with multiple regions", + mockOutput: &ec2.DescribeRegionsOutput{ + Regions: []types.Region{ + {RegionName: aws.String("us-east-1")}, + {RegionName: aws.String("eu-west-1")}, + {RegionName: aws.String("ap-south-1")}, + }, + }, + expectRegions: []string{"ap-south-1", "eu-west-1", "us-east-1"}, // Sorted + expectError: false, + }, + { + name: "Error from AWS API", + mockOutput: nil, + mockError: errors.New("AWS API error"), + expectRegions: nil, + expectError: true, + }, + { + name: "Empty regions list", + mockOutput: &ec2.DescribeRegionsOutput{ + Regions: []types.Region{}, + }, + expectRegions: []string{}, + expectError: false, + }, + { + name: "Regions with nil names", + mockOutput: &ec2.DescribeRegionsOutput{ + Regions: []types.Region{ + {RegionName: aws.String("us-east-1")}, + {RegionName: nil}, + {RegionName: aws.String("eu-west-1")}, + }, + }, + expectRegions: []string{"eu-west-1", "us-east-1"}, // Sorted, nil excluded + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockEC2 := &MockEC2Client{} + mockEC2.On("DescribeRegions", ctx, mock.Anything).Return(tt.mockOutput, tt.mockError) + + // Use the new interface-based function + regions, err := getAllAWSRegionsWithClient(ctx, mockEC2) + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, regions) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectRegions, regions) + } + + mockEC2.AssertExpectations(t) + }) + } + + t.Run("SDK client path against the fixture endpoint", func(t *testing.T) { + cfg, awsStub := newAWSFixtureConfig(t) + regions, err := getAllAWSRegions(ctx, cfg) + + require.NoError(t, err) + assert.Equal(t, []string{"eu-west-1", "us-west-2"}, regions, "regions are sorted") + awsStub.requireRequests(t, map[string]int{"ec2.us-east-1.amazonaws.com DescribeRegions": 1}) + }) +} + +func TestDiscoverRegionsForService(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + service common.ServiceType + mockReturns []common.Recommendation + expectedRegions []string + expectError bool + }{ + { + name: "Multiple unique regions", + service: common.ServiceRDS, + mockReturns: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "db.t3.micro"}, + {Region: "us-west-2", ResourceType: "db.t3.small"}, + {Region: "eu-west-1", ResourceType: "db.t3.medium"}, + }, + expectedRegions: []string{"eu-west-1", "us-east-1", "us-west-2"}, + }, + { + name: "Duplicate regions", + service: common.ServiceEC2, + mockReturns: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "t3.micro"}, + {Region: "us-east-1", ResourceType: "t3.small"}, + {Region: "us-west-2", ResourceType: "t3.medium"}, + }, + expectedRegions: []string{"us-east-1", "us-west-2"}, + }, + { + name: "No recommendations", + service: common.ServiceElastiCache, + mockReturns: []common.Recommendation{}, + expectedRegions: []string{}, + }, + { + name: "Recommendations with empty regions filtered", + service: common.ServiceRedshift, + mockReturns: []common.Recommendation{ + {Region: "us-east-1", ResourceType: "ra3.xlplus"}, + {Region: "", ResourceType: "ra3.4xlarge"}, + {Region: "us-west-2", ResourceType: "ra3.16xlarge"}, + }, + expectedRegions: []string{"us-east-1", "us-west-2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &MockRecommendationsClient{} + mockClient.On("GetRecommendations", ctx, mock.MatchedBy(func(p *common.RecommendationParams) bool { + return p.Service == tt.service && p.Region == "" + })).Return(tt.mockReturns, nil) + + regions, err := discoverRegionsForService(ctx, mockClient, tt.service, Config{TermYears: 1}) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedRegions, regions) + + mockClient.AssertExpectations(t) + }) + } +} diff --git a/cmd/multi_service_should_include_test.go b/cmd/multi_service_should_include_test.go new file mode 100644 index 000000000..8b259c3c9 --- /dev/null +++ b/cmd/multi_service_should_include_test.go @@ -0,0 +1,355 @@ +package main + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestShouldIncludeRegion(t *testing.T) { + // Save original values + origCfg := toolCfg + + defer func() { + toolCfg = origCfg + }() + + tests := []struct { + name string + region string + includeRegions []string + excludeRegions []string + expected bool + }{ + { + name: "No filters - should include", + region: "us-east-1", + includeRegions: []string{}, + excludeRegions: []string{}, + expected: true, + }, + { + name: "In include list", + region: "us-east-1", + includeRegions: []string{"us-east-1", "us-west-2"}, + excludeRegions: []string{}, + expected: true, + }, + { + name: "Not in include list", + region: "eu-west-1", + includeRegions: []string{"us-east-1"}, + excludeRegions: []string{}, + expected: false, + }, + { + name: "In exclude list", + region: "us-east-1", + includeRegions: []string{}, + excludeRegions: []string{"us-east-1"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + toolCfg.IncludeRegions = tt.includeRegions + toolCfg.ExcludeRegions = tt.excludeRegions + + result := shouldIncludeRegion(tt.region, &toolCfg) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestShouldIncludeInstanceType(t *testing.T) { + // Save original values + origCfg := toolCfg + + defer func() { + toolCfg = origCfg + }() + + tests := []struct { + name string + instanceType string + includeInstanceTypes []string + excludeInstanceTypes []string + expected bool + }{ + { + name: "No filters - should include", + instanceType: "db.t3.micro", + includeInstanceTypes: []string{}, + excludeInstanceTypes: []string{}, + expected: true, + }, + { + name: "In include list", + instanceType: "cache.t3.micro", + includeInstanceTypes: []string{"cache.t3.micro"}, + excludeInstanceTypes: []string{}, + expected: true, + }, + { + name: "In exclude list", + instanceType: "db.t3.large", + includeInstanceTypes: []string{}, + excludeInstanceTypes: []string{"db.t3.large"}, + expected: false, + }, + { + name: "Not in include list - excluded via whitelist", + instanceType: "db.r5.large", + includeInstanceTypes: []string{"db.t3.micro"}, + excludeInstanceTypes: []string{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + toolCfg.IncludeInstanceTypes = tt.includeInstanceTypes + toolCfg.ExcludeInstanceTypes = tt.excludeInstanceTypes + + result := shouldIncludeInstanceType(tt.instanceType, &toolCfg) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestShouldIncludeAccount(t *testing.T) { + // Save original values + origCfg := toolCfg + + defer func() { + toolCfg = origCfg + }() + + tests := []struct { + name string + accountID string + includeAccounts []string + excludeAccounts []string + expected bool + }{ + { + name: "No filters - should include", + accountID: "123456789012", + includeAccounts: []string{}, + excludeAccounts: []string{}, + expected: true, + }, + { + name: "In include list", + accountID: "123456789012", + includeAccounts: []string{"123456789012", "111111111111"}, + excludeAccounts: []string{}, + expected: true, + }, + { + name: "Not in include list", + accountID: "999888777666", + includeAccounts: []string{"123456789012"}, + excludeAccounts: []string{}, + expected: false, + }, + { + name: "In exclude list", + accountID: "123456789012", + includeAccounts: []string{}, + excludeAccounts: []string{"123456789012"}, + expected: false, + }, + { + name: "Not in exclude list", + accountID: "999888777666", + includeAccounts: []string{}, + excludeAccounts: []string{"123456789012"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + toolCfg.IncludeAccounts = tt.includeAccounts + toolCfg.ExcludeAccounts = tt.excludeAccounts + + result := shouldIncludeAccount(tt.accountID, &toolCfg) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestShouldIncludeEngine(t *testing.T) { + // Save original values + origCfg := toolCfg + + defer func() { + toolCfg = origCfg + }() + + tests := []struct { + name string + recommendation common.Recommendation + includeEngines []string + excludeEngines []string + expected bool + }{ + { + name: "ElastiCache Redis - no filters", + recommendation: common.Recommendation{ + Service: common.ServiceElastiCache, + Details: &common.CacheDetails{ + Engine: "redis", + }, + }, + includeEngines: []string{}, + excludeEngines: []string{}, + expected: true, + }, + { + name: "ElastiCache Redis - in include list", + recommendation: common.Recommendation{ + Service: common.ServiceElastiCache, + Details: &common.CacheDetails{ + Engine: "redis", + }, + }, + includeEngines: []string{"redis"}, + excludeEngines: []string{}, + expected: true, + }, + { + name: "ElastiCache Valkey - not in include list", + recommendation: common.Recommendation{ + Service: common.ServiceElastiCache, + Details: &common.CacheDetails{ + Engine: "valkey", + }, + }, + includeEngines: []string{"redis"}, + excludeEngines: []string{}, + expected: false, + }, + { + name: "ElastiCache Redis - in exclude list", + recommendation: common.Recommendation{ + Service: common.ServiceElastiCache, + Details: &common.CacheDetails{ + Engine: "redis", + }, + }, + includeEngines: []string{}, + excludeEngines: []string{"redis"}, + expected: false, + }, + { + name: "RDS with nil Details", + recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Details: nil, + }, + includeEngines: []string{"mysql"}, + excludeEngines: []string{}, + expected: false, // nil Details with include list - exclude unknown engines + }, + { + name: "RDS with nil Details - no filters", + recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Details: nil, + }, + includeEngines: []string{}, + excludeEngines: []string{}, + expected: true, // nil Details with no filters - include by default + }, + { + name: "RDS MySQL - with ServiceDetails", + recommendation: common.Recommendation{ + Service: common.ServiceRDS, + Details: &common.DatabaseDetails{ + Engine: "mysql", + }, + }, + includeEngines: []string{"mysql", "postgresql"}, + excludeEngines: []string{}, + expected: true, + }, + { + name: "Case insensitive matching", + recommendation: common.Recommendation{ + Service: common.ServiceElastiCache, + Details: &common.CacheDetails{ + Engine: "Redis", + }, + }, + includeEngines: []string{"REDIS"}, + excludeEngines: []string{}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + toolCfg.IncludeEngines = tt.includeEngines + toolCfg.ExcludeEngines = tt.excludeEngines + + result := shouldIncludeEngine(&tt.recommendation, &toolCfg) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestShouldIncludePoolSize(t *testing.T) { + tests := []struct { + name string + avg float64 + minPool float64 + expected bool + }{ + {"filter disabled (0)", 0.5, 0, true}, + {"avg=0 passes through", 0, 2.0, true}, + {"avg below threshold", 1.5, 2.0, false}, + {"avg equal to threshold", 2.0, 2.0, true}, + {"avg above threshold", 3.0, 2.0, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rec := common.Recommendation{AverageInstancesUsedPerHour: tt.avg} + cfg := Config{MinPoolSize: tt.minPool} + assert.Equal(t, tt.expected, shouldIncludePoolSize(&rec, &cfg)) + }) + } +} + +func TestShouldIncludeAccountIDOrNameExactMatch(t *testing.T) { + const id = "123456789012" + tests := []struct { + name string + accountName string + include []string + exclude []string + want bool + }{ + {"include dev does not select devops-prod", "devops-prod", []string{"dev"}, nil, false}, + {"exclude dev does not drop devops-prod", "devops-prod", nil, []string{"dev"}, true}, + {"include exact name different case", "DevOps-Prod", []string{"devops-prod"}, nil, true}, + {"exclude exact name different case", "devops-prod", nil, []string{"DEVOPS-PROD"}, false}, + {"include exact ID", "devops-prod", []string{id}, nil, true}, + {"exclude exact ID", "devops-prod", nil, []string{id}, false}, + {"include name with surrounding whitespace", "dev", []string{" dev\t"}, nil, true}, + {"exclude ID with surrounding whitespace", "dev", nil, []string{" " + id + " "}, false}, + {"include substring of ID does not match", "devops-prod", []string{"12345"}, nil, false}, + {"exclude substring of ID does not drop", "devops-prod", nil, []string{"9012"}, true}, + {"blank include never matches", "dev", []string{" "}, nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{IncludeAccounts: tt.include, ExcludeAccounts: tt.exclude} + assert.Equal(t, tt.want, shouldIncludeAccountIDOrName(id, tt.accountName, cfg)) + }) + } +} diff --git a/cmd/multi_service_stats_comparison_test.go b/cmd/multi_service_stats_comparison_test.go new file mode 100644 index 000000000..4c54e6f04 --- /dev/null +++ b/cmd/multi_service_stats_comparison_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" +) + +func TestPrintComparisonSection(t *testing.T) { + tests := []struct { + riStats map[common.ServiceType]ServiceProcessingStats + checkOutput func(t *testing.T, output string) + name string + recommendations []common.Recommendation + riSavings float64 + }{ + { + name: "Comparison with EC2 RIs and EC2 Instance SP", + recommendations: []common.Recommendation{ + { + Service: common.ServiceSavingsPlansAll, + EstimatedSavings: 600.0, + Details: &common.SavingsPlanDetails{ + PlanType: "EC2Instance", + }, + }, + }, + riStats: map[common.ServiceType]ServiceProcessingStats{ + common.ServiceEC2: { + TotalEstimatedSavings: 500.0, + }, + }, + riSavings: 500.0, + checkOutput: func(t *testing.T, output string) { + assert.Contains(t, output, "COMPARISON:") + assert.Contains(t, output, "Option 1 (All RIs)") + assert.Contains(t, output, "500.00") + assert.Contains(t, output, "Option 2") + }, + }, + { + name: "Comparison with Database RIs and Database SP", + recommendations: []common.Recommendation{ + { + Service: common.ServiceSavingsPlansAll, + EstimatedSavings: 800.0, + Details: &common.SavingsPlanDetails{ + PlanType: "Database", + }, + }, + }, + riStats: map[common.ServiceType]ServiceProcessingStats{ + common.ServiceRDS: { + TotalEstimatedSavings: 700.0, + }, + common.ServiceElastiCache: { + TotalEstimatedSavings: 200.0, + }, + }, + riSavings: 900.0, + checkOutput: func(t *testing.T, output string) { + assert.Contains(t, output, "COMPARISON:") + assert.Contains(t, output, "Option 3") + assert.Contains(t, output, "Database SP") + }, + }, + { + name: "Compute SP better than EC2 Instance SP", + recommendations: []common.Recommendation{ + { + Service: common.ServiceSavingsPlansAll, + EstimatedSavings: 500.0, + Details: &common.SavingsPlanDetails{ + PlanType: "EC2Instance", + }, + }, + { + Service: common.ServiceSavingsPlansAll, + EstimatedSavings: 700.0, + Details: &common.SavingsPlanDetails{ + PlanType: "Compute", + }, + }, + }, + riStats: map[common.ServiceType]ServiceProcessingStats{ + common.ServiceEC2: { + TotalEstimatedSavings: 600.0, + }, + }, + riSavings: 600.0, + checkOutput: func(t *testing.T, output string) { + assert.Contains(t, output, "Compute SP") + assert.Contains(t, output, "700.00") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Capture stdout + output := captureAppOutput(t, func() { + printComparisonSection(tt.recommendations, tt.riStats, tt.riSavings) + }) + + tt.checkOutput(t, output) + }) + } +} diff --git a/cmd/multi_service_stats_test.go b/cmd/multi_service_stats_test.go index 004f192da..8172daff8 100644 --- a/cmd/multi_service_stats_test.go +++ b/cmd/multi_service_stats_test.go @@ -417,107 +417,6 @@ func TestPrintSavingsPlansSection(t *testing.T) { } } -func TestPrintComparisonSection(t *testing.T) { - tests := []struct { - riStats map[common.ServiceType]ServiceProcessingStats - checkOutput func(t *testing.T, output string) - name string - recommendations []common.Recommendation - riSavings float64 - }{ - { - name: "Comparison with EC2 RIs and EC2 Instance SP", - recommendations: []common.Recommendation{ - { - Service: common.ServiceSavingsPlansAll, - EstimatedSavings: 600.0, - Details: &common.SavingsPlanDetails{ - PlanType: "EC2Instance", - }, - }, - }, - riStats: map[common.ServiceType]ServiceProcessingStats{ - common.ServiceEC2: { - TotalEstimatedSavings: 500.0, - }, - }, - riSavings: 500.0, - checkOutput: func(t *testing.T, output string) { - assert.Contains(t, output, "COMPARISON:") - assert.Contains(t, output, "Option 1 (All RIs)") - assert.Contains(t, output, "500.00") - assert.Contains(t, output, "Option 2") - }, - }, - { - name: "Comparison with Database RIs and Database SP", - recommendations: []common.Recommendation{ - { - Service: common.ServiceSavingsPlansAll, - EstimatedSavings: 800.0, - Details: &common.SavingsPlanDetails{ - PlanType: "Database", - }, - }, - }, - riStats: map[common.ServiceType]ServiceProcessingStats{ - common.ServiceRDS: { - TotalEstimatedSavings: 700.0, - }, - common.ServiceElastiCache: { - TotalEstimatedSavings: 200.0, - }, - }, - riSavings: 900.0, - checkOutput: func(t *testing.T, output string) { - assert.Contains(t, output, "COMPARISON:") - assert.Contains(t, output, "Option 3") - assert.Contains(t, output, "Database SP") - }, - }, - { - name: "Compute SP better than EC2 Instance SP", - recommendations: []common.Recommendation{ - { - Service: common.ServiceSavingsPlansAll, - EstimatedSavings: 500.0, - Details: &common.SavingsPlanDetails{ - PlanType: "EC2Instance", - }, - }, - { - Service: common.ServiceSavingsPlansAll, - EstimatedSavings: 700.0, - Details: &common.SavingsPlanDetails{ - PlanType: "Compute", - }, - }, - }, - riStats: map[common.ServiceType]ServiceProcessingStats{ - common.ServiceEC2: { - TotalEstimatedSavings: 600.0, - }, - }, - riSavings: 600.0, - checkOutput: func(t *testing.T, output string) { - assert.Contains(t, output, "Compute SP") - assert.Contains(t, output, "700.00") - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Capture stdout - output := captureAppOutput(t, func() { - printComparisonSection(tt.recommendations, tt.riStats, tt.riSavings) - }) - - tt.checkOutput(t, output) - }) - } -} - func TestPrintFinalMessage(t *testing.T) { tests := []struct { name string diff --git a/cmd/multi_service_test.go b/cmd/multi_service_test.go index bdf653803..6ca95c242 100644 --- a/cmd/multi_service_test.go +++ b/cmd/multi_service_test.go @@ -4,14 +4,11 @@ import ( "bytes" "context" "encoding/csv" - "encoding/json" - "fmt" "log" "net/http" "net/http/httptest" "os" "path/filepath" - "strconv" "strings" "sync/atomic" "testing" @@ -22,192 +19,9 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" rdstypes "github.com/aws/aws-sdk-go-v2/service/rds/types" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) -func TestRunToolMultiService_Validation(t *testing.T) { - // Save original values - origCfg := toolCfg - - // Restore after test - defer func() { - toolCfg = origCfg - }() - - tests := []struct { - setupVars func() - name string - expectPanic bool - }{ - { - name: "Valid input - all services", - setupVars: func() { - toolCfg.Coverage = 75.0 - toolCfg.PaymentOption = "partial-upfront" - toolCfg.TermYears = 3 - toolCfg.AllServices = true - toolCfg.Services = nil - }, - expectPanic: false, - }, - { - name: "Valid input - specific services", - setupVars: func() { - toolCfg.Coverage = 50.0 - toolCfg.PaymentOption = "no-upfront" - toolCfg.TermYears = 1 - toolCfg.AllServices = false - toolCfg.Services = []string{"rds", "ec2"} - }, - expectPanic: false, - }, - { - name: "Invalid coverage - too high", - setupVars: func() { - toolCfg.Coverage = 150.0 - toolCfg.PaymentOption = "partial-upfront" - toolCfg.TermYears = 3 - }, - expectPanic: true, - }, - { - name: "Invalid coverage - negative", - setupVars: func() { - toolCfg.Coverage = -10.0 - toolCfg.PaymentOption = "all-upfront" - toolCfg.TermYears = 1 - }, - expectPanic: true, - }, - { - name: "Invalid payment option", - setupVars: func() { - toolCfg.Coverage = 80.0 - toolCfg.PaymentOption = "invalid-payment" - toolCfg.TermYears = 3 - }, - expectPanic: true, - }, - { - name: "Invalid term years", - setupVars: func() { - toolCfg.Coverage = 80.0 - toolCfg.PaymentOption = "partial-upfront" - toolCfg.TermYears = 2 // Only 1 or 3 allowed - }, - expectPanic: true, - }, - { - name: "Default to RDS when no services", - setupVars: func() { - toolCfg.Coverage = 80.0 - toolCfg.PaymentOption = "all-upfront" - toolCfg.TermYears = 3 - toolCfg.AllServices = false - toolCfg.Services = nil - }, - expectPanic: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.setupVars() - - if tt.expectPanic { - // Can't easily test log.Fatalf, so we skip execution - // In production code, would use dependency injection - return - } - - // For non-panic tests, verify the setup is valid - assert.GreaterOrEqual(t, toolCfg.Coverage, 0.0) - assert.LessOrEqual(t, toolCfg.Coverage, 100.0) - assert.Contains(t, []string{"all-upfront", "partial-upfront", "no-upfront"}, toolCfg.PaymentOption) - assert.Contains(t, []int{1, 3}, toolCfg.TermYears) - }) - } -} - -func TestProcessService_EdgeCases(t *testing.T) { - // Save original values - origCfg := toolCfg - - defer func() { - toolCfg = origCfg - }() - - // Set test values - toolCfg.PaymentOption = "partial-upfront" - toolCfg.TermYears = 3 - - tests := []struct { - name string - setupFunc func() - service common.ServiceType - isDryRun bool - expectRecs int - }{ - { - name: "With explicit regions", - setupFunc: func() { - toolCfg.Regions = []string{"us-east-1"} - toolCfg.Coverage = 100.0 - }, - service: common.ServiceRDS, - isDryRun: true, - expectRecs: 0, // Would need mock to return actual recs - }, - { - name: "No regions triggers discovery", - setupFunc: func() { - toolCfg.Regions = []string{} - toolCfg.Coverage = 75.0 - }, - service: common.ServiceEC2, - isDryRun: false, - expectRecs: 0, // Would need mock - }, - { - name: "Zero coverage", - setupFunc: func() { - toolCfg.Regions = []string{"us-west-2"} - toolCfg.Coverage = 0.0 - }, - service: common.ServiceElastiCache, - isDryRun: true, - expectRecs: 0, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tt.setupFunc() - - // Verify test configuration was applied correctly - // Note: Full integration tests would require AWS credentials or mocks - // These tests verify the configuration setup is working as expected - - switch tt.name { - case "With explicit regions": - assert.Equal(t, []string{"us-east-1"}, toolCfg.Regions) - assert.Equal(t, 100.0, toolCfg.Coverage) - case "No regions triggers discovery": - assert.Empty(t, toolCfg.Regions) - assert.Equal(t, 75.0, toolCfg.Coverage) - case "Zero coverage": - assert.Equal(t, []string{"us-west-2"}, toolCfg.Regions) - assert.Equal(t, 0.0, toolCfg.Coverage) - } - - // Verify the test case service is valid - assert.NotEmpty(t, tt.service, "service should not be empty") - assert.GreaterOrEqual(t, tt.expectRecs, 0, "expected recommendations should be non-negative") - }) - } -} - // ==================== Helper Function Tests ==================== func TestApplyCoverageToRecommendations(t *testing.T) { @@ -509,811 +323,10 @@ type ServiceConfig struct { // ==================== Filter Function Tests ==================== -func TestApplyFilters_RegionFiltering(t *testing.T) { - tests := []struct { - name string - currentRegion string - recs []common.Recommendation - includeRegions []string - excludeRegions []string - expectedCount int - }{ - { - name: "No region filters - all pass", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, - {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, - {Region: "eu-west-1", ResourceType: "db.t3.large", Count: 3, Service: common.ServiceRDS}, - }, - includeRegions: []string{}, - excludeRegions: []string{}, - currentRegion: "", - expectedCount: 3, - }, - { - name: "Include specific regions", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, - {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, - {Region: "eu-west-1", ResourceType: "db.t3.large", Count: 3, Service: common.ServiceRDS}, - }, - includeRegions: []string{"us-east-1", "us-west-2"}, - excludeRegions: []string{}, - currentRegion: "", - expectedCount: 2, - }, - { - name: "Exclude specific regions", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, - {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, - {Region: "eu-west-1", ResourceType: "db.t3.large", Count: 3, Service: common.ServiceRDS}, - }, - includeRegions: []string{}, - excludeRegions: []string{"us-west-2"}, - currentRegion: "", - expectedCount: 2, - }, - { - name: "Current region filter with RDS", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, - {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, - }, - includeRegions: []string{}, - excludeRegions: []string{}, - currentRegion: "us-east-1", - expectedCount: 1, - }, - { - name: "Savings Plans bypass region filter", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "ComputeSP", Count: 1, Service: common.ServiceSavingsPlansAll}, - {Region: "us-west-2", ResourceType: "ComputeSP", Count: 2, Service: common.ServiceSavingsPlansAll}, - }, - includeRegions: []string{}, - excludeRegions: []string{}, - currentRegion: "us-east-1", - expectedCount: 2, // Both should pass, SP is account-level - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := Config{ - IncludeRegions: tt.includeRegions, - ExcludeRegions: tt.excludeRegions, - } - - result := applyFilters(tt.recs, &cfg, map[string][]InstanceEngineVersion{}, map[string]MajorEngineVersionInfo{}, tt.currentRegion, nil) - assert.Equal(t, tt.expectedCount, len(result), "Expected %d recommendations, got %d", tt.expectedCount, len(result)) - }) - } -} - -func TestApplyFilters_InstanceTypeFiltering(t *testing.T) { - tests := []struct { - name string - recs []common.Recommendation - includeInstanceTypes []string - excludeInstanceTypes []string - expectedCount int - }{ - { - name: "Include specific instance types", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, - {Region: "us-east-1", ResourceType: "db.r5.large", Count: 3, Service: common.ServiceRDS}, - }, - includeInstanceTypes: []string{"db.t3.small", "db.t3.medium"}, - excludeInstanceTypes: []string{}, - expectedCount: 2, - }, - { - name: "Exclude specific instance types", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, - {Region: "us-east-1", ResourceType: "db.r5.large", Count: 3, Service: common.ServiceRDS}, - }, - includeInstanceTypes: []string{}, - excludeInstanceTypes: []string{"db.r5.large"}, - expectedCount: 2, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := Config{ - IncludeInstanceTypes: tt.includeInstanceTypes, - ExcludeInstanceTypes: tt.excludeInstanceTypes, - } - - result := applyFilters(tt.recs, &cfg, map[string][]InstanceEngineVersion{}, map[string]MajorEngineVersionInfo{}, "", nil) - assert.Equal(t, tt.expectedCount, len(result)) - }) - } -} - -func TestApplyFilters_EngineFiltering(t *testing.T) { - tests := []struct { - name string - recs []common.Recommendation - includeEngines []string - excludeEngines []string - expectedCount int - }{ - { - name: "Include specific engines", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}}, - {Region: "us-east-1", ResourceType: "cache.t3.small", Count: 3, Service: common.ServiceElastiCache, Details: &common.CacheDetails{Engine: "redis"}}, - }, - includeEngines: []string{"postgresql", "mysql"}, - excludeEngines: []string{}, - expectedCount: 2, - }, - { - name: "Exclude specific engines", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}}, - {Region: "us-east-1", ResourceType: "cache.t3.small", Count: 3, Service: common.ServiceElastiCache, Details: &common.CacheDetails{Engine: "redis"}}, - }, - includeEngines: []string{}, - excludeEngines: []string{"redis"}, - expectedCount: 2, - }, - { - name: "Case insensitive engine matching", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "PostgreSQL"}}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "MySQL"}}, - }, - includeEngines: []string{"postgresql", "mysql"}, - excludeEngines: []string{}, - expectedCount: 2, - }, - { - name: "No engine details - with include list", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}}, - }, - includeEngines: []string{"mysql"}, - excludeEngines: []string{}, - expectedCount: 1, // Only the one with engine details - }, - { - name: "No engine details - no include list", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS}, - }, - includeEngines: []string{}, - excludeEngines: []string{}, - expectedCount: 2, // All pass - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := Config{ - IncludeEngines: tt.includeEngines, - ExcludeEngines: tt.excludeEngines, - } - - result := applyFilters(tt.recs, &cfg, map[string][]InstanceEngineVersion{}, map[string]MajorEngineVersionInfo{}, "", nil) - assert.Equal(t, tt.expectedCount, len(result)) - }) - } -} - -func TestApplyFilters_AccountFiltering(t *testing.T) { - tests := []struct { - name string - recs []common.Recommendation - includeAccounts []string - excludeAccounts []string - expectedCount int - }{ - { - name: "Include specific accounts", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, AccountName: "prod-account"}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, AccountName: "dev-account"}, - {Region: "us-east-1", ResourceType: "db.t3.large", Count: 3, Service: common.ServiceRDS, AccountName: "staging-account"}, - }, - includeAccounts: []string{"prod-account", "DEV-account"}, - excludeAccounts: []string{}, - expectedCount: 2, // Exact, case-insensitive match - }, - { - name: "Exclude specific accounts", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, AccountName: "prod-account"}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, AccountName: "dev-account"}, - }, - includeAccounts: []string{}, - excludeAccounts: []string{"dev-account"}, - expectedCount: 1, - }, - { - name: "Empty account name with filters", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, AccountName: ""}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, AccountName: "prod"}, - }, - includeAccounts: []string{"prod"}, - excludeAccounts: []string{}, - expectedCount: 1, // Empty account name is filtered out - }, - { - name: "Empty account name without filters", - recs: []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, AccountName: ""}, - {Region: "us-east-1", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, AccountName: "prod"}, - }, - includeAccounts: []string{}, - excludeAccounts: []string{}, - expectedCount: 2, // All pass - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cfg := Config{ - IncludeAccounts: tt.includeAccounts, - ExcludeAccounts: tt.excludeAccounts, - } - - result := applyFilters(tt.recs, &cfg, map[string][]InstanceEngineVersion{}, map[string]MajorEngineVersionInfo{}, "", nil) - assert.Equal(t, tt.expectedCount, len(result)) - }) - } -} - -func TestApplyFilters_CombinedFilters(t *testing.T) { - recs := []common.Recommendation{ - {Region: "us-east-1", ResourceType: "db.t3.small", Count: 1, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}, AccountName: "prod-account"}, - {Region: "us-west-2", ResourceType: "db.t3.medium", Count: 2, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "mysql"}, AccountName: "dev-account"}, - {Region: "us-east-1", ResourceType: "db.r5.large", Count: 3, Service: common.ServiceRDS, Details: &common.DatabaseDetails{Engine: "postgresql"}, AccountName: "staging-account"}, - {Region: "eu-west-1", ResourceType: "cache.t3.small", Count: 4, Service: common.ServiceElastiCache, Details: &common.CacheDetails{Engine: "redis"}, AccountName: "prod-account"}, - } - - cfg := Config{ - IncludeRegions: []string{"us-east-1", "us-west-2"}, - IncludeEngines: []string{"postgresql", "mysql"}, - ExcludeInstanceTypes: []string{"db.r5.large"}, - IncludeAccounts: []string{"prod-account", "dev-account"}, - } - - result := applyFilters(recs, &cfg, map[string][]InstanceEngineVersion{}, map[string]MajorEngineVersionInfo{}, "", nil) - - // Only the first two should pass all filters - assert.Equal(t, 2, len(result)) - if len(result) >= 2 { - assert.Equal(t, "db.t3.small", result[0].ResourceType) - assert.Equal(t, "db.t3.medium", result[1].ResourceType) - } -} - // ==================== CSV Report Tests ==================== -func TestWriteMultiServiceCSVReport_EmptyResults(t *testing.T) { - tmpFile := "/tmp/test_empty_results.csv" - defer os.Remove(tmpFile) - - err := writeMultiServiceCSVReport([]common.PurchaseResult{}, tmpFile) - assert.NoError(t, err) - - // File should not be created for empty results - _, err = os.Stat(tmpFile) - assert.True(t, os.IsNotExist(err)) -} - -func TestWriteMultiServiceCSVReport_Success(t *testing.T) { - tmpFile := "/tmp/test_csv_success.csv" - defer os.Remove(tmpFile) - - results := []common.PurchaseResult{ - { - Recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-east-1", - ResourceType: "db.t3.small", - Count: 2, - Account: "123456789012", - AccountName: "test-account", - Term: "1yr", - PaymentOption: "all-upfront", - EstimatedSavings: 100.50, - }, - Success: true, - CommitmentID: "test-commitment-id", - Timestamp: time.Now(), - }, - } - - err := writeMultiServiceCSVReport(results, tmpFile) - assert.NoError(t, err) - - // Verify file was created and has content - content, err := os.ReadFile(tmpFile) - assert.NoError(t, err) - assert.Contains(t, string(content), "Service,Region,ResourceType") - assert.Contains(t, string(content), "rds") - assert.Contains(t, string(content), "us-east-1") - assert.Contains(t, string(content), "db.t3.small") - assert.Contains(t, string(content), "test-commitment-id") -} - -func TestWriteMultiServiceCSVReport_WithError(t *testing.T) { - tmpFile := "/tmp/test_csv_with_error.csv" - defer os.Remove(tmpFile) - - results := []common.PurchaseResult{ - { - Recommendation: common.Recommendation{ - Service: common.ServiceEC2, - Region: "us-west-2", - ResourceType: "t3.medium", - Count: 1, - }, - Success: false, - CommitmentID: "", - Error: fmt.Errorf("purchase failed: insufficient quota"), - Timestamp: time.Now(), - }, - } - - err := writeMultiServiceCSVReport(results, tmpFile) - assert.NoError(t, err) - - // Verify error is included in CSV - content, err := os.ReadFile(tmpFile) - assert.NoError(t, err) - assert.Contains(t, string(content), "false") - assert.Contains(t, string(content), "purchase failed: insufficient quota") -} - -func TestWriteMultiServiceCSVReport_InvalidPath(t *testing.T) { - // Use an invalid path (directory that doesn't exist) - invalidPath := "/nonexistent/directory/test.csv" - - results := []common.PurchaseResult{ - { - Recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-east-1", - ResourceType: "db.t3.small", - Count: 1, - }, - Success: true, - Timestamp: time.Now(), - }, - } - - err := writeMultiServiceCSVReport(results, invalidPath) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to create CSV file") -} - -func TestWriteMultiServiceCSVReport_MultipleResults(t *testing.T) { - tmpFile := "/tmp/test_csv_multiple.csv" - defer os.Remove(tmpFile) - - results := []common.PurchaseResult{ - { - Recommendation: common.Recommendation{ - Service: common.ServiceRDS, - Region: "us-east-1", - ResourceType: "db.t3.small", - Count: 2, - EstimatedSavings: 150.25, - }, - Success: true, - CommitmentID: "commitment-1", - Timestamp: time.Now(), - }, - { - Recommendation: common.Recommendation{ - Service: common.ServiceElastiCache, - Region: "us-west-2", - ResourceType: "cache.t3.micro", - Count: 3, - EstimatedSavings: 75.50, - }, - Success: true, - CommitmentID: "commitment-2", - Timestamp: time.Now(), - }, - } - - err := writeMultiServiceCSVReport(results, tmpFile) - assert.NoError(t, err) - - // Verify both results are in CSV - content, err := os.ReadFile(tmpFile) - assert.NoError(t, err) - assert.Contains(t, string(content), "commitment-1") - assert.Contains(t, string(content), "commitment-2") - assert.Contains(t, string(content), "150.25") - assert.Contains(t, string(content), "75.50") -} - // ==================== Additional ProcessPurchaseLoop Tests ==================== -func TestProcessPurchaseLoopPurchaseFailure(t *testing.T) { - useCallerAccount(t) - ctx := context.Background() - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.Coverage = 80.0 - - recs := []common.Recommendation{ - {Service: common.ServiceRDS, ResourceType: "db.t3.large", Count: 1, EstimatedSavings: 500}, - } - - mockClient := &MockServiceClient{} - // Simulate a purchase failure - failureResult := common.PurchaseResult{ - Recommendation: recs[0], - Success: false, - CommitmentID: "", - Error: fmt.Errorf("API error: quota exceeded"), - Timestamp: time.Now(), - } - mockClient.On("PurchaseCommitment", mock.Anything, recs[0], mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(failureResult, nil) - - t.Setenv("DISABLE_PURCHASE_DELAY", "true") - - results, _ := processPurchaseLoop(ctx, recs, "ap-south-1", false, mockClient, toolCfg, "test-run") - - assert.Len(t, results, 1) - assert.False(t, results[0].Success) - assert.NotNil(t, results[0].Error) - assert.Contains(t, results[0].Error.Error(), "quota exceeded") - - mockClient.AssertExpectations(t) -} - -func TestProcessPurchaseLoopEmptyRecommendations(t *testing.T) { - ctx := context.Background() - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.Coverage = 100.0 - - mockClient := &MockServiceClient{} - - results, _ := processPurchaseLoop(ctx, []common.Recommendation{}, "us-east-1", false, mockClient, toolCfg, "test-run") - - assert.Empty(t, results) - mockClient.AssertNotCalled(t, "PurchaseCommitment", mock.Anything, mock.Anything, mock.Anything) -} - -func TestProcessServicePurchasesUserCancellation(t *testing.T) { - useCallerAccount(t) - ctx := context.Background() - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.Coverage = 85.0 - - recs := []common.Recommendation{ - {Service: common.ServiceElastiCache, ResourceType: "cache.r6g.large", Count: 2, EstimatedSavings: 200}, - } - - mockClient := &MockServiceClient{} - result := common.PurchaseResult{ - Recommendation: recs[0], - Success: true, - CommitmentID: "cache-purchase-123", - Timestamp: time.Now(), - } - mockClient.On("PurchaseCommitment", mock.Anything, recs[0], mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) - - t.Setenv("DISABLE_PURCHASE_DELAY", "true") - - results, _ := processPurchaseLoop(ctx, recs, "us-west-1", false, mockClient, toolCfg, "test-run") - - assert.Len(t, results, 1) - assert.True(t, results[0].Success) - assert.Equal(t, "cache-purchase-123", results[0].CommitmentID) - - mockClient.AssertExpectations(t) -} - -func TestProcessServicePurchasesDryRunMultiple(t *testing.T) { - useCallerAccount(t) - ctx := context.Background() - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.Coverage = 100.0 - - recs := []common.Recommendation{ - {Service: common.ServiceRDS, ResourceType: "db.r5.xlarge", Count: 5, EstimatedSavings: 1000}, - {Service: common.ServiceRDS, ResourceType: "db.r5.2xlarge", Count: 3, EstimatedSavings: 800}, - {Service: common.ServiceRDS, ResourceType: "db.r5.4xlarge", Count: 1, EstimatedSavings: 500}, - } - - mockClient := &MockServiceClient{} - // Dry run should not call PurchaseCommitment - - results, _ := processPurchaseLoop(ctx, recs, "ap-northeast-1", true, mockClient, toolCfg, "test-run") - - assert.Len(t, results, 3) - for i, result := range results { - assert.True(t, result.Success) - assert.True(t, result.DryRun) - assert.Contains(t, result.CommitmentID, "dryrun") - assert.Nil(t, result.Error) - assert.Equal(t, recs[i].ResourceType, result.Recommendation.ResourceType) - } - - mockClient.AssertNotCalled(t, "PurchaseCommitment", mock.Anything, mock.Anything, mock.Anything) -} - -// ==================== New Extracted Function Tests ==================== - -func TestExecutePurchaseWithEmptyPurchaseID(t *testing.T) { - ctx := context.Background() - // Save original values - origCfg := toolCfg - - defer func() { - toolCfg = origCfg - }() - - toolCfg.Coverage = 85.0 - - rec := common.Recommendation{ - Service: common.ServiceElastiCache, - ResourceType: "cache.r5.large", - Count: 3, - } - - mockClient := &MockServiceClient{} - // Return result without PurchaseID - expectedResult := common.PurchaseResult{ - Recommendation: rec, - Success: true, - CommitmentID: "", // Empty ID - should be generated - Error: nil, - Timestamp: time.Now(), - } - mockClient.On("PurchaseCommitment", mock.Anything, rec, mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(expectedResult, nil) - - // Logger output disabled for testing - - result := executePurchase(ctx, rec, "ap-southeast-1", 2, mockClient, toolCfg) - - assert.True(t, result.Success) - assert.NotEmpty(t, result.CommitmentID) // Should have generated ID - assert.Contains(t, result.CommitmentID, "ap-southeast-1") - - mockClient.AssertExpectations(t) -} - -func TestProcessPurchaseLoopDryRun(t *testing.T) { - useCallerAccount(t) - ctx := context.Background() - // Save original values - origCfg := toolCfg - - defer func() { - toolCfg = origCfg - }() - - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.Coverage = 75.0 - - recs := []common.Recommendation{ - {Service: common.ServiceRDS, ResourceType: "db.t3.small", Count: 2, SourceRecommendation: "Test 1"}, - {Service: common.ServiceRDS, ResourceType: "db.t3.medium", Count: 3, SourceRecommendation: "Test 2"}, - } - - mockClient := &MockServiceClient{} - - // Logger output disabled for testing - - results, _ := processPurchaseLoop(ctx, recs, "us-east-1", true, mockClient, toolCfg, "test-run") - - assert.Len(t, results, 2) - for _, result := range results { - assert.True(t, result.Success) - assert.Nil(t, result.Error) // Dry runs are successful, so no error - assert.True(t, result.DryRun) - assert.Contains(t, result.CommitmentID, "dryrun") - } - - // Mock should not be called in dry run mode - mockClient.AssertNotCalled(t, "PurchaseCommitment", mock.Anything, mock.Anything, mock.Anything) -} - -func TestProcessPurchaseLoopActualPurchase(t *testing.T) { - useCallerAccount(t) - ctx := context.Background() - // Save original values - origCfg := toolCfg - - defer func() { - toolCfg = origCfg - }() - - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.Coverage = 80.0 - - recs := []common.Recommendation{ - {Service: common.ServiceEC2, ResourceType: "t3.small", Count: 1, SourceRecommendation: "EC2 Test 1", EstimatedSavings: 100, Details: &common.ComputeDetails{InstanceType: "t3.small", Platform: "Linux/UNIX", Tenancy: "default", Scope: "regional"}}, - {Service: common.ServiceEC2, ResourceType: "t3.medium", Count: 2, SourceRecommendation: "EC2 Test 2", EstimatedSavings: 200, Details: &common.ComputeDetails{InstanceType: "t3.medium", Platform: "Linux/UNIX", Tenancy: "default", Scope: "regional"}}, - } - - mockClient := &MockServiceClient{} - for i, rec := range recs { - result := common.PurchaseResult{ - Recommendation: rec, - Success: true, - CommitmentID: fmt.Sprintf("purchase-id-%d", i), - Error: nil, - Timestamp: time.Now(), - } - mockClient.On("PurchaseCommitment", mock.Anything, rec, mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) - } - - // Logger output disabled for testing - - // Disable purchase delay for testing - t.Setenv("DISABLE_PURCHASE_DELAY", "true") - - results, _ := processPurchaseLoop(ctx, recs, "eu-west-1", false, mockClient, toolCfg, "test-run") - - assert.Len(t, results, 2) - for i, result := range results { - assert.True(t, result.Success) - assert.Equal(t, fmt.Sprintf("purchase-id-%d", i), result.CommitmentID) - } - - mockClient.AssertExpectations(t) -} - -func TestProcessPurchaseLoopWithConfirmation(t *testing.T) { - useCallerAccount(t) - ctx := context.Background() - // Save original values - origCfg := toolCfg - - defer func() { - toolCfg = origCfg - }() - - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.Coverage = 80.0 - - recs := []common.Recommendation{ - {Service: common.ServiceRDS, ResourceType: "db.r5.large", Count: 5, SourceRecommendation: "Expensive", EstimatedSavings: 1000}, - } - - mockClient := &MockServiceClient{} - // processPurchaseLoop no longer confirms internally (confirmPurchaseRun - // runs once in runToolFromCSV before this loop is reached), so it always - // proceeds straight to the purchase call. - result := common.PurchaseResult{ - Recommendation: recs[0], - Success: true, - CommitmentID: "confirmed-purchase-123", - Error: nil, - Timestamp: time.Now(), - } - mockClient.On("PurchaseCommitment", mock.Anything, recs[0], mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) - - // Logger output disabled for testing - - // Disable purchase delay for testing - t.Setenv("DISABLE_PURCHASE_DELAY", "true") - - results, _ := processPurchaseLoop(ctx, recs, "us-west-2", false, mockClient, toolCfg, "test-run") - - assert.Len(t, results, 1) - assert.True(t, results[0].Success) - assert.Equal(t, "confirmed-purchase-123", results[0].CommitmentID) - - mockClient.AssertExpectations(t) -} - -func TestFilterAndAdjustRecommendations(t *testing.T) { - // Save and restore ALL global variables - saved := saveGlobalVars() - defer saved.restore() - - tests := []struct { - setupFilters func() - name string - recommendations []common.Recommendation - coverage float64 - expectedMin int - expectedMax int - }{ - { - name: "100% coverage no filters", - recommendations: []common.Recommendation{ - {Service: common.ServiceRDS, ResourceType: "db.t3.small", Count: 5}, - {Service: common.ServiceRDS, ResourceType: "db.t3.medium", Count: 3}, - }, - coverage: 100.0, - setupFilters: func() { - toolCfg.MaxInstances = 0 - toolCfg.OverrideCount = 0 - }, - expectedMin: 2, - expectedMax: 2, - }, - { - name: "50% coverage", - recommendations: []common.Recommendation{ - {Service: common.ServiceRDS, ResourceType: "db.t3.small", Count: 10}, - {Service: common.ServiceRDS, ResourceType: "db.t3.medium", Count: 6}, - }, - coverage: 50.0, - setupFilters: func() { - toolCfg.MaxInstances = 0 - toolCfg.OverrideCount = 0 - }, - expectedMin: 1, - expectedMax: 2, - }, - { - name: "Instance limit applied", - // EstimatedSavings is set because a binding --max-instances has to - // rank the rows it chooses between; requireRankingSignal refuses a - // run whose rows carry no savings value rather than capping them by - // name. - recommendations: []common.Recommendation{ - {Service: common.ServiceRDS, ResourceType: "db.t3.small", Count: 10, EstimatedSavings: 100}, - {Service: common.ServiceRDS, ResourceType: "db.t3.medium", Count: 10, EstimatedSavings: 200}, - {Service: common.ServiceRDS, ResourceType: "db.t3.large", Count: 10, EstimatedSavings: 300}, - }, - coverage: 100.0, - setupFilters: func() { - toolCfg.MaxInstances = 15 - toolCfg.OverrideCount = 0 - }, - expectedMin: 1, - expectedMax: 3, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup filters - tt.setupFilters() - - // Suppress logger - // Logger output disabled for testing - - result, err := filterAndAdjustRecommendations(context.Background(), tt.recommendations, tt.coverage, toolCfg) - require.NoError(t, err) - - // Verify result is within expected range - assert.GreaterOrEqual(t, len(result), tt.expectedMin) - assert.LessOrEqual(t, len(result), tt.expectedMax) - - // Verify all results have count > 0 - for _, rec := range result { - assert.Positive(t, rec.Count) - } - }) - } -} - // clearAWSEndpointOverrides blanks every service-specific AWS_ENDPOINT_URL_* // variable and AWS_IGNORE_CONFIGURED_ENDPOINT_URLS for the test, so a // developer's global overrides can neither bypass nor disable a test's local @@ -1407,412 +420,6 @@ func writeTestRecommendationsCSV(t *testing.T, csvData string) string { return path } -func TestRunToolFromCSV(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - awsHits := isolateAWSEnv(t) - - // Column names match writeMultiServiceCSVReport's header (the real - // round-trip shape). The previous fixture used headers the parser - // silently ignored, so every row loaded with Count=0 and the whole run - // processed nothing while the NotPanics assertion stayed green. - csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,2,1yr,all-upfront,111111111111 -elasticache,us-west-2,cache.t3.micro,redis,1,1yr,all-upfront,111111111111 -`) - - tests := []struct { - name string - coverage float64 - wantResourceTypes []string - wantCounts []string - }{ - { - // 100% coverage keeps both recommendations at their CSV counts. - name: "Dry run writes a report row per recommendation", - coverage: 100.0, - wantResourceTypes: []string{"db.t3.small", "cache.t3.micro"}, - wantCounts: []string{"2", "1"}, - }, - { - // 50% coverage halves db.t3.small (2 -> 1) and drops - // cache.t3.micro (1 -> 0). - name: "Coverage sizing is applied before the purchase loop", - coverage: 50.0, - wantResourceTypes: []string{"db.t3.small"}, - wantCounts: []string{"1"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - reportPath := filepath.Join(t.TempDir(), "report.csv") - toolCfg.CSVInput = csvPath - toolCfg.CSVOutput = reportPath - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.ActualPurchase = false - toolCfg.Coverage = tt.coverage - toolCfg.TargetCoverage = 0 - toolCfg.MaxInstances = 0 - toolCfg.OverrideCount = 0 - - err := runToolFromCSV(context.Background(), toolCfg) - require.NoError(t, err) - - header, rows := readPurchaseReport(t, reportPath) - assert.ElementsMatch(t, tt.wantResourceTypes, reportColumn(t, header, rows, "ResourceType")) - assert.ElementsMatch(t, tt.wantCounts, reportColumn(t, header, rows, "Count")) - for _, success := range reportColumn(t, header, rows, "Success") { - assert.Equal(t, "true", success, "dry-run purchases must be recorded as successful") - } - }) - } - // Without a request the env wiring in isolateAWSEnv would be vacuous. - assert.Positive(t, awsHits.Load(), "isolateAWSEnv's local endpoint must have served the duplicate checks") -} - -// TestRunToolFromCSV_DuplicateCheckFailureRefusesToPurchase reproduces #1941: -// on a real purchase run (--input-csv ... --purchase), a failed duplicate -// check (throttling, a describe-call AccessDenied, a transient 5xx) must not -// fall back to purchasing the un-deduplicated counts. isolateAWSEnv points -// the SDK at invalid credentials so GetExistingCommitments fails the same way -// a real AWS error would, and the region must be skipped entirely rather than -// purchased with the original (potentially duplicate) counts. Before the fix, -// adjustRecsForDuplicates' error was logged as a warning and adjustedRecs was -// reset to the original recs, so the region proceeded into the purchase loop -// unchanged. -func TestRunToolFromCSV_DuplicateCheckFailureRefusesToPurchase(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - isolateAWSEnv(t) - - csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,2,1yr,all-upfront,111111111111 -`) - reportPath := filepath.Join(t.TempDir(), "report.csv") - auditPath := filepath.Join(t.TempDir(), "audit.jsonl") - - toolCfg.CSVInput = csvPath - toolCfg.CSVOutput = reportPath - toolCfg.AuditLog = auditPath - toolCfg.ActualPurchase = true // the money path: a duplicate-check failure here must fail closed - toolCfg.Coverage = 100.0 - toolCfg.TargetCoverage = 0 - toolCfg.MaxInstances = 0 - toolCfg.OverrideCount = 0 - - err := runToolFromCSV(context.Background(), toolCfg) - require.NoError(t, err) - - // The region is skipped before executePurchase or processPurchaseLoop - // ever run, so no PurchaseResult is produced and no report is written - // (writeMultiServiceCSVReport no-ops on an empty results slice). Before - // the fix, the un-deduplicated recommendation reached the purchase loop - // and a report row (attempting a real AWS purchase call) was produced. - _, statErr := os.Stat(reportPath) - assert.True(t, os.IsNotExist(statErr), "a failed duplicate check on a purchase run must skip the region entirely, not attempt to purchase the un-deduplicated counts") -} - -// TestRunToolFromCSV_NonExistentFile asserts the unreadable-input error path -// surfaces as an error (previously log.Fatalf, which made it untestable). -func TestRunToolFromCSV_NonExistentFile(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.CSVInput = filepath.Join(t.TempDir(), "does-not-exist.csv") - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.ActualPurchase = false - - err := runToolFromCSV(context.Background(), toolCfg) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to read CSV file") - assert.Contains(t, err.Error(), "failed to open CSV file") -} - -// TestRunToolFromCSV_EmptyFile asserts a CSV without a header row is rejected -// with a parse error rather than being silently treated as zero rows. -func TestRunToolFromCSV_EmptyFile(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - toolCfg.CSVInput = writeTestRecommendationsCSV(t, "") - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.ActualPurchase = false - - err := runToolFromCSV(context.Background(), toolCfg) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to read CSV file") - assert.Contains(t, err.Error(), "failed to read CSV header") -} - -func TestRunToolFromCSV_WithMaxInstances(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - isolateAWSEnv(t) - - // EstimatedSavings is populated because a binding --max-instances has to - // rank the rows it chooses between. A file without that column is refused - // rather than capped by name; that case is covered by - // TestCSVCapRefusesRowsWithoutARankingSignal. - csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,EstimatedSavings,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,10,100.00,1yr,all-upfront,123456789012 -rds,us-east-1,db.t3.medium,mysql,10,200.00,1yr,all-upfront,123456789012 -rds,us-east-1,db.t3.large,postgres,10,300.00,1yr,all-upfront,123456789012 -`) - - reportPath := filepath.Join(t.TempDir(), "report.csv") - toolCfg.CSVInput = csvPath - toolCfg.CSVOutput = reportPath - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.ActualPurchase = false - toolCfg.Coverage = 100.0 - toolCfg.TargetCoverage = 0 - toolCfg.OverrideCount = 0 - toolCfg.MaxInstances = 15 // Should limit total instances to 15 - - err := runToolFromCSV(context.Background(), toolCfg) - require.NoError(t, err) - - header, rows := readPurchaseReport(t, reportPath) - total := 0 - for _, raw := range reportColumn(t, header, rows, "Count") { - n, convErr := strconv.Atoi(raw) - require.NoError(t, convErr) - total += n - } - assert.Positive(t, total, "some instances must survive the max-instances cap") - assert.LessOrEqual(t, total, 15, "purchased instance total must respect --max-instances") -} - -func TestRunToolFromCSV_WithOverrideCount(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - isolateAWSEnv(t) - - csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,10,1yr,all-upfront,123456789012 -rds,us-east-1,db.t3.medium,mysql,5,1yr,all-upfront,123456789012 -`) - - reportPath := filepath.Join(t.TempDir(), "report.csv") - toolCfg.CSVInput = csvPath - toolCfg.CSVOutput = reportPath - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - toolCfg.ActualPurchase = false - toolCfg.Coverage = 100.0 - toolCfg.TargetCoverage = 0 - toolCfg.MaxInstances = 0 - toolCfg.OverrideCount = 3 // Override each recommendation to count=3 - - err := runToolFromCSV(context.Background(), toolCfg) - require.NoError(t, err) - - header, rows := readPurchaseReport(t, reportPath) - counts := reportColumn(t, header, rows, "Count") - require.Len(t, counts, 2, "both recommendations must reach the purchase loop") - for _, raw := range counts { - assert.Equal(t, "3", raw, "--override-count must replace every recommendation count") - } -} - -// TestRunToolFromCSV_WritesAuditRecordsOnDryRun reproduces #1609 through the -// public --input-csv entry point: before the fix, processPurchaseLoop never -// wrote an audit record at all, on a dry run or a real purchase. A dry run is -// used here (rather than ActualPurchase=true with invalid credentials) -// because #1941 made the duplicate check fail closed on a real purchase run: -// with no valid AWS credentials the check now refuses the region before ever -// reaching a purchase attempt, and writes no audit record at all by design -// (see TestCheckDuplicates_ErrorOnPurchaseRun_DropsRecsRatherThanFallingBack) -// -- so there is no way to drive an un-mocked real-purchase attempt through -// this entry point in a test. A dry run still exercises the exact wiring -// #1609 fixes (prepareCSVPurchaseRun's runID -> processPurchaseLoop -> -// writePurchaseAuditRecord), since the duplicate check only warns and -// continues on a dry run rather than refusing. -// TestProcessPurchaseLoop_WritesAuditRecordForRealPurchase below covers the -// "success"/"error" statuses a real purchase attempt gets audited with. -func TestRunToolFromCSV_WritesAuditRecordsOnDryRun(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - isolateAWSEnv(t) - - csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,2,1yr,all-upfront,111111111111 -`) - auditPath := filepath.Join(t.TempDir(), "audit.jsonl") - - toolCfg.CSVInput = csvPath - toolCfg.CSVOutput = filepath.Join(t.TempDir(), "report.csv") - toolCfg.AuditLog = auditPath - toolCfg.ActualPurchase = false - toolCfg.Coverage = 100.0 - toolCfg.TargetCoverage = 0 - toolCfg.MaxInstances = 0 - toolCfg.OverrideCount = 0 - - err := runToolFromCSV(context.Background(), toolCfg) - require.NoError(t, err) - - data, readErr := os.ReadFile(auditPath) // #nosec G304 -- test-owned tempdir path - require.NoError(t, readErr, "a purchase run through --input-csv must write an audit log") - require.NotEmpty(t, data, "the audit log must not be empty -- an empty file would make the next length check pass vacuously") - - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - require.Len(t, lines, 1, "one audit record per recommendation") - - var rec map[string]any - require.NoError(t, json.Unmarshal([]byte(lines[0]), &rec)) - assert.NotEmpty(t, rec["run_id"], "the audit record must carry a run ID grouping the CSV run") - assert.Equal(t, common.PurchaseSourceCLI, rec["source"]) - assert.Equal(t, "db.t3.small", rec["resource_type"]) - assert.Equal(t, true, rec["dry_run"]) - assert.Equal(t, "skipped", rec["status"]) -} - -// TestProcessPurchaseLoop_WritesAuditRecordForRealPurchase reproduces the -// other half of #1609 at the processPurchaseLoop level: a real (non-dry-run) -// purchase attempt must be audited as "success" or "error", never silently -// dropped. createServiceClient is not injectable (see the comment on -// TestApplyMinCountFloorAfterDuplicateAdjustment), so this exercises -// processPurchaseLoop directly with a mocked provider.ServiceClient rather -// than through runToolFromCSV -- the same technique -// TestProcessPurchaseLoopActualPurchase already uses to test this loop's -// purchase behavior without live AWS credentials. -func TestProcessPurchaseLoop_WritesAuditRecordForRealPurchase(t *testing.T) { - useCallerAccount(t) - tests := []struct { - result common.PurchaseResult - name string - wantStatus string - }{ - { - name: "success", - result: common.PurchaseResult{Success: true, CommitmentID: "test-purchase-id", Timestamp: time.Now()}, - wantStatus: "success", - }, - { - name: "error", - result: common.PurchaseResult{Success: false, Error: fmt.Errorf("API error: quota exceeded"), Timestamp: time.Now()}, - wantStatus: "error", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := context.Background() - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - toolCfg.AuditLog = filepath.Join(t.TempDir(), "audit.jsonl") - t.Setenv("DISABLE_PURCHASE_DELAY", "true") - - recs := []common.Recommendation{ - {Service: common.ServiceRDS, ResourceType: "db.t3.small", Count: 2, EstimatedSavings: 100}, - } - result := tt.result - result.Recommendation = recs[0] - - mockClient := &MockServiceClient{} - mockClient.On("PurchaseCommitment", mock.Anything, recs[0], mock.MatchedBy(func(o common.PurchaseOptions) bool { return o.Source == common.PurchaseSourceCLI })).Return(result, nil) - - processPurchaseLoop(ctx, recs, "us-east-1", false /* isDryRun */, mockClient, toolCfg, "test-run-id") - - data, readErr := os.ReadFile(toolCfg.AuditLog) // #nosec G304 -- test-owned tempdir path - require.NoError(t, readErr, "a real purchase attempt must write an audit log") - require.NotEmpty(t, data, "the audit log must not be empty -- an empty file would make the next length check pass vacuously") - - lines := strings.Split(strings.TrimSpace(string(data)), "\n") - require.Len(t, lines, 1, "one audit record per recommendation") - - var rec map[string]any - require.NoError(t, json.Unmarshal([]byte(lines[0]), &rec)) - assert.Equal(t, "test-run-id", rec["run_id"]) - assert.Equal(t, common.PurchaseSourceCLI, rec["source"]) - assert.Equal(t, false, rec["dry_run"]) - assert.Equal(t, tt.wantStatus, rec["status"]) - - mockClient.AssertExpectations(t) - }) - } -} - -// TestRunToolFromCSV_ChecksAuditLogWritability reproduces the second half of -// #1609: the --input-csv path never verified the audit log was writable -// before touching AWS, unlike the non-CSV path (CheckAuditLogWritable in -// runToolMultiService). An unwritable audit-log directory must be rejected -// before the CSV is even read, on both dry-run and purchase invocations. -func TestRunToolFromCSV_ChecksAuditLogWritability(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - - csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,2,1yr,all-upfront,111111111111 -`) - - toolCfg.CSVInput = csvPath - toolCfg.AuditLog = filepath.Join(t.TempDir(), "does-not-exist-dir", "audit.jsonl") - toolCfg.ActualPurchase = false - - err := runToolFromCSV(context.Background(), toolCfg) - require.Error(t, err) - assert.Contains(t, err.Error(), "audit log") -} - -// TestRunToolFromCSV_ConfirmsOnceBeforeAnyRegion reproduces #1610 through the -// public --input-csv entry point. Before the fix, confirmation lived inside -// processPurchaseLoop and was asked once per (service, region), after that -// region's AWS config, service client and duplicate check; declining only -// canceled that region. Now prepareCSVPurchaseRun asks exactly once, against -// the whole post-filter set (both regions here: 5 instances), before any AWS -// call. stdin is not a terminal in tests, so ConfirmPurchase declines without -// blocking, which is the "operator declines" and non-interactive fail-closed -// path at once. -func TestRunToolFromCSV_ConfirmsOnceBeforeAnyRegion(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - isolateAWSEnv(t) - - var appOut, stdOut bytes.Buffer - oldLogger := AppLogger - AppLogger = log.New(&appOut, "", 0) - t.Cleanup(func() { AppLogger = oldLogger }) - oldWriter := log.Writer() - log.SetOutput(&stdOut) - t.Cleanup(func() { log.SetOutput(oldWriter) }) - - csvPath := writeTestRecommendationsCSV(t, `Service,Region,ResourceType,Engine,Count,Term,PaymentOption,Account -rds,us-east-1,db.t3.small,postgres,2,1yr,all-upfront,111111111111 -rds,us-west-2,db.t3.medium,postgres,3,1yr,all-upfront,123456789012 -`) - reportPath := filepath.Join(t.TempDir(), "report.csv") - auditPath := filepath.Join(t.TempDir(), "audit.jsonl") - - toolCfg.CSVInput = csvPath - toolCfg.CSVOutput = reportPath - toolCfg.AuditLog = auditPath - toolCfg.ActualPurchase = true - toolCfg.Coverage = 100.0 - toolCfg.TargetCoverage = 0 - toolCfg.MaxInstances = 0 - toolCfg.OverrideCount = 0 - - err := runToolFromCSV(context.Background(), toolCfg) - require.NoError(t, err) - - assert.Equal(t, 1, strings.Count(stdOut.String(), "stdin is not a terminal"), - "the purchase must be confirmed exactly once for the whole run, not per region") - assert.Contains(t, appOut.String(), "Purchase canceled.") - assert.NotContains(t, appOut.String(), "Region:", - "a declined confirmation must abort before any region is processed") - - _, statErr := os.Stat(reportPath) - assert.True(t, os.IsNotExist(statErr), "a declined run must write no purchase report") - data, readErr := os.ReadFile(auditPath) // #nosec G304 -- test-owned tempdir path - if readErr == nil { - assert.Empty(t, data, "a declined run attempts no purchase, so it writes no audit record") - } else { - assert.True(t, os.IsNotExist(readErr), "unexpected audit log read error: %v", readErr) - } -} - // ==================== Tests for adjustRecommendationForExcludedVersions ==================== // Helper to create test version info with extended support dates. diff --git a/cmd/validators_coverage_test.go b/cmd/validators_coverage_test.go new file mode 100644 index 000000000..f0d8ac3b2 --- /dev/null +++ b/cmd/validators_coverage_test.go @@ -0,0 +1,201 @@ +package main + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// TestValidateTargetCoverage covers the --target-coverage range check +// and the "both flags explicitly set" info-log gate. Range cases pass nil +// cmd (the explicit-flag detection is irrelevant for them); the "both set" +// case constructs a real cobra command and marks --coverage as explicitly +// set so the Changed("coverage") branch actually fires. The log line +// itself isn't asserted (log.Printf goes to stderr and capturing it from +// this package is more friction than value). +func TestValidateTargetCoverage(t *testing.T) { + tests := []struct { + name string + errSubstr string + target float64 + coverage float64 + wantErr bool + useCobraCmd bool + }{ + {name: "disabled (zero) is valid", target: 0, coverage: 80, wantErr: false}, + {name: "min boundary valid", target: 0.0001, coverage: 80, wantErr: false}, + {name: "max boundary valid", target: 100, coverage: 80, wantErr: false}, + {name: "mid-range valid", target: 95, coverage: 80, wantErr: false}, + { + name: "negative target rejected", target: -0.5, coverage: 80, wantErr: true, + errSubstr: "target-coverage percentage must be between 0 and 100", + }, + { + name: "above 100 rejected", target: 100.01, coverage: 80, wantErr: true, + errSubstr: "target-coverage percentage must be between 0 and 100", + }, + { + // Both flags explicitly set — the precedence info-log fires; + // validation still passes. useCobraCmd builds a real command so + // Changed("coverage") returns true; without this the branch is + // effectively dead code in the test. + name: "target + coverage both set, both valid", + target: 95, + coverage: 50, + wantErr: false, + useCobraCmd: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Cleanup(saveGlobalVars().restore) + toolCfg = Config{Coverage: tt.coverage, TargetCoverage: tt.target, CoverageLookbackDays: 30} + var cmd *cobra.Command + if tt.useCobraCmd { + cmd = &cobra.Command{Use: "test"} + // Default doesn't matter — the Set call below marks the flag + // as Changed, which is what validateTargetCoverage checks. + cmd.Flags().Float64("coverage", 80, "") + if err := cmd.Flags().Set("coverage", "50"); err != nil { + t.Fatalf("failed to mark coverage flag as Changed: %v", err) + } + } + err := validateNumericRanges(cmd) + if tt.wantErr { + if err == nil { + t.Errorf("validateNumericRanges() expected error containing %q, got nil", tt.errSubstr) + } else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { + t.Errorf("validateNumericRanges() error = %v, want substring %q", err, tt.errSubstr) + } + } else if err != nil { + t.Errorf("validateNumericRanges() unexpected error = %v", err) + } + }) + } +} + +// TestValidateCoverageLookbackDays verifies that validateNumericRanges rejects +// non-positive --coverage-lookback-days and accepts positive values. +func TestValidateCoverageLookbackDays(t *testing.T) { + tests := []struct { + name string + errSubstr string + days int + wantErr bool + }{ + {name: "default 30 is valid", days: 30, wantErr: false}, + {name: "1 day is valid", days: 1, wantErr: false}, + {name: "90 days is valid", days: 90, wantErr: false}, + { + name: "zero rejected", + days: 0, + wantErr: true, + errSubstr: "coverage-lookback-days must be >= 1", + }, + { + name: "negative rejected", + days: -1, + wantErr: true, + errSubstr: "coverage-lookback-days must be >= 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + toolCfg = Config{ + Coverage: 80, + CoverageLookbackDays: tt.days, + } + err := validateNumericRanges(nil) + if tt.wantErr { + if err == nil { + t.Errorf("validateNumericRanges() expected error containing %q, got nil", tt.errSubstr) + } else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { + t.Errorf("validateNumericRanges() error = %v, want substring %q", err, tt.errSubstr) + } + } else if err != nil { + t.Errorf("validateNumericRanges() unexpected error = %v", err) + } + }) + } +} + +// TestValidateRecLookbackPeriod verifies that validateRecLookbackPeriod accepts +// the three valid values and rejects anything else, including empty string. +func TestValidateRecLookbackPeriod(t *testing.T) { + tests := []struct { + name string + period string + wantErr bool + errSubstr string + }{ + {name: "7d valid", period: "7d", wantErr: false}, + {name: "30d valid", period: "30d", wantErr: false}, + {name: "60d valid", period: "60d", wantErr: false}, + {name: "empty rejected", period: "", wantErr: true, errSubstr: "invalid rec-lookback-period"}, + {name: "14d rejected", period: "14d", wantErr: true, errSubstr: "invalid rec-lookback-period"}, + {name: "90d rejected", period: "90d", wantErr: true, errSubstr: "invalid rec-lookback-period"}, + {name: "SEVEN_DAYS rejected", period: "SEVEN_DAYS", wantErr: true, errSubstr: "invalid rec-lookback-period"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + toolCfg.RecLookbackPeriod = tt.period + err := validateRecLookbackPeriod() + if tt.wantErr { + if err == nil { + t.Errorf("validateRecLookbackPeriod() expected error containing %q, got nil", tt.errSubstr) + } else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { + t.Errorf("validateRecLookbackPeriod() error = %v, want substring %q", err, tt.errSubstr) + } + } else if err != nil { + t.Errorf("validateRecLookbackPeriod() unexpected error = %v", err) + } + }) + } +} + +// TestValidateIdempotencyWindow covers #1262: the flag used to be accepted +// unparsed, so "banana" or "90m" ran silently with a hardcoded 24h window. +func TestValidateIdempotencyWindow(t *testing.T) { + tests := []struct { + window string + wantHours int + wantErr bool + }{ + {window: "24h", wantHours: 24}, + {window: "72h", wantHours: 72}, + {window: "1h", wantHours: 1}, + {window: "", wantErr: true}, + {window: "banana", wantErr: true}, + {window: "0h", wantErr: true}, + {window: "-24h", wantErr: true}, + {window: "90m", wantErr: true}, + {window: "1h30m", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.window, func(t *testing.T) { + origCfg := toolCfg + defer func() { toolCfg = origCfg }() + toolCfg.IdempotencyWindow = tt.window + err := validateIdempotencyWindow() + if tt.wantErr { + if err == nil || !strings.Contains(err.Error(), "invalid idempotency-window") { + t.Fatalf("validateIdempotencyWindow(%q) error = %v, want invalid idempotency-window", tt.window, err) + } + return + } + if err != nil { + t.Fatalf("validateIdempotencyWindow(%q) unexpected error = %v", tt.window, err) + } + if toolCfg.IdempotencyWindowHours != tt.wantHours { + t.Errorf("IdempotencyWindowHours = %d, want %d", toolCfg.IdempotencyWindowHours, tt.wantHours) + } + }) + } +} diff --git a/cmd/validators_file_paths_test.go b/cmd/validators_file_paths_test.go new file mode 100644 index 000000000..338c41c41 --- /dev/null +++ b/cmd/validators_file_paths_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestValidateFilePaths(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + + tests := []struct { + setupFunc func() func() + name string + errMsg string + wantErr bool + }{ + { + name: "valid CSV output path", + setupFunc: func() func() { + toolCfg.CSVOutput = filepath.Join(tmpDir, "output.csv") + toolCfg.CSVInput = "" + return func() {} + }, + wantErr: false, + }, + { + name: "valid CSV input path", + setupFunc: func() func() { + // Create a test CSV file + inputPath := filepath.Join(tmpDir, "input.csv") + if err := os.WriteFile(inputPath, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + toolCfg.CSVInput = inputPath + toolCfg.CSVOutput = "" + return func() { + os.Remove(inputPath) + } + }, + wantErr: false, + }, + { + name: "CSV output directory does not exist", + setupFunc: func() func() { + toolCfg.CSVOutput = "/nonexistent/directory/output.csv" + toolCfg.CSVInput = "" + return func() {} + }, + wantErr: true, + errMsg: "output directory does not exist", + }, + { + name: "CSV output file already exists", + setupFunc: func() func() { + p := filepath.Join(tmpDir, "existing.csv") + if err := os.WriteFile(p, []byte("keep"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + toolCfg.CSVOutput = p + return func() { os.Remove(p) } + }, + wantErr: true, + errMsg: "output path already exists", + }, + { + name: "CSV output path is an existing directory", + setupFunc: func() func() { + toolCfg.CSVOutput = tmpDir + return func() {} + }, + wantErr: true, + errMsg: "output path already exists", + }, + { + name: "CSV output is a dangling symlink", + setupFunc: func() func() { + p := filepath.Join(tmpDir, "dangling.csv") + if err := os.Symlink(filepath.Join(tmpDir, "missing-target"), p); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + toolCfg.CSVOutput = p + return func() { os.Remove(p) } + }, + wantErr: true, + errMsg: "output path already exists", + }, + { + name: "CSV input equals output", + setupFunc: func() func() { + p := filepath.Join(tmpDir, "same.csv") + if err := os.WriteFile(p, []byte("keep"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + toolCfg.CSVOutput = p + toolCfg.CSVInput = p + return func() { os.Remove(p) } + }, + wantErr: true, + errMsg: "output path already exists", + }, + { + name: "CSV input file does not exist", + setupFunc: func() func() { + toolCfg.CSVInput = filepath.Join(tmpDir, "nonexistent.csv") + toolCfg.CSVOutput = "" + return func() {} + }, + wantErr: true, + errMsg: "input CSV file does not exist", + }, + { + name: "CSV input file wrong extension", + setupFunc: func() func() { + // Create a test file with wrong extension + inputPath := filepath.Join(tmpDir, "input.txt") + if err := os.WriteFile(inputPath, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + toolCfg.CSVInput = inputPath + toolCfg.CSVOutput = "" + return func() { + os.Remove(inputPath) + } + }, + wantErr: true, + errMsg: "input file must have .csv extension", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup + t.Cleanup(saveGlobalVars().restore) + toolCfg = Config{} + cleanup := tt.setupFunc() + defer cleanup() + + // Execute + err := validateFilePaths() + + // Verify + if tt.wantErr { + if err == nil { + t.Errorf("validateFilePaths() expected error containing %q, got nil", tt.errMsg) + } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { + t.Errorf("validateFilePaths() error = %v, want error containing %q", err, tt.errMsg) + } + } else { + if err != nil { + t.Errorf("validateFilePaths() unexpected error = %v", err) + } + } + }) + } +} diff --git a/cmd/validators_test.go b/cmd/validators_test.go index 24f6d157c..d34305831 100644 --- a/cmd/validators_test.go +++ b/cmd/validators_test.go @@ -2,14 +2,11 @@ package main import ( "context" - "os" - "path/filepath" "strings" "testing" "time" "github.com/LeanerCloud/cloud-commitments-go/pkg/common" - "github.com/spf13/cobra" ) func TestValidateNumericRanges(t *testing.T) { @@ -304,156 +301,6 @@ func TestContainsService(t *testing.T) { } } -func TestValidateFilePaths(t *testing.T) { - // Create a temporary directory for testing - tmpDir := t.TempDir() - - tests := []struct { - setupFunc func() func() - name string - errMsg string - wantErr bool - }{ - { - name: "valid CSV output path", - setupFunc: func() func() { - toolCfg.CSVOutput = filepath.Join(tmpDir, "output.csv") - toolCfg.CSVInput = "" - return func() {} - }, - wantErr: false, - }, - { - name: "valid CSV input path", - setupFunc: func() func() { - // Create a test CSV file - inputPath := filepath.Join(tmpDir, "input.csv") - if err := os.WriteFile(inputPath, []byte("test"), 0644); err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - toolCfg.CSVInput = inputPath - toolCfg.CSVOutput = "" - return func() { - os.Remove(inputPath) - } - }, - wantErr: false, - }, - { - name: "CSV output directory does not exist", - setupFunc: func() func() { - toolCfg.CSVOutput = "/nonexistent/directory/output.csv" - toolCfg.CSVInput = "" - return func() {} - }, - wantErr: true, - errMsg: "output directory does not exist", - }, - { - name: "CSV output file already exists", - setupFunc: func() func() { - p := filepath.Join(tmpDir, "existing.csv") - if err := os.WriteFile(p, []byte("keep"), 0644); err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - toolCfg.CSVOutput = p - return func() { os.Remove(p) } - }, - wantErr: true, - errMsg: "output path already exists", - }, - { - name: "CSV output path is an existing directory", - setupFunc: func() func() { - toolCfg.CSVOutput = tmpDir - return func() {} - }, - wantErr: true, - errMsg: "output path already exists", - }, - { - name: "CSV output is a dangling symlink", - setupFunc: func() func() { - p := filepath.Join(tmpDir, "dangling.csv") - if err := os.Symlink(filepath.Join(tmpDir, "missing-target"), p); err != nil { - t.Skipf("symlinks unavailable: %v", err) - } - toolCfg.CSVOutput = p - return func() { os.Remove(p) } - }, - wantErr: true, - errMsg: "output path already exists", - }, - { - name: "CSV input equals output", - setupFunc: func() func() { - p := filepath.Join(tmpDir, "same.csv") - if err := os.WriteFile(p, []byte("keep"), 0644); err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - toolCfg.CSVOutput = p - toolCfg.CSVInput = p - return func() { os.Remove(p) } - }, - wantErr: true, - errMsg: "output path already exists", - }, - { - name: "CSV input file does not exist", - setupFunc: func() func() { - toolCfg.CSVInput = filepath.Join(tmpDir, "nonexistent.csv") - toolCfg.CSVOutput = "" - return func() {} - }, - wantErr: true, - errMsg: "input CSV file does not exist", - }, - { - name: "CSV input file wrong extension", - setupFunc: func() func() { - // Create a test file with wrong extension - inputPath := filepath.Join(tmpDir, "input.txt") - if err := os.WriteFile(inputPath, []byte("test"), 0644); err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - toolCfg.CSVInput = inputPath - toolCfg.CSVOutput = "" - return func() { - os.Remove(inputPath) - } - }, - wantErr: true, - errMsg: "input file must have .csv extension", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup - t.Cleanup(saveGlobalVars().restore) - toolCfg = Config{} - cleanup := tt.setupFunc() - defer cleanup() - - // Execute - err := validateFilePaths() - - // Verify - if tt.wantErr { - if err == nil { - t.Errorf("validateFilePaths() expected error containing %q, got nil", tt.errMsg) - } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { - t.Errorf("validateFilePaths() error = %v, want error containing %q", err, tt.errMsg) - } - } else { - if err != nil { - t.Errorf("validateFilePaths() unexpected error = %v", err) - } - } - }) - } -} - func TestValidateNoConflicts(t *testing.T) { tests := []struct { name string @@ -522,199 +369,6 @@ func TestValidateNoConflicts(t *testing.T) { // Note: TestValidateInstanceTypes and TestValidateFlags already exist in main_test.go -// TestValidateTargetCoverage covers the --target-coverage range check -// and the "both flags explicitly set" info-log gate. Range cases pass nil -// cmd (the explicit-flag detection is irrelevant for them); the "both set" -// case constructs a real cobra command and marks --coverage as explicitly -// set so the Changed("coverage") branch actually fires. The log line -// itself isn't asserted (log.Printf goes to stderr and capturing it from -// this package is more friction than value). -func TestValidateTargetCoverage(t *testing.T) { - tests := []struct { - name string - errSubstr string - target float64 - coverage float64 - wantErr bool - useCobraCmd bool - }{ - {name: "disabled (zero) is valid", target: 0, coverage: 80, wantErr: false}, - {name: "min boundary valid", target: 0.0001, coverage: 80, wantErr: false}, - {name: "max boundary valid", target: 100, coverage: 80, wantErr: false}, - {name: "mid-range valid", target: 95, coverage: 80, wantErr: false}, - { - name: "negative target rejected", target: -0.5, coverage: 80, wantErr: true, - errSubstr: "target-coverage percentage must be between 0 and 100", - }, - { - name: "above 100 rejected", target: 100.01, coverage: 80, wantErr: true, - errSubstr: "target-coverage percentage must be between 0 and 100", - }, - { - // Both flags explicitly set — the precedence info-log fires; - // validation still passes. useCobraCmd builds a real command so - // Changed("coverage") returns true; without this the branch is - // effectively dead code in the test. - name: "target + coverage both set, both valid", - target: 95, - coverage: 50, - wantErr: false, - useCobraCmd: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Cleanup(saveGlobalVars().restore) - toolCfg = Config{Coverage: tt.coverage, TargetCoverage: tt.target, CoverageLookbackDays: 30} - var cmd *cobra.Command - if tt.useCobraCmd { - cmd = &cobra.Command{Use: "test"} - // Default doesn't matter — the Set call below marks the flag - // as Changed, which is what validateTargetCoverage checks. - cmd.Flags().Float64("coverage", 80, "") - if err := cmd.Flags().Set("coverage", "50"); err != nil { - t.Fatalf("failed to mark coverage flag as Changed: %v", err) - } - } - err := validateNumericRanges(cmd) - if tt.wantErr { - if err == nil { - t.Errorf("validateNumericRanges() expected error containing %q, got nil", tt.errSubstr) - } else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { - t.Errorf("validateNumericRanges() error = %v, want substring %q", err, tt.errSubstr) - } - } else if err != nil { - t.Errorf("validateNumericRanges() unexpected error = %v", err) - } - }) - } -} - -// TestValidateCoverageLookbackDays verifies that validateNumericRanges rejects -// non-positive --coverage-lookback-days and accepts positive values. -func TestValidateCoverageLookbackDays(t *testing.T) { - tests := []struct { - name string - errSubstr string - days int - wantErr bool - }{ - {name: "default 30 is valid", days: 30, wantErr: false}, - {name: "1 day is valid", days: 1, wantErr: false}, - {name: "90 days is valid", days: 90, wantErr: false}, - { - name: "zero rejected", - days: 0, - wantErr: true, - errSubstr: "coverage-lookback-days must be >= 1", - }, - { - name: "negative rejected", - days: -1, - wantErr: true, - errSubstr: "coverage-lookback-days must be >= 1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - toolCfg = Config{ - Coverage: 80, - CoverageLookbackDays: tt.days, - } - err := validateNumericRanges(nil) - if tt.wantErr { - if err == nil { - t.Errorf("validateNumericRanges() expected error containing %q, got nil", tt.errSubstr) - } else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { - t.Errorf("validateNumericRanges() error = %v, want substring %q", err, tt.errSubstr) - } - } else if err != nil { - t.Errorf("validateNumericRanges() unexpected error = %v", err) - } - }) - } -} - -// TestValidateRecLookbackPeriod verifies that validateRecLookbackPeriod accepts -// the three valid values and rejects anything else, including empty string. -func TestValidateRecLookbackPeriod(t *testing.T) { - tests := []struct { - name string - period string - wantErr bool - errSubstr string - }{ - {name: "7d valid", period: "7d", wantErr: false}, - {name: "30d valid", period: "30d", wantErr: false}, - {name: "60d valid", period: "60d", wantErr: false}, - {name: "empty rejected", period: "", wantErr: true, errSubstr: "invalid rec-lookback-period"}, - {name: "14d rejected", period: "14d", wantErr: true, errSubstr: "invalid rec-lookback-period"}, - {name: "90d rejected", period: "90d", wantErr: true, errSubstr: "invalid rec-lookback-period"}, - {name: "SEVEN_DAYS rejected", period: "SEVEN_DAYS", wantErr: true, errSubstr: "invalid rec-lookback-period"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - toolCfg.RecLookbackPeriod = tt.period - err := validateRecLookbackPeriod() - if tt.wantErr { - if err == nil { - t.Errorf("validateRecLookbackPeriod() expected error containing %q, got nil", tt.errSubstr) - } else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) { - t.Errorf("validateRecLookbackPeriod() error = %v, want substring %q", err, tt.errSubstr) - } - } else if err != nil { - t.Errorf("validateRecLookbackPeriod() unexpected error = %v", err) - } - }) - } -} - -// TestValidateIdempotencyWindow covers #1262: the flag used to be accepted -// unparsed, so "banana" or "90m" ran silently with a hardcoded 24h window. -func TestValidateIdempotencyWindow(t *testing.T) { - tests := []struct { - window string - wantHours int - wantErr bool - }{ - {window: "24h", wantHours: 24}, - {window: "72h", wantHours: 72}, - {window: "1h", wantHours: 1}, - {window: "", wantErr: true}, - {window: "banana", wantErr: true}, - {window: "0h", wantErr: true}, - {window: "-24h", wantErr: true}, - {window: "90m", wantErr: true}, - {window: "1h30m", wantErr: true}, - } - for _, tt := range tests { - t.Run(tt.window, func(t *testing.T) { - origCfg := toolCfg - defer func() { toolCfg = origCfg }() - toolCfg.IdempotencyWindow = tt.window - err := validateIdempotencyWindow() - if tt.wantErr { - if err == nil || !strings.Contains(err.Error(), "invalid idempotency-window") { - t.Fatalf("validateIdempotencyWindow(%q) error = %v, want invalid idempotency-window", tt.window, err) - } - return - } - if err != nil { - t.Fatalf("validateIdempotencyWindow(%q) unexpected error = %v", tt.window, err) - } - if toolCfg.IdempotencyWindowHours != tt.wantHours { - t.Errorf("IdempotencyWindowHours = %d, want %d", toolCfg.IdempotencyWindowHours, tt.wantHours) - } - }) - } -} - // TestCheckDuplicates_HonorsIdempotencyWindow reproduces the #1262 scenario: // a re-run 30h after a purchase with --idempotency-window 72h must subtract // that purchase. With the window ignored the lookback stayed at 24h and the