Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 18 additions & 5 deletions cmd/multi_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func effectiveDryRun(cfg Config) bool {
// (2) score, display, confirm, and purchase.
func runToolMultiService(ctx context.Context, cfg Config) {
if cfg.CSVInput != "" {
runToolFromCSV(ctx, cfg)
runCSVPathOrFatal(ctx, cfg)
return
}

Expand Down Expand Up @@ -272,8 +272,20 @@ func buildServiceStats(recs []common.Recommendation, results []common.PurchaseRe
return stats
}

// runCSVPathOrFatal runs the CSV purchase path and exits fatally on error.
// It isolates the error-to-fatal glue from runToolMultiService so that path
// stays under the cyclomatic-complexity budget while runToolFromCSV remains
// unit-testable via its returned error.
func runCSVPathOrFatal(ctx context.Context, cfg Config) {
if err := runToolFromCSV(ctx, cfg); err != nil {
log.Fatalf("%v", err)
}
}

// runToolFromCSV processes recommendations from a CSV input file.
func runToolFromCSV(ctx context.Context, cfg Config) {
// It returns an error instead of exiting so the orchestration glue is
// unit-testable; the caller (runCSVPathOrFatal) turns errors fatal.
func runToolFromCSV(ctx context.Context, cfg Config) error {
isDryRun := effectiveDryRun(cfg)
printRunMode(isDryRun)

Expand All @@ -284,7 +296,7 @@ func runToolFromCSV(ctx context.Context, cfg Config) {
// Read recommendations from CSV
recs, err := loadRecommendationsFromCSV(cfg.CSVInput)
if err != nil {
log.Fatalf("Failed to read CSV file: %v", err)
return fmt.Errorf("failed to read CSV file: %w", err)
}

AppLogger.Printf("✅ Loaded %d recommendations from CSV\n", len(recs))
Expand All @@ -294,7 +306,7 @@ func runToolFromCSV(ctx context.Context, cfg Config) {

if len(recs) == 0 {
AppLogger.Println("⚠️ No recommendations to process after filtering")
return
return nil
}

// Load AWS configuration
Expand All @@ -305,7 +317,7 @@ func runToolFromCSV(ctx context.Context, cfg Config) {
}
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, configOptions...)
if err != nil {
log.Fatalf("Failed to load AWS config: %v", err)
return fmt.Errorf("failed to load AWS config: %w", err)
}

// Create account alias cache for lookup
Expand Down Expand Up @@ -386,6 +398,7 @@ func runToolFromCSV(ctx context.Context, cfg Config) {
// Print final summary using the post-dedup slice so counts match what was
// actually processed, not the pre-dedup input passed into the outer loop.
printMultiServiceSummary(allAdjustedRecs, allResults, serviceStats, isDryRun)
return nil
}

// filterAndAdjustRecommendations applies filters, coverage, count override, and instance limits to recommendations.
Expand Down
143 changes: 99 additions & 44 deletions cmd/multi_service_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import (

"github.com/LeanerCloud/CUDly/pkg/common"
"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"
)

// ==================== Tests for coverage improvement ====================
Expand All @@ -18,72 +21,124 @@ import (

// ==================== Tests for queryMajorEngineVersions ====================

func TestQueryMajorEngineVersions_Success(t *testing.T) {
// This test verifies the logic of queryMajorEngineVersions without mocking
// Since it requires AWS credentials, we test the error cases and structure
// 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
}

ctx := context.Background()
origCfg := toolCfg
defer func() { toolCfg = origCfg }()
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
}

// Test with empty profile (should work if AWS credentials are configured)
toolCfg.Profile = ""
toolCfg.ValidationProfile = ""
// 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")},
},
}

// This will attempt to load AWS config - may fail without credentials
result, err := queryMajorEngineVersions(ctx, toolCfg)
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")
}

// Either succeeds with valid credentials or fails gracefully
if err != nil {
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to load AWS config")
} else {
// If it succeeds, verify the result structure
assert.NotNil(t, result)
// Result can be empty if no versions are found
// TestQueryMajorEngineVersionsWithClient_EngineErrorContinues asserts the
// warn-and-continue contract: one engine failing must not drop the results
// of the others, and the overall call still succeeds.
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.NoError(t, err, "per-engine API failures are warn-and-continue")

assert.ElementsMatch(t,
[]string{"mysql", "postgres", "aurora-mysql", "aurora-postgresql"},
stub.enginesQueried,
"a failing engine must not stop the remaining engine queries")
require.Len(t, result, 1)
assert.Equal(t, "15", result["postgres:15"].MajorEngineVersion)
}

func TestQueryMajorEngineVersions_ProfileHandling(t *testing.T) {
// 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()
origCfg := toolCfg
defer func() { toolCfg = origCfg }()

tests := []struct {
name string
profile string
validationProfile string
wantProfileInErr string
}{
{
name: "Uses validation profile if set",
profile: "main-profile",
validationProfile: "validation-profile",
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: "main-profile",
validationProfile: "",
},
{
name: "Empty profiles use default",
profile: "",
validationProfile: "",
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) {
toolCfg.Profile = tt.profile
toolCfg.ValidationProfile = tt.validationProfile

// This will attempt to load config - may fail without valid profiles
_, err := queryMajorEngineVersions(ctx, toolCfg)

// We just verify it doesn't panic - actual AWS calls may fail
// Error is acceptable for invalid profiles
if err != nil {
assert.Error(t, err)
}
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")
})
}
}
Expand Down
Loading
Loading