From cb7b535b917ffddc38f12069b32fbce5a4034eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 22 Sep 2021 16:11:34 +0200 Subject: [PATCH] Add tests for delete --- cmd/ghcs/delete.go | 10 ++- cmd/ghcs/delete_test.go | 169 +++++++++++++++++++++++++++++++++-- cmd/ghcs/mock_api.go | 180 ++++++++++++++++++++++++++++++++++++++ cmd/ghcs/mock_prompter.go | 73 ++++++++++++++++ 4 files changed, 425 insertions(+), 7 deletions(-) create mode 100644 cmd/ghcs/mock_api.go create mode 100644 cmd/ghcs/mock_prompter.go diff --git a/cmd/ghcs/delete.go b/cmd/ghcs/delete.go index c08344163..3408f08d7 100644 --- a/cmd/ghcs/delete.go +++ b/cmd/ghcs/delete.go @@ -27,10 +27,12 @@ type deleteOptions struct { prompter prompter } +//go:generate moq -fmt goimports -rm -out mock_prompter.go . prompter type prompter interface { Confirm(message string) (bool, error) } +//go:generate moq -fmt goimports -rm -out mock_api.go . apiClient type apiClient interface { GetUser(ctx context.Context) (*api.User, error) ListCodespaces(ctx context.Context, user string) ([]*api.Codespace, error) @@ -57,9 +59,9 @@ func newDeleteCmd() *cobra.Command { }, } - deleteCmd.Flags().StringVarP(&opts.codespaceName, "codespace", "c", "", "Delete codespace by `name`") + deleteCmd.Flags().StringVarP(&opts.codespaceName, "codespace", "c", "", "The `name` of the codespace to delete") deleteCmd.Flags().BoolVar(&opts.deleteAll, "all", false, "Delete all codespaces") - deleteCmd.Flags().StringVarP(&opts.repoFilter, "repo", "r", "", "Delete codespaces for a repository") + deleteCmd.Flags().StringVarP(&opts.repoFilter, "repo", "r", "", "Delete codespaces for a `repository`") deleteCmd.Flags().BoolVarP(&opts.skipConfirm, "force", "f", false, "Skip confirmation for codespaces that contain unsaved changes") deleteCmd.Flags().Uint16Var(&opts.keepDays, "days", 0, "Delete codespaces older than `N` days") @@ -116,6 +118,10 @@ func delete(ctx context.Context, opts deleteOptions) error { codespacesToDelete = append(codespacesToDelete, c) } + if len(codespacesToDelete) == 0 { + return errors.New("no codespaces to delete") + } + g := errgroup.Group{} for _, c := range codespacesToDelete { codespaceName := c.Name diff --git a/cmd/ghcs/delete_test.go b/cmd/ghcs/delete_test.go index 783ad80e6..754254494 100644 --- a/cmd/ghcs/delete_test.go +++ b/cmd/ghcs/delete_test.go @@ -2,28 +2,187 @@ package ghcs import ( "context" + "fmt" + "sort" "testing" + "time" + + "github.com/github/ghcs/internal/api" ) func TestDelete(t *testing.T) { + user := &api.User{Login: "hubot"} + now, _ := time.Parse(time.RFC3339, "2021-09-22T00:00:00Z") + daysAgo := func(n int) string { + return now.Add(time.Hour * -time.Duration(24*n)).Format(time.RFC3339) + } + tests := []struct { - name string - opts deleteOptions - wantErr bool + name string + opts deleteOptions + codespaces []*api.Codespace + confirms map[string]bool + wantErr bool + wantDeleted []string }{ { name: "by name", opts: deleteOptions{ - codespaceName: "foo-bar-123", + codespaceName: "hubot-robawt-abc", }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + }, + { + Name: "hubot-robawt-abc", + }, + }, + wantDeleted: []string{"hubot-robawt-abc"}, + }, + { + name: "by repo", + opts: deleteOptions{ + repoFilter: "monalisa/spoon-knife", + }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + RepositoryNWO: "monalisa/Spoon-Knife", + }, + { + Name: "hubot-robawt-abc", + RepositoryNWO: "hubot/ROBAWT", + }, + { + Name: "monalisa-spoonknife-c4f3", + RepositoryNWO: "monalisa/Spoon-Knife", + }, + }, + wantDeleted: []string{"monalisa-spoonknife-123", "monalisa-spoonknife-c4f3"}, + }, + { + name: "unused", + opts: deleteOptions{ + deleteAll: true, + keepDays: 3, + }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + LastUsedAt: daysAgo(1), + }, + { + Name: "hubot-robawt-abc", + LastUsedAt: daysAgo(4), + }, + { + Name: "monalisa-spoonknife-c4f3", + LastUsedAt: daysAgo(10), + }, + }, + wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"}, + }, + { + name: "with confirm", + opts: deleteOptions{ + isInteractive: true, + deleteAll: true, + skipConfirm: false, + }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + Environment: api.CodespaceEnvironment{ + GitStatus: api.CodespaceEnvironmentGitStatus{ + HasUnpushedChanges: true, + }, + }, + }, + { + Name: "hubot-robawt-abc", + Environment: api.CodespaceEnvironment{ + GitStatus: api.CodespaceEnvironmentGitStatus{ + HasUncommitedChanges: true, + }, + }, + }, + { + Name: "monalisa-spoonknife-c4f3", + Environment: api.CodespaceEnvironment{ + GitStatus: api.CodespaceEnvironmentGitStatus{ + HasUnpushedChanges: false, + HasUncommitedChanges: false, + }, + }, + }, + }, + confirms: map[string]bool{ + "Codespace monalisa-spoonknife-123 has unsaved changes. OK to delete?": false, + "Codespace hubot-robawt-abc has unsaved changes. OK to delete?": true, + }, + wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := delete(context.Background(), tt.opts) + apiMock := &apiClientMock{ + GetUserFunc: func(_ context.Context) (*api.User, error) { + return user, nil + }, + ListCodespacesFunc: func(_ context.Context, userLogin string) ([]*api.Codespace, error) { + if userLogin != user.Login { + return nil, fmt.Errorf("unexpected user %q", userLogin) + } + return tt.codespaces, nil + }, + DeleteCodespaceFunc: func(_ context.Context, userLogin, name string) error { + if userLogin != user.Login { + return fmt.Errorf("unexpected user %q", userLogin) + } + return nil + }, + } + opts := tt.opts + opts.apiClient = apiMock + opts.now = func() time.Time { return now } + opts.prompter = &prompterMock{ + ConfirmFunc: func(msg string) (bool, error) { + res, found := tt.confirms[msg] + if !found { + return false, fmt.Errorf("unexpected prompt %q", msg) + } + return res, nil + }, + } + + err := delete(context.Background(), opts) if (err != nil) != tt.wantErr { t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr) } + if n := len(apiMock.GetUserCalls()); n != 1 { + t.Errorf("GetUser invoked %d times, expected %d", n, 1) + } + var gotDeleted []string + for _, delArgs := range apiMock.DeleteCodespaceCalls() { + gotDeleted = append(gotDeleted, delArgs.Name) + } + sort.Strings(gotDeleted) + if !sliceEquals(gotDeleted, tt.wantDeleted) { + t.Errorf("deleted %q, want %q", gotDeleted, tt.wantDeleted) + } }) } } + +func sliceEquals(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/cmd/ghcs/mock_api.go b/cmd/ghcs/mock_api.go new file mode 100644 index 000000000..46edd2835 --- /dev/null +++ b/cmd/ghcs/mock_api.go @@ -0,0 +1,180 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package ghcs + +import ( + "context" + "sync" + + "github.com/github/ghcs/internal/api" +) + +// Ensure, that apiClientMock does implement apiClient. +// If this is not the case, regenerate this file with moq. +var _ apiClient = &apiClientMock{} + +// apiClientMock is a mock implementation of apiClient. +// +// func TestSomethingThatUsesapiClient(t *testing.T) { +// +// // make and configure a mocked apiClient +// mockedapiClient := &apiClientMock{ +// DeleteCodespaceFunc: func(ctx context.Context, user string, name string) error { +// panic("mock out the DeleteCodespace method") +// }, +// GetUserFunc: func(ctx context.Context) (*api.User, error) { +// panic("mock out the GetUser method") +// }, +// ListCodespacesFunc: func(ctx context.Context, user string) ([]*api.Codespace, error) { +// panic("mock out the ListCodespaces method") +// }, +// } +// +// // use mockedapiClient in code that requires apiClient +// // and then make assertions. +// +// } +type apiClientMock struct { + // DeleteCodespaceFunc mocks the DeleteCodespace method. + DeleteCodespaceFunc func(ctx context.Context, user string, name string) error + + // GetUserFunc mocks the GetUser method. + GetUserFunc func(ctx context.Context) (*api.User, error) + + // ListCodespacesFunc mocks the ListCodespaces method. + ListCodespacesFunc func(ctx context.Context, user string) ([]*api.Codespace, error) + + // calls tracks calls to the methods. + calls struct { + // DeleteCodespace holds details about calls to the DeleteCodespace method. + DeleteCodespace []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // User is the user argument value. + User string + // Name is the name argument value. + Name string + } + // GetUser holds details about calls to the GetUser method. + GetUser []struct { + // Ctx is the ctx argument value. + Ctx context.Context + } + // ListCodespaces holds details about calls to the ListCodespaces method. + ListCodespaces []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // User is the user argument value. + User string + } + } + lockDeleteCodespace sync.RWMutex + lockGetUser sync.RWMutex + lockListCodespaces sync.RWMutex +} + +// DeleteCodespace calls DeleteCodespaceFunc. +func (mock *apiClientMock) DeleteCodespace(ctx context.Context, user string, name string) error { + if mock.DeleteCodespaceFunc == nil { + panic("apiClientMock.DeleteCodespaceFunc: method is nil but apiClient.DeleteCodespace was just called") + } + callInfo := struct { + Ctx context.Context + User string + Name string + }{ + Ctx: ctx, + User: user, + Name: name, + } + mock.lockDeleteCodespace.Lock() + mock.calls.DeleteCodespace = append(mock.calls.DeleteCodespace, callInfo) + mock.lockDeleteCodespace.Unlock() + return mock.DeleteCodespaceFunc(ctx, user, name) +} + +// DeleteCodespaceCalls gets all the calls that were made to DeleteCodespace. +// Check the length with: +// len(mockedapiClient.DeleteCodespaceCalls()) +func (mock *apiClientMock) DeleteCodespaceCalls() []struct { + Ctx context.Context + User string + Name string +} { + var calls []struct { + Ctx context.Context + User string + Name string + } + mock.lockDeleteCodespace.RLock() + calls = mock.calls.DeleteCodespace + mock.lockDeleteCodespace.RUnlock() + return calls +} + +// GetUser calls GetUserFunc. +func (mock *apiClientMock) GetUser(ctx context.Context) (*api.User, error) { + if mock.GetUserFunc == nil { + panic("apiClientMock.GetUserFunc: method is nil but apiClient.GetUser was just called") + } + callInfo := struct { + Ctx context.Context + }{ + Ctx: ctx, + } + mock.lockGetUser.Lock() + mock.calls.GetUser = append(mock.calls.GetUser, callInfo) + mock.lockGetUser.Unlock() + return mock.GetUserFunc(ctx) +} + +// GetUserCalls gets all the calls that were made to GetUser. +// Check the length with: +// len(mockedapiClient.GetUserCalls()) +func (mock *apiClientMock) GetUserCalls() []struct { + Ctx context.Context +} { + var calls []struct { + Ctx context.Context + } + mock.lockGetUser.RLock() + calls = mock.calls.GetUser + mock.lockGetUser.RUnlock() + return calls +} + +// ListCodespaces calls ListCodespacesFunc. +func (mock *apiClientMock) ListCodespaces(ctx context.Context, user string) ([]*api.Codespace, error) { + if mock.ListCodespacesFunc == nil { + panic("apiClientMock.ListCodespacesFunc: method is nil but apiClient.ListCodespaces was just called") + } + callInfo := struct { + Ctx context.Context + User string + }{ + Ctx: ctx, + User: user, + } + mock.lockListCodespaces.Lock() + mock.calls.ListCodespaces = append(mock.calls.ListCodespaces, callInfo) + mock.lockListCodespaces.Unlock() + return mock.ListCodespacesFunc(ctx, user) +} + +// ListCodespacesCalls gets all the calls that were made to ListCodespaces. +// Check the length with: +// len(mockedapiClient.ListCodespacesCalls()) +func (mock *apiClientMock) ListCodespacesCalls() []struct { + Ctx context.Context + User string +} { + var calls []struct { + Ctx context.Context + User string + } + mock.lockListCodespaces.RLock() + calls = mock.calls.ListCodespaces + mock.lockListCodespaces.RUnlock() + return calls +} diff --git a/cmd/ghcs/mock_prompter.go b/cmd/ghcs/mock_prompter.go new file mode 100644 index 000000000..e15209c03 --- /dev/null +++ b/cmd/ghcs/mock_prompter.go @@ -0,0 +1,73 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package ghcs + +import ( + "sync" +) + +// Ensure, that prompterMock does implement prompter. +// If this is not the case, regenerate this file with moq. +var _ prompter = &prompterMock{} + +// prompterMock is a mock implementation of prompter. +// +// func TestSomethingThatUsesprompter(t *testing.T) { +// +// // make and configure a mocked prompter +// mockedprompter := &prompterMock{ +// ConfirmFunc: func(message string) (bool, error) { +// panic("mock out the Confirm method") +// }, +// } +// +// // use mockedprompter in code that requires prompter +// // and then make assertions. +// +// } +type prompterMock struct { + // ConfirmFunc mocks the Confirm method. + ConfirmFunc func(message string) (bool, error) + + // calls tracks calls to the methods. + calls struct { + // Confirm holds details about calls to the Confirm method. + Confirm []struct { + // Message is the message argument value. + Message string + } + } + lockConfirm sync.RWMutex +} + +// Confirm calls ConfirmFunc. +func (mock *prompterMock) Confirm(message string) (bool, error) { + if mock.ConfirmFunc == nil { + panic("prompterMock.ConfirmFunc: method is nil but prompter.Confirm was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockConfirm.Lock() + mock.calls.Confirm = append(mock.calls.Confirm, callInfo) + mock.lockConfirm.Unlock() + return mock.ConfirmFunc(message) +} + +// ConfirmCalls gets all the calls that were made to Confirm. +// Check the length with: +// len(mockedprompter.ConfirmCalls()) +func (mock *prompterMock) ConfirmCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockConfirm.RLock() + calls = mock.calls.Confirm + mock.lockConfirm.RUnlock() + return calls +}