diff --git a/internal/usecase/param/delete.go b/internal/usecase/param/delete.go new file mode 100644 index 00000000..df071856 --- /dev/null +++ b/internal/usecase/param/delete.go @@ -0,0 +1,60 @@ +package param + +import ( + "context" + "errors" + "fmt" + + "github.com/samber/lo" + + "github.com/mpyw/suve/internal/api/paramapi" +) + +// DeleteClient is the interface for the delete use case. +type DeleteClient interface { + paramapi.DeleteParameterAPI + paramapi.GetParameterAPI +} + +// DeleteInput holds input for the delete use case. +type DeleteInput struct { + Name string +} + +// DeleteOutput holds the result of the delete use case. +type DeleteOutput struct { + Name string +} + +// DeleteUseCase executes delete operations. +type DeleteUseCase struct { + Client DeleteClient +} + +// GetCurrentValue fetches the current value for preview. +func (u *DeleteUseCase) GetCurrentValue(ctx context.Context, name string) (string, error) { + out, err := u.Client.GetParameter(ctx, ¶mapi.GetParameterInput{ + Name: lo.ToPtr(name), + WithDecryption: lo.ToPtr(true), + }) + if err != nil { + var pnf *paramapi.ParameterNotFound + if errors.As(err, &pnf) { + return "", nil + } + return "", err + } + return lo.FromPtr(out.Parameter.Value), nil +} + +// Execute runs the delete use case. +func (u *DeleteUseCase) Execute(ctx context.Context, input DeleteInput) (*DeleteOutput, error) { + _, err := u.Client.DeleteParameter(ctx, ¶mapi.DeleteParameterInput{ + Name: lo.ToPtr(input.Name), + }) + if err != nil { + return nil, fmt.Errorf("failed to delete parameter: %w", err) + } + + return &DeleteOutput{Name: input.Name}, nil +} diff --git a/internal/usecase/param/delete_test.go b/internal/usecase/param/delete_test.go new file mode 100644 index 00000000..db2b9b8e --- /dev/null +++ b/internal/usecase/param/delete_test.go @@ -0,0 +1,112 @@ +package param_test + +import ( + "context" + "errors" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mpyw/suve/internal/api/paramapi" + "github.com/mpyw/suve/internal/usecase/param" +) + +type mockDeleteClient struct { + getParameterResult *paramapi.GetParameterOutput + getParameterErr error + deleteParameterResult *paramapi.DeleteParameterOutput + deleteParameterErr error +} + +func (m *mockDeleteClient) GetParameter(_ context.Context, _ *paramapi.GetParameterInput, _ ...func(*paramapi.Options)) (*paramapi.GetParameterOutput, error) { + if m.getParameterErr != nil { + return nil, m.getParameterErr + } + return m.getParameterResult, nil +} + +func (m *mockDeleteClient) DeleteParameter(_ context.Context, _ *paramapi.DeleteParameterInput, _ ...func(*paramapi.Options)) (*paramapi.DeleteParameterOutput, error) { + if m.deleteParameterErr != nil { + return nil, m.deleteParameterErr + } + return m.deleteParameterResult, nil +} + +func TestDeleteUseCase_GetCurrentValue(t *testing.T) { + t.Parallel() + + client := &mockDeleteClient{ + getParameterResult: ¶mapi.GetParameterOutput{ + Parameter: ¶mapi.Parameter{ + Value: lo.ToPtr("current-value"), + }, + }, + } + + uc := ¶m.DeleteUseCase{Client: client} + + value, err := uc.GetCurrentValue(context.Background(), "/app/config") + require.NoError(t, err) + assert.Equal(t, "current-value", value) +} + +func TestDeleteUseCase_GetCurrentValue_NotFound(t *testing.T) { + t.Parallel() + + client := &mockDeleteClient{ + getParameterErr: ¶mapi.ParameterNotFound{Message: lo.ToPtr("not found")}, + } + + uc := ¶m.DeleteUseCase{Client: client} + + value, err := uc.GetCurrentValue(context.Background(), "/app/not-exists") + require.NoError(t, err) + assert.Empty(t, value) +} + +func TestDeleteUseCase_GetCurrentValue_Error(t *testing.T) { + t.Parallel() + + client := &mockDeleteClient{ + getParameterErr: errors.New("aws error"), + } + + uc := ¶m.DeleteUseCase{Client: client} + + _, err := uc.GetCurrentValue(context.Background(), "/app/config") + assert.Error(t, err) +} + +func TestDeleteUseCase_Execute(t *testing.T) { + t.Parallel() + + client := &mockDeleteClient{ + deleteParameterResult: ¶mapi.DeleteParameterOutput{}, + } + + uc := ¶m.DeleteUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.DeleteInput{ + Name: "/app/to-delete", + }) + require.NoError(t, err) + assert.Equal(t, "/app/to-delete", output.Name) +} + +func TestDeleteUseCase_Execute_Error(t *testing.T) { + t.Parallel() + + client := &mockDeleteClient{ + deleteParameterErr: errors.New("delete failed"), + } + + uc := ¶m.DeleteUseCase{Client: client} + + _, err := uc.Execute(context.Background(), param.DeleteInput{ + Name: "/app/config", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to delete parameter") +} diff --git a/internal/usecase/param/diff.go b/internal/usecase/param/diff.go new file mode 100644 index 00000000..10876494 --- /dev/null +++ b/internal/usecase/param/diff.go @@ -0,0 +1,59 @@ +package param + +import ( + "context" + + "github.com/samber/lo" + + "github.com/mpyw/suve/internal/api/paramapi" + "github.com/mpyw/suve/internal/version/paramversion" +) + +// DiffClient is the interface for the diff use case. +type DiffClient interface { + paramapi.GetParameterAPI + paramapi.GetParameterHistoryAPI +} + +// DiffInput holds input for the diff use case. +type DiffInput struct { + Spec1 *paramversion.Spec + Spec2 *paramversion.Spec +} + +// DiffOutput holds the result of the diff use case. +type DiffOutput struct { + OldName string + OldVersion int64 + OldValue string + NewName string + NewVersion int64 + NewValue string +} + +// DiffUseCase executes diff operations. +type DiffUseCase struct { + Client DiffClient +} + +// Execute runs the diff use case. +func (u *DiffUseCase) Execute(ctx context.Context, input DiffInput) (*DiffOutput, error) { + param1, err := paramversion.GetParameterWithVersion(ctx, u.Client, input.Spec1, true) + if err != nil { + return nil, err + } + + param2, err := paramversion.GetParameterWithVersion(ctx, u.Client, input.Spec2, true) + if err != nil { + return nil, err + } + + return &DiffOutput{ + OldName: lo.FromPtr(param1.Name), + OldVersion: param1.Version, + OldValue: lo.FromPtr(param1.Value), + NewName: lo.FromPtr(param2.Name), + NewVersion: param2.Version, + NewValue: lo.FromPtr(param2.Value), + }, nil +} diff --git a/internal/usecase/param/diff_test.go b/internal/usecase/param/diff_test.go new file mode 100644 index 00000000..1d868e4c --- /dev/null +++ b/internal/usecase/param/diff_test.go @@ -0,0 +1,189 @@ +package param_test + +import ( + "context" + "errors" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mpyw/suve/internal/api/paramapi" + "github.com/mpyw/suve/internal/usecase/param" + "github.com/mpyw/suve/internal/version/paramversion" +) + +type mockDiffClient struct { + getParameterResults []*paramapi.GetParameterOutput + getParameterErrs []error + getParameterCalls int + // historyParams stores the base data; each call returns a fresh copy + historyParams []paramapi.ParameterHistory + getHistoryErr error +} + +func (m *mockDiffClient) GetParameter(_ context.Context, _ *paramapi.GetParameterInput, _ ...func(*paramapi.Options)) (*paramapi.GetParameterOutput, error) { + idx := m.getParameterCalls + m.getParameterCalls++ + + if idx < len(m.getParameterErrs) && m.getParameterErrs[idx] != nil { + return nil, m.getParameterErrs[idx] + } + if idx < len(m.getParameterResults) { + return m.getParameterResults[idx], nil + } + return nil, errors.New("unexpected GetParameter call") +} + +func (m *mockDiffClient) GetParameterHistory(_ context.Context, _ *paramapi.GetParameterHistoryInput, _ ...func(*paramapi.Options)) (*paramapi.GetParameterHistoryOutput, error) { + if m.getHistoryErr != nil { + return nil, m.getHistoryErr + } + // Return a fresh copy to avoid in-place mutations affecting subsequent calls + params := make([]paramapi.ParameterHistory, len(m.historyParams)) + copy(params, m.historyParams) + return ¶mapi.GetParameterHistoryOutput{Parameters: params}, nil +} + +func TestDiffUseCase_Execute(t *testing.T) { + t.Parallel() + + // #VERSION specs without shift use GetParameter (with name:version format) + client := &mockDiffClient{ + getParameterResults: []*paramapi.GetParameterOutput{ + {Parameter: ¶mapi.Parameter{Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("old-value"), Version: 1}}, + {Parameter: ¶mapi.Parameter{Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("new-value"), Version: 2}}, + }, + } + + uc := ¶m.DiffUseCase{Client: client} + + spec1, _ := paramversion.Parse("/app/config#1") + spec2, _ := paramversion.Parse("/app/config#2") + + output, err := uc.Execute(context.Background(), param.DiffInput{ + Spec1: spec1, + Spec2: spec2, + }) + require.NoError(t, err) + assert.Equal(t, "/app/config", output.OldName) + assert.Equal(t, int64(1), output.OldVersion) + assert.Equal(t, "old-value", output.OldValue) + assert.Equal(t, "/app/config", output.NewName) + assert.Equal(t, int64(2), output.NewVersion) + assert.Equal(t, "new-value", output.NewValue) +} + +func TestDiffUseCase_Execute_Spec1Error(t *testing.T) { + t.Parallel() + + client := &mockDiffClient{ + getParameterErrs: []error{errors.New("get parameter error")}, + } + + uc := ¶m.DiffUseCase{Client: client} + + spec1, _ := paramversion.Parse("/app/config#1") + spec2, _ := paramversion.Parse("/app/config#2") + + _, err := uc.Execute(context.Background(), param.DiffInput{ + Spec1: spec1, + Spec2: spec2, + }) + assert.Error(t, err) +} + +func TestDiffUseCase_Execute_Spec2Error(t *testing.T) { + t.Parallel() + + client := &mockDiffClient{ + getParameterResults: []*paramapi.GetParameterOutput{ + {Parameter: ¶mapi.Parameter{Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("old-value"), Version: 1}}, + }, + getParameterErrs: []error{nil, errors.New("second get parameter error")}, + } + + uc := ¶m.DiffUseCase{Client: client} + + spec1, _ := paramversion.Parse("/app/config#1") + spec2, _ := paramversion.Parse("/app/config#2") + + _, err := uc.Execute(context.Background(), param.DiffInput{ + Spec1: spec1, + Spec2: spec2, + }) + assert.Error(t, err) +} + +func TestDiffUseCase_Execute_WithLatest(t *testing.T) { + t.Parallel() + + // Both specs without shift use GetParameter + client := &mockDiffClient{ + getParameterResults: []*paramapi.GetParameterOutput{ + {Parameter: ¶mapi.Parameter{Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("old-value"), Version: 3}}, + {Parameter: ¶mapi.Parameter{Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("latest-value"), Version: 5}}, + }, + } + + uc := ¶m.DiffUseCase{Client: client} + + spec1, _ := paramversion.Parse("/app/config#3") + spec2, _ := paramversion.Parse("/app/config") + + output, err := uc.Execute(context.Background(), param.DiffInput{ + Spec1: spec1, + Spec2: spec2, + }) + require.NoError(t, err) + assert.Equal(t, int64(3), output.OldVersion) + assert.Equal(t, int64(5), output.NewVersion) +} + +func TestDiffUseCase_Execute_WithShift(t *testing.T) { + t.Parallel() + + // Specs with shift use GetParameterHistory + client := &mockDiffClient{ + historyParams: []paramapi.ParameterHistory{ + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v1"), Version: 1}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v2"), Version: 2}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v3"), Version: 3}, + }, + } + + uc := ¶m.DiffUseCase{Client: client} + + spec1, _ := paramversion.Parse("/app/config~2") // 2 versions back from latest (v3 -> v1) + spec2, _ := paramversion.Parse("/app/config~1") // 1 version back from latest (v3 -> v2) + + output, err := uc.Execute(context.Background(), param.DiffInput{ + Spec1: spec1, + Spec2: spec2, + }) + require.NoError(t, err) + assert.Equal(t, int64(1), output.OldVersion) + assert.Equal(t, "v1", output.OldValue) + assert.Equal(t, int64(2), output.NewVersion) + assert.Equal(t, "v2", output.NewValue) +} + +func TestDiffUseCase_Execute_WithShift_Error(t *testing.T) { + t.Parallel() + + client := &mockDiffClient{ + getHistoryErr: errors.New("history error"), + } + + uc := ¶m.DiffUseCase{Client: client} + + spec1, _ := paramversion.Parse("/app/config~1") + spec2, _ := paramversion.Parse("/app/config") + + _, err := uc.Execute(context.Background(), param.DiffInput{ + Spec1: spec1, + Spec2: spec2, + }) + assert.Error(t, err) +} diff --git a/internal/usecase/param/list.go b/internal/usecase/param/list.go new file mode 100644 index 00000000..6525d757 --- /dev/null +++ b/internal/usecase/param/list.go @@ -0,0 +1,131 @@ +package param + +import ( + "context" + "fmt" + "regexp" + + "github.com/samber/lo" + + "github.com/mpyw/suve/internal/api/paramapi" + "github.com/mpyw/suve/internal/parallel" +) + +// ListClient is the interface for the list use case. +type ListClient interface { + paramapi.DescribeParametersAPI + paramapi.GetParameterAPI +} + +// ListInput holds input for the list use case. +type ListInput struct { + Prefix string + Recursive bool + Filter string // Regex filter pattern + WithValue bool // Include parameter values +} + +// ListEntry represents a single parameter in list output. +type ListEntry struct { + Name string + Value *string // nil when error or not requested + Error error +} + +// ListOutput holds the result of the list use case. +type ListOutput struct { + Entries []ListEntry +} + +// ListUseCase executes list operations. +type ListUseCase struct { + Client ListClient +} + +// Execute runs the list use case. +func (u *ListUseCase) Execute(ctx context.Context, input ListInput) (*ListOutput, error) { + // Compile regex filter if specified + var filterRegex *regexp.Regexp + if input.Filter != "" { + var err error + filterRegex, err = regexp.Compile(input.Filter) + if err != nil { + return nil, fmt.Errorf("invalid filter regex: %w", err) + } + } + + option := "OneLevel" + if input.Recursive { + option = "Recursive" + } + + apiInput := ¶mapi.DescribeParametersInput{} + if input.Prefix != "" { + apiInput.ParameterFilters = []paramapi.ParameterStringFilter{ + { + Key: lo.ToPtr("Path"), + Option: lo.ToPtr(option), + Values: []string{input.Prefix}, + }, + } + } + + // Collect all parameter names + var names []string + paginator := paramapi.NewDescribeParametersPaginator(u.Client, apiInput) + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return nil, fmt.Errorf("failed to describe parameters: %w", err) + } + + for _, param := range page.Parameters { + name := lo.FromPtr(param.Name) + if filterRegex != nil && !filterRegex.MatchString(name) { + continue + } + names = append(names, name) + } + } + + output := &ListOutput{} + + // If values are not requested, return names only + if !input.WithValue { + for _, name := range names { + output.Entries = append(output.Entries, ListEntry{Name: name}) + } + return output, nil + } + + // Fetch values in parallel + namesMap := make(map[string]struct{}, len(names)) + for _, name := range names { + namesMap[name] = struct{}{} + } + + results := parallel.ExecuteMap(ctx, namesMap, func(ctx context.Context, name string, _ struct{}) (string, error) { + out, err := u.Client.GetParameter(ctx, ¶mapi.GetParameterInput{ + Name: lo.ToPtr(name), + WithDecryption: lo.ToPtr(true), + }) + if err != nil { + return "", err + } + return lo.FromPtr(out.Parameter.Value), nil + }) + + // Collect results + for _, name := range names { + result := results[name] + entry := ListEntry{Name: name} + if result.Err != nil { + entry.Error = result.Err + } else { + entry.Value = lo.ToPtr(result.Value) + } + output.Entries = append(output.Entries, entry) + } + + return output, nil +} diff --git a/internal/usecase/param/list_test.go b/internal/usecase/param/list_test.go new file mode 100644 index 00000000..d3789992 --- /dev/null +++ b/internal/usecase/param/list_test.go @@ -0,0 +1,225 @@ +package param_test + +import ( + "context" + "errors" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mpyw/suve/internal/api/paramapi" + "github.com/mpyw/suve/internal/usecase/param" +) + +type mockListClient struct { + describeResult *paramapi.DescribeParametersOutput + describeErr error + getParameterValue map[string]string + getParameterErr map[string]error +} + +func (m *mockListClient) DescribeParameters(_ context.Context, _ *paramapi.DescribeParametersInput, _ ...func(*paramapi.Options)) (*paramapi.DescribeParametersOutput, error) { + if m.describeErr != nil { + return nil, m.describeErr + } + return m.describeResult, nil +} + +func (m *mockListClient) GetParameter(_ context.Context, input *paramapi.GetParameterInput, _ ...func(*paramapi.Options)) (*paramapi.GetParameterOutput, error) { + name := lo.FromPtr(input.Name) + if m.getParameterErr != nil { + if err, ok := m.getParameterErr[name]; ok { + return nil, err + } + } + if m.getParameterValue != nil { + if value, ok := m.getParameterValue[name]; ok { + return ¶mapi.GetParameterOutput{ + Parameter: ¶mapi.Parameter{Value: lo.ToPtr(value)}, + }, nil + } + } + return nil, ¶mapi.ParameterNotFound{Message: lo.ToPtr("not found")} +} + +func TestListUseCase_Execute_Empty(t *testing.T) { + t.Parallel() + + client := &mockListClient{ + describeResult: ¶mapi.DescribeParametersOutput{ + Parameters: []paramapi.ParameterMetadata{}, + }, + } + + uc := ¶m.ListUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.ListInput{}) + require.NoError(t, err) + assert.Empty(t, output.Entries) +} + +func TestListUseCase_Execute_WithPrefix(t *testing.T) { + t.Parallel() + + client := &mockListClient{ + describeResult: ¶mapi.DescribeParametersOutput{ + Parameters: []paramapi.ParameterMetadata{ + {Name: lo.ToPtr("/app/config")}, + {Name: lo.ToPtr("/app/secret")}, + }, + }, + } + + uc := ¶m.ListUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.ListInput{ + Prefix: "/app", + }) + require.NoError(t, err) + assert.Len(t, output.Entries, 2) +} + +func TestListUseCase_Execute_Recursive(t *testing.T) { + t.Parallel() + + client := &mockListClient{ + describeResult: ¶mapi.DescribeParametersOutput{ + Parameters: []paramapi.ParameterMetadata{ + {Name: lo.ToPtr("/app/config")}, + {Name: lo.ToPtr("/app/sub/nested")}, + }, + }, + } + + uc := ¶m.ListUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.ListInput{ + Prefix: "/app", + Recursive: true, + }) + require.NoError(t, err) + assert.Len(t, output.Entries, 2) +} + +func TestListUseCase_Execute_WithFilter(t *testing.T) { + t.Parallel() + + client := &mockListClient{ + describeResult: ¶mapi.DescribeParametersOutput{ + Parameters: []paramapi.ParameterMetadata{ + {Name: lo.ToPtr("/app/config")}, + {Name: lo.ToPtr("/app/secret")}, + {Name: lo.ToPtr("/app/other")}, + }, + }, + } + + uc := ¶m.ListUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.ListInput{ + Filter: "config|secret", + }) + require.NoError(t, err) + assert.Len(t, output.Entries, 2) +} + +func TestListUseCase_Execute_InvalidFilter(t *testing.T) { + t.Parallel() + + client := &mockListClient{ + describeResult: ¶mapi.DescribeParametersOutput{}, + } + + uc := ¶m.ListUseCase{Client: client} + + _, err := uc.Execute(context.Background(), param.ListInput{ + Filter: "[invalid", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid filter regex") +} + +func TestListUseCase_Execute_DescribeError(t *testing.T) { + t.Parallel() + + client := &mockListClient{ + describeErr: errors.New("aws error"), + } + + uc := ¶m.ListUseCase{Client: client} + + _, err := uc.Execute(context.Background(), param.ListInput{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to describe parameters") +} + +func TestListUseCase_Execute_WithValue(t *testing.T) { + t.Parallel() + + client := &mockListClient{ + describeResult: ¶mapi.DescribeParametersOutput{ + Parameters: []paramapi.ParameterMetadata{ + {Name: lo.ToPtr("/app/config")}, + {Name: lo.ToPtr("/app/secret")}, + }, + }, + getParameterValue: map[string]string{ + "/app/config": "config-value", + "/app/secret": "secret-value", + }, + } + + uc := ¶m.ListUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.ListInput{ + WithValue: true, + }) + require.NoError(t, err) + assert.Len(t, output.Entries, 2) + + for _, entry := range output.Entries { + assert.NotNil(t, entry.Value) + assert.Nil(t, entry.Error) + } +} + +func TestListUseCase_Execute_WithValue_PartialError(t *testing.T) { + t.Parallel() + + client := &mockListClient{ + describeResult: ¶mapi.DescribeParametersOutput{ + Parameters: []paramapi.ParameterMetadata{ + {Name: lo.ToPtr("/app/config")}, + {Name: lo.ToPtr("/app/error")}, + }, + }, + getParameterValue: map[string]string{ + "/app/config": "config-value", + }, + getParameterErr: map[string]error{ + "/app/error": errors.New("fetch error"), + }, + } + + uc := ¶m.ListUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.ListInput{ + WithValue: true, + }) + require.NoError(t, err) + assert.Len(t, output.Entries, 2) + + var hasValue, hasError bool + for _, entry := range output.Entries { + if entry.Value != nil { + hasValue = true + } + if entry.Error != nil { + hasError = true + } + } + assert.True(t, hasValue) + assert.True(t, hasError) +} diff --git a/internal/usecase/param/log.go b/internal/usecase/param/log.go new file mode 100644 index 00000000..1ed027a9 --- /dev/null +++ b/internal/usecase/param/log.go @@ -0,0 +1,105 @@ +package param + +import ( + "context" + "fmt" + "time" + + "github.com/samber/lo" + + "github.com/mpyw/suve/internal/api/paramapi" +) + +// LogClient is the interface for the log use case. +type LogClient interface { + paramapi.GetParameterHistoryAPI +} + +// LogInput holds input for the log use case. +type LogInput struct { + Name string + MaxResults int32 + Since *time.Time + Until *time.Time + Reverse bool // Reverse chronological order +} + +// LogEntry represents a single version entry. +type LogEntry struct { + Version int64 + Type paramapi.ParameterType + Value string + LastModified *time.Time + IsCurrent bool +} + +// LogOutput holds the result of the log use case. +type LogOutput struct { + Name string + Entries []LogEntry +} + +// LogUseCase executes log operations. +type LogUseCase struct { + Client LogClient +} + +// Execute runs the log use case. +func (u *LogUseCase) Execute(ctx context.Context, input LogInput) (*LogOutput, error) { + result, err := u.Client.GetParameterHistory(ctx, ¶mapi.GetParameterHistoryInput{ + Name: lo.ToPtr(input.Name), + WithDecryption: lo.ToPtr(true), + MaxResults: lo.ToPtr(input.MaxResults), + }) + if err != nil { + return nil, fmt.Errorf("failed to get parameter history: %w", err) + } + + params := result.Parameters + if len(params) == 0 { + return &LogOutput{Name: input.Name}, nil + } + + // Find max version + var maxVersion int64 + for _, p := range params { + if p.Version > maxVersion { + maxVersion = p.Version + } + } + + // Convert to entries and apply date filters + var entries []LogEntry + for _, history := range params { + // Apply date filters + if history.LastModifiedDate != nil { + if input.Since != nil && history.LastModifiedDate.Before(*input.Since) { + continue + } + if input.Until != nil && history.LastModifiedDate.After(*input.Until) { + continue + } + } + + entry := LogEntry{ + Version: history.Version, + Type: history.Type, + Value: lo.FromPtr(history.Value), + LastModified: history.LastModifiedDate, + IsCurrent: history.Version == maxVersion, + } + entries = append(entries, entry) + } + + // AWS returns oldest first; reverse to show newest first (unless --reverse) + if !input.Reverse { + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + } + + return &LogOutput{ + Name: input.Name, + Entries: entries, + }, nil +} diff --git a/internal/usecase/param/log_test.go b/internal/usecase/param/log_test.go new file mode 100644 index 00000000..c40f50f5 --- /dev/null +++ b/internal/usecase/param/log_test.go @@ -0,0 +1,259 @@ +package param_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mpyw/suve/internal/api/paramapi" + "github.com/mpyw/suve/internal/usecase/param" +) + +type mockLogClient struct { + getHistoryResult *paramapi.GetParameterHistoryOutput + getHistoryErr error +} + +func (m *mockLogClient) GetParameterHistory(_ context.Context, _ *paramapi.GetParameterHistoryInput, _ ...func(*paramapi.Options)) (*paramapi.GetParameterHistoryOutput, error) { + if m.getHistoryErr != nil { + return nil, m.getHistoryErr + } + return m.getHistoryResult, nil +} + +func TestLogUseCase_Execute(t *testing.T) { + t.Parallel() + + now := time.Now() + client := &mockLogClient{ + getHistoryResult: ¶mapi.GetParameterHistoryOutput{ + Parameters: []paramapi.ParameterHistory{ + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v1"), Version: 1, Type: paramapi.ParameterTypeString, LastModifiedDate: lo.ToPtr(now.Add(-2 * time.Hour))}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v2"), Version: 2, Type: paramapi.ParameterTypeString, LastModifiedDate: lo.ToPtr(now.Add(-1 * time.Hour))}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v3"), Version: 3, Type: paramapi.ParameterTypeString, LastModifiedDate: lo.ToPtr(now)}, + }, + }, + } + + uc := ¶m.LogUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.LogInput{ + Name: "/app/config", + }) + require.NoError(t, err) + assert.Equal(t, "/app/config", output.Name) + assert.Len(t, output.Entries, 3) + + // Newest first (default order) + assert.Equal(t, int64(3), output.Entries[0].Version) + assert.Equal(t, int64(2), output.Entries[1].Version) + assert.Equal(t, int64(1), output.Entries[2].Version) + + // IsCurrent flag + assert.True(t, output.Entries[0].IsCurrent) + assert.False(t, output.Entries[1].IsCurrent) + assert.False(t, output.Entries[2].IsCurrent) +} + +func TestLogUseCase_Execute_Empty(t *testing.T) { + t.Parallel() + + client := &mockLogClient{ + getHistoryResult: ¶mapi.GetParameterHistoryOutput{ + Parameters: []paramapi.ParameterHistory{}, + }, + } + + uc := ¶m.LogUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.LogInput{ + Name: "/app/config", + }) + require.NoError(t, err) + assert.Equal(t, "/app/config", output.Name) + assert.Empty(t, output.Entries) +} + +func TestLogUseCase_Execute_Error(t *testing.T) { + t.Parallel() + + client := &mockLogClient{ + getHistoryErr: errors.New("aws error"), + } + + uc := ¶m.LogUseCase{Client: client} + + _, err := uc.Execute(context.Background(), param.LogInput{ + Name: "/app/config", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get parameter history") +} + +func TestLogUseCase_Execute_Reverse(t *testing.T) { + t.Parallel() + + now := time.Now() + client := &mockLogClient{ + getHistoryResult: ¶mapi.GetParameterHistoryOutput{ + Parameters: []paramapi.ParameterHistory{ + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v1"), Version: 1, LastModifiedDate: lo.ToPtr(now.Add(-2 * time.Hour))}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v2"), Version: 2, LastModifiedDate: lo.ToPtr(now.Add(-1 * time.Hour))}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v3"), Version: 3, LastModifiedDate: lo.ToPtr(now)}, + }, + }, + } + + uc := ¶m.LogUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.LogInput{ + Name: "/app/config", + Reverse: true, + }) + require.NoError(t, err) + + // Oldest first when Reverse is true (keeps AWS order) + assert.Equal(t, int64(1), output.Entries[0].Version) + assert.Equal(t, int64(2), output.Entries[1].Version) + assert.Equal(t, int64(3), output.Entries[2].Version) +} + +func TestLogUseCase_Execute_SinceFilter(t *testing.T) { + t.Parallel() + + now := time.Now() + client := &mockLogClient{ + getHistoryResult: ¶mapi.GetParameterHistoryOutput{ + Parameters: []paramapi.ParameterHistory{ + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v1"), Version: 1, LastModifiedDate: lo.ToPtr(now.Add(-3 * time.Hour))}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v2"), Version: 2, LastModifiedDate: lo.ToPtr(now.Add(-1 * time.Hour))}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v3"), Version: 3, LastModifiedDate: lo.ToPtr(now)}, + }, + }, + } + + uc := ¶m.LogUseCase{Client: client} + + since := now.Add(-2 * time.Hour) + output, err := uc.Execute(context.Background(), param.LogInput{ + Name: "/app/config", + Since: &since, + }) + require.NoError(t, err) + + // v1 is before the since filter, so only v2 and v3 should be included + assert.Len(t, output.Entries, 2) + assert.Equal(t, int64(3), output.Entries[0].Version) + assert.Equal(t, int64(2), output.Entries[1].Version) +} + +func TestLogUseCase_Execute_UntilFilter(t *testing.T) { + t.Parallel() + + now := time.Now() + client := &mockLogClient{ + getHistoryResult: ¶mapi.GetParameterHistoryOutput{ + Parameters: []paramapi.ParameterHistory{ + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v1"), Version: 1, LastModifiedDate: lo.ToPtr(now.Add(-3 * time.Hour))}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v2"), Version: 2, LastModifiedDate: lo.ToPtr(now.Add(-1 * time.Hour))}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v3"), Version: 3, LastModifiedDate: lo.ToPtr(now)}, + }, + }, + } + + uc := ¶m.LogUseCase{Client: client} + + until := now.Add(-30 * time.Minute) + output, err := uc.Execute(context.Background(), param.LogInput{ + Name: "/app/config", + Until: &until, + }) + require.NoError(t, err) + + // v3 is after the until filter, so only v1 and v2 should be included + assert.Len(t, output.Entries, 2) + assert.Equal(t, int64(2), output.Entries[0].Version) + assert.Equal(t, int64(1), output.Entries[1].Version) +} + +func TestLogUseCase_Execute_DateRangeFilter(t *testing.T) { + t.Parallel() + + now := time.Now() + client := &mockLogClient{ + getHistoryResult: ¶mapi.GetParameterHistoryOutput{ + Parameters: []paramapi.ParameterHistory{ + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v1"), Version: 1, LastModifiedDate: lo.ToPtr(now.Add(-4 * time.Hour))}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v2"), Version: 2, LastModifiedDate: lo.ToPtr(now.Add(-2 * time.Hour))}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v3"), Version: 3, LastModifiedDate: lo.ToPtr(now)}, + }, + }, + } + + uc := ¶m.LogUseCase{Client: client} + + since := now.Add(-3 * time.Hour) + until := now.Add(-1 * time.Hour) + output, err := uc.Execute(context.Background(), param.LogInput{ + Name: "/app/config", + Since: &since, + Until: &until, + }) + require.NoError(t, err) + + // Only v2 should be within the range + assert.Len(t, output.Entries, 1) + assert.Equal(t, int64(2), output.Entries[0].Version) +} + +func TestLogUseCase_Execute_NoLastModifiedDate(t *testing.T) { + t.Parallel() + + client := &mockLogClient{ + getHistoryResult: ¶mapi.GetParameterHistoryOutput{ + Parameters: []paramapi.ParameterHistory{ + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v1"), Version: 1, LastModifiedDate: nil}, + }, + }, + } + + uc := ¶m.LogUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.LogInput{ + Name: "/app/config", + }) + require.NoError(t, err) + assert.Len(t, output.Entries, 1) + assert.Nil(t, output.Entries[0].LastModified) +} + +func TestLogUseCase_Execute_FilterWithNilLastModifiedDate(t *testing.T) { + t.Parallel() + + now := time.Now() + client := &mockLogClient{ + getHistoryResult: ¶mapi.GetParameterHistoryOutput{ + Parameters: []paramapi.ParameterHistory{ + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v1"), Version: 1, LastModifiedDate: nil}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v2"), Version: 2, LastModifiedDate: lo.ToPtr(now)}, + }, + }, + } + + uc := ¶m.LogUseCase{Client: client} + + since := now.Add(-1 * time.Hour) + output, err := uc.Execute(context.Background(), param.LogInput{ + Name: "/app/config", + Since: &since, + }) + require.NoError(t, err) + + // v1 has nil LastModifiedDate, so filter is skipped; v2 is after since + assert.Len(t, output.Entries, 2) +} diff --git a/internal/usecase/param/set.go b/internal/usecase/param/set.go new file mode 100644 index 00000000..3090cf20 --- /dev/null +++ b/internal/usecase/param/set.go @@ -0,0 +1,121 @@ +package param + +import ( + "context" + "errors" + "fmt" + + "github.com/samber/lo" + + "github.com/mpyw/suve/internal/api/paramapi" + "github.com/mpyw/suve/internal/tagging" +) + +// SetClient is the interface for the set use case. +type SetClient interface { + paramapi.GetParameterAPI + paramapi.PutParameterAPI + paramapi.AddTagsToResourceAPI + paramapi.RemoveTagsFromResourceAPI +} + +// SetInput holds input for the set use case. +type SetInput struct { + Name string + Value string + Type paramapi.ParameterType + Description string + TagChange *tagging.Change +} + +// SetOutput holds the result of the set use case. +type SetOutput struct { + Name string + Version int64 + IsCreated bool // true if created, false if updated +} + +// SetUseCase executes set operations. +type SetUseCase struct { + Client SetClient +} + +// Exists checks if a parameter exists. +func (u *SetUseCase) Exists(ctx context.Context, name string) (bool, error) { + _, err := u.Client.GetParameter(ctx, ¶mapi.GetParameterInput{ + Name: lo.ToPtr(name), + }) + if err != nil { + var pnf *paramapi.ParameterNotFound + if errors.As(err, &pnf) { + return false, nil + } + return false, err + } + return true, nil +} + +// Execute runs the set use case. +func (u *SetUseCase) Execute(ctx context.Context, input SetInput) (*SetOutput, error) { + // Check if parameter exists (for determining create vs update) + exists, err := u.Exists(ctx, input.Name) + if err != nil { + return nil, err + } + + // Put parameter + putInput := ¶mapi.PutParameterInput{ + Name: lo.ToPtr(input.Name), + Value: lo.ToPtr(input.Value), + Type: input.Type, + Overwrite: lo.ToPtr(true), + } + if input.Description != "" { + putInput.Description = lo.ToPtr(input.Description) + } + + putOutput, err := u.Client.PutParameter(ctx, putInput) + if err != nil { + return nil, fmt.Errorf("failed to put parameter: %w", err) + } + + // Handle tagging + if input.TagChange != nil && !input.TagChange.IsEmpty() { + // Add tags + if len(input.TagChange.Add) > 0 { + tags := make([]paramapi.Tag, 0, len(input.TagChange.Add)) + for k, v := range input.TagChange.Add { + tags = append(tags, paramapi.Tag{ + Key: lo.ToPtr(k), + Value: lo.ToPtr(v), + }) + } + _, err := u.Client.AddTagsToResource(ctx, ¶mapi.AddTagsToResourceInput{ + ResourceId: lo.ToPtr(input.Name), + ResourceType: paramapi.ResourceTypeForTaggingParameter, + Tags: tags, + }) + if err != nil { + return nil, fmt.Errorf("failed to add tags: %w", err) + } + } + + // Remove tags + if len(input.TagChange.Remove) > 0 { + _, err := u.Client.RemoveTagsFromResource(ctx, ¶mapi.RemoveTagsFromResourceInput{ + ResourceId: lo.ToPtr(input.Name), + ResourceType: paramapi.ResourceTypeForTaggingParameter, + TagKeys: input.TagChange.Remove, + }) + if err != nil { + return nil, fmt.Errorf("failed to remove tags: %w", err) + } + } + } + + return &SetOutput{ + Name: input.Name, + Version: putOutput.Version, + IsCreated: !exists, + }, nil +} diff --git a/internal/usecase/param/set_test.go b/internal/usecase/param/set_test.go new file mode 100644 index 00000000..21e68bbd --- /dev/null +++ b/internal/usecase/param/set_test.go @@ -0,0 +1,260 @@ +package param_test + +import ( + "context" + "errors" + "testing" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mpyw/suve/internal/api/paramapi" + "github.com/mpyw/suve/internal/tagging" + "github.com/mpyw/suve/internal/usecase/param" +) + +type mockSetClient struct { + getParameterResult *paramapi.GetParameterOutput + getParameterErr error + putParameterResult *paramapi.PutParameterOutput + putParameterErr error + addTagsErr error + removeTagsErr error +} + +func (m *mockSetClient) GetParameter(_ context.Context, _ *paramapi.GetParameterInput, _ ...func(*paramapi.Options)) (*paramapi.GetParameterOutput, error) { + if m.getParameterErr != nil { + return nil, m.getParameterErr + } + return m.getParameterResult, nil +} + +func (m *mockSetClient) PutParameter(_ context.Context, _ *paramapi.PutParameterInput, _ ...func(*paramapi.Options)) (*paramapi.PutParameterOutput, error) { + if m.putParameterErr != nil { + return nil, m.putParameterErr + } + return m.putParameterResult, nil +} + +func (m *mockSetClient) AddTagsToResource(_ context.Context, _ *paramapi.AddTagsToResourceInput, _ ...func(*paramapi.Options)) (*paramapi.AddTagsToResourceOutput, error) { + if m.addTagsErr != nil { + return nil, m.addTagsErr + } + return ¶mapi.AddTagsToResourceOutput{}, nil +} + +func (m *mockSetClient) RemoveTagsFromResource(_ context.Context, _ *paramapi.RemoveTagsFromResourceInput, _ ...func(*paramapi.Options)) (*paramapi.RemoveTagsFromResourceOutput, error) { + if m.removeTagsErr != nil { + return nil, m.removeTagsErr + } + return ¶mapi.RemoveTagsFromResourceOutput{}, nil +} + +func TestSetUseCase_Exists(t *testing.T) { + t.Parallel() + + client := &mockSetClient{ + getParameterResult: ¶mapi.GetParameterOutput{ + Parameter: ¶mapi.Parameter{Name: lo.ToPtr("/app/config")}, + }, + } + + uc := ¶m.SetUseCase{Client: client} + + exists, err := uc.Exists(context.Background(), "/app/config") + require.NoError(t, err) + assert.True(t, exists) +} + +func TestSetUseCase_Exists_NotFound(t *testing.T) { + t.Parallel() + + client := &mockSetClient{ + getParameterErr: ¶mapi.ParameterNotFound{Message: lo.ToPtr("not found")}, + } + + uc := ¶m.SetUseCase{Client: client} + + exists, err := uc.Exists(context.Background(), "/app/not-exists") + require.NoError(t, err) + assert.False(t, exists) +} + +func TestSetUseCase_Exists_Error(t *testing.T) { + t.Parallel() + + client := &mockSetClient{ + getParameterErr: errors.New("aws error"), + } + + uc := ¶m.SetUseCase{Client: client} + + _, err := uc.Exists(context.Background(), "/app/config") + assert.Error(t, err) +} + +func TestSetUseCase_Execute_Create(t *testing.T) { + t.Parallel() + + client := &mockSetClient{ + getParameterErr: ¶mapi.ParameterNotFound{Message: lo.ToPtr("not found")}, + putParameterResult: ¶mapi.PutParameterOutput{Version: 1}, + } + + uc := ¶m.SetUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.SetInput{ + Name: "/app/new", + Value: "new-value", + Type: paramapi.ParameterTypeString, + }) + require.NoError(t, err) + assert.Equal(t, "/app/new", output.Name) + assert.Equal(t, int64(1), output.Version) + assert.True(t, output.IsCreated) +} + +func TestSetUseCase_Execute_Update(t *testing.T) { + t.Parallel() + + client := &mockSetClient{ + getParameterResult: ¶mapi.GetParameterOutput{ + Parameter: ¶mapi.Parameter{Name: lo.ToPtr("/app/config")}, + }, + putParameterResult: ¶mapi.PutParameterOutput{Version: 5}, + } + + uc := ¶m.SetUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.SetInput{ + Name: "/app/config", + Value: "updated-value", + Type: paramapi.ParameterTypeString, + Description: "updated description", + }) + require.NoError(t, err) + assert.Equal(t, "/app/config", output.Name) + assert.Equal(t, int64(5), output.Version) + assert.False(t, output.IsCreated) +} + +func TestSetUseCase_Execute_ExistsError(t *testing.T) { + t.Parallel() + + client := &mockSetClient{ + getParameterErr: errors.New("aws error"), + } + + uc := ¶m.SetUseCase{Client: client} + + _, err := uc.Execute(context.Background(), param.SetInput{ + Name: "/app/config", + Value: "value", + }) + assert.Error(t, err) +} + +func TestSetUseCase_Execute_PutError(t *testing.T) { + t.Parallel() + + client := &mockSetClient{ + getParameterErr: ¶mapi.ParameterNotFound{Message: lo.ToPtr("not found")}, + putParameterErr: errors.New("put failed"), + } + + uc := ¶m.SetUseCase{Client: client} + + _, err := uc.Execute(context.Background(), param.SetInput{ + Name: "/app/config", + Value: "value", + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to put parameter") +} + +func TestSetUseCase_Execute_WithTags(t *testing.T) { + t.Parallel() + + client := &mockSetClient{ + getParameterErr: ¶mapi.ParameterNotFound{Message: lo.ToPtr("not found")}, + putParameterResult: ¶mapi.PutParameterOutput{Version: 1}, + } + + uc := ¶m.SetUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.SetInput{ + Name: "/app/new", + Value: "value", + Type: paramapi.ParameterTypeString, + TagChange: &tagging.Change{ + Add: map[string]string{"env": "prod"}, + Remove: []string{"old-tag"}, + }, + }) + require.NoError(t, err) + assert.Equal(t, "/app/new", output.Name) +} + +func TestSetUseCase_Execute_AddTagsError(t *testing.T) { + t.Parallel() + + client := &mockSetClient{ + getParameterErr: ¶mapi.ParameterNotFound{Message: lo.ToPtr("not found")}, + putParameterResult: ¶mapi.PutParameterOutput{Version: 1}, + addTagsErr: errors.New("add tags failed"), + } + + uc := ¶m.SetUseCase{Client: client} + + _, err := uc.Execute(context.Background(), param.SetInput{ + Name: "/app/new", + Value: "value", + TagChange: &tagging.Change{ + Add: map[string]string{"env": "prod"}, + }, + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to add tags") +} + +func TestSetUseCase_Execute_RemoveTagsError(t *testing.T) { + t.Parallel() + + client := &mockSetClient{ + getParameterErr: ¶mapi.ParameterNotFound{Message: lo.ToPtr("not found")}, + putParameterResult: ¶mapi.PutParameterOutput{Version: 1}, + removeTagsErr: errors.New("remove tags failed"), + } + + uc := ¶m.SetUseCase{Client: client} + + _, err := uc.Execute(context.Background(), param.SetInput{ + Name: "/app/new", + Value: "value", + TagChange: &tagging.Change{ + Remove: []string{"old-tag"}, + }, + }) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to remove tags") +} + +func TestSetUseCase_Execute_EmptyTagChange(t *testing.T) { + t.Parallel() + + client := &mockSetClient{ + getParameterErr: ¶mapi.ParameterNotFound{Message: lo.ToPtr("not found")}, + putParameterResult: ¶mapi.PutParameterOutput{Version: 1}, + } + + uc := ¶m.SetUseCase{Client: client} + + output, err := uc.Execute(context.Background(), param.SetInput{ + Name: "/app/new", + Value: "value", + TagChange: &tagging.Change{}, + }) + require.NoError(t, err) + assert.Equal(t, "/app/new", output.Name) +} diff --git a/internal/usecase/param/show.go b/internal/usecase/param/show.go new file mode 100644 index 00000000..13a04af6 --- /dev/null +++ b/internal/usecase/param/show.go @@ -0,0 +1,58 @@ +// Package param provides use cases for SSM Parameter Store operations. +package param + +import ( + "context" + "time" + + "github.com/samber/lo" + + "github.com/mpyw/suve/internal/api/paramapi" + "github.com/mpyw/suve/internal/version/paramversion" +) + +// ShowClient is the interface for the show use case. +type ShowClient interface { + paramapi.GetParameterAPI + paramapi.GetParameterHistoryAPI +} + +// ShowInput holds input for the show use case. +type ShowInput struct { + Spec *paramversion.Spec + Decrypt bool +} + +// ShowOutput holds the result of the show use case. +type ShowOutput struct { + Name string + Value string + Version int64 + Type paramapi.ParameterType + LastModified *time.Time +} + +// ShowUseCase executes show operations. +type ShowUseCase struct { + Client ShowClient +} + +// Execute runs the show use case. +func (u *ShowUseCase) Execute(ctx context.Context, input ShowInput) (*ShowOutput, error) { + param, err := paramversion.GetParameterWithVersion(ctx, u.Client, input.Spec, input.Decrypt) + if err != nil { + return nil, err + } + + output := &ShowOutput{ + Name: lo.FromPtr(param.Name), + Value: lo.FromPtr(param.Value), + Version: param.Version, + Type: param.Type, + } + if param.LastModifiedDate != nil { + output.LastModified = param.LastModifiedDate + } + + return output, nil +} diff --git a/internal/usecase/param/show_test.go b/internal/usecase/param/show_test.go new file mode 100644 index 00000000..72bad7df --- /dev/null +++ b/internal/usecase/param/show_test.go @@ -0,0 +1,178 @@ +package param_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mpyw/suve/internal/api/paramapi" + "github.com/mpyw/suve/internal/usecase/param" + "github.com/mpyw/suve/internal/version/paramversion" +) + +type mockShowClient struct { + getParameterResult *paramapi.GetParameterOutput + getParameterErr error + getHistoryResult *paramapi.GetParameterHistoryOutput + getHistoryErr error +} + +func (m *mockShowClient) GetParameter(_ context.Context, _ *paramapi.GetParameterInput, _ ...func(*paramapi.Options)) (*paramapi.GetParameterOutput, error) { + if m.getParameterErr != nil { + return nil, m.getParameterErr + } + return m.getParameterResult, nil +} + +func (m *mockShowClient) GetParameterHistory(_ context.Context, _ *paramapi.GetParameterHistoryInput, _ ...func(*paramapi.Options)) (*paramapi.GetParameterHistoryOutput, error) { + if m.getHistoryErr != nil { + return nil, m.getHistoryErr + } + if m.getHistoryResult == nil { + return ¶mapi.GetParameterHistoryOutput{}, nil + } + return m.getHistoryResult, nil +} + +func TestShowUseCase_Execute(t *testing.T) { + t.Parallel() + + now := time.Now() + client := &mockShowClient{ + getParameterResult: ¶mapi.GetParameterOutput{ + Parameter: ¶mapi.Parameter{ + Name: lo.ToPtr("/app/config"), + Value: lo.ToPtr("secret-value"), + Version: 5, + Type: paramapi.ParameterTypeSecureString, + LastModifiedDate: &now, + }, + }, + } + + uc := ¶m.ShowUseCase{Client: client} + + spec, err := paramversion.Parse("/app/config") + require.NoError(t, err) + + output, err := uc.Execute(context.Background(), param.ShowInput{ + Spec: spec, + Decrypt: true, + }) + require.NoError(t, err) + assert.Equal(t, "/app/config", output.Name) + assert.Equal(t, "secret-value", output.Value) + assert.Equal(t, int64(5), output.Version) + assert.Equal(t, paramapi.ParameterTypeSecureString, output.Type) + assert.NotNil(t, output.LastModified) +} + +func TestShowUseCase_Execute_WithVersion(t *testing.T) { + t.Parallel() + + // #VERSION spec without shift uses GetParameter (SSM supports name:version format) + client := &mockShowClient{ + getParameterResult: ¶mapi.GetParameterOutput{ + Parameter: ¶mapi.Parameter{ + Name: lo.ToPtr("/app/config"), + Value: lo.ToPtr("old-value"), + Version: 3, + Type: paramapi.ParameterTypeString, + }, + }, + } + + uc := ¶m.ShowUseCase{Client: client} + + spec, err := paramversion.Parse("/app/config#3") + require.NoError(t, err) + + output, err := uc.Execute(context.Background(), param.ShowInput{ + Spec: spec, + Decrypt: true, + }) + require.NoError(t, err) + assert.Equal(t, "/app/config", output.Name) + assert.Equal(t, "old-value", output.Value) + assert.Equal(t, int64(3), output.Version) +} + +func TestShowUseCase_Execute_WithShift(t *testing.T) { + t.Parallel() + + // Spec with shift uses GetParameterHistory + client := &mockShowClient{ + getHistoryResult: ¶mapi.GetParameterHistoryOutput{ + Parameters: []paramapi.ParameterHistory{ + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v1"), Version: 1, Type: paramapi.ParameterTypeString}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v2"), Version: 2, Type: paramapi.ParameterTypeString}, + {Name: lo.ToPtr("/app/config"), Value: lo.ToPtr("v3"), Version: 3, Type: paramapi.ParameterTypeString}, + }, + }, + } + + uc := ¶m.ShowUseCase{Client: client} + + spec, err := paramversion.Parse("/app/config~1") // 1 version back from latest + require.NoError(t, err) + + output, err := uc.Execute(context.Background(), param.ShowInput{ + Spec: spec, + Decrypt: true, + }) + require.NoError(t, err) + assert.Equal(t, "/app/config", output.Name) + assert.Equal(t, "v2", output.Value) // v3 - 1 = v2 + assert.Equal(t, int64(2), output.Version) +} + +func TestShowUseCase_Execute_Error(t *testing.T) { + t.Parallel() + + client := &mockShowClient{ + getParameterErr: errors.New("aws error"), + } + + uc := ¶m.ShowUseCase{Client: client} + + spec, err := paramversion.Parse("/app/config") + require.NoError(t, err) + + _, err = uc.Execute(context.Background(), param.ShowInput{ + Spec: spec, + Decrypt: true, + }) + assert.Error(t, err) +} + +func TestShowUseCase_Execute_NoLastModified(t *testing.T) { + t.Parallel() + + client := &mockShowClient{ + getParameterResult: ¶mapi.GetParameterOutput{ + Parameter: ¶mapi.Parameter{ + Name: lo.ToPtr("/app/config"), + Value: lo.ToPtr("value"), + Version: 1, + Type: paramapi.ParameterTypeString, + }, + }, + } + + uc := ¶m.ShowUseCase{Client: client} + + spec, err := paramversion.Parse("/app/config") + require.NoError(t, err) + + output, err := uc.Execute(context.Background(), param.ShowInput{ + Spec: spec, + Decrypt: true, + }) + require.NoError(t, err) + assert.Nil(t, output.LastModified) +}