From 9c296ad631254e254aa8a64c46ded5b572f17626 Mon Sep 17 00:00:00 2001 From: Juliano Martinez Date: Thu, 25 Dec 2025 13:50:38 +0100 Subject: [PATCH] refactor: use RunE for testable commands and improve coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor runCmd from Run to RunE for better testability - Extract runElectionWithContext for isolated testing - Add proper error returns instead of os.Exit(1) - Add validation for empty enabled services list - Add cmd package tests (root_test.go, run_test.go) - Add comprehensive tests for wrapper methods and edge cases - Coverage improved from 69.8% to 85.0% 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/root_test.go | 98 +++++++ cmd/run.go | 80 +++--- cmd/run_test.go | 71 +++++ internal/ballot/ballot_test.go | 498 +++++++++++++++++++++++++++++++++ 4 files changed, 715 insertions(+), 32 deletions(-) create mode 100644 cmd/root_test.go create mode 100644 cmd/run_test.go diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..2b0d488 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,98 @@ +/* +Copyright © 2022 Juliano Martinez + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestRootCmd_Structure(t *testing.T) { + assert.Equal(t, "ballot", rootCmd.Use) + assert.NotEmpty(t, rootCmd.Short) + assert.NotEmpty(t, rootCmd.Long) +} + +func TestRootCmd_HasRunSubcommand(t *testing.T) { + found := false + for _, cmd := range rootCmd.Commands() { + if cmd.Use == "run" { + found = true + break + } + } + assert.True(t, found, "rootCmd should have 'run' subcommand") +} + +func TestRootCmd_ExecuteHelp(t *testing.T) { + // Capture output + buf := new(bytes.Buffer) + rootCmd.SetOut(buf) + rootCmd.SetErr(buf) + rootCmd.SetArgs([]string{"--help"}) + + err := rootCmd.Execute() + assert.NoError(t, err) + assert.Contains(t, buf.String(), "ballot") +} + +func TestInitConfig_WithConfigFile(t *testing.T) { + // Create a temporary config file + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "test-config.yaml") + configContent := []byte(` +election: + enabled: + - test_service +`) + err := os.WriteFile(configPath, configContent, 0644) + assert.NoError(t, err) + + // Reset viper and set config file + viper.Reset() + cfgFile = configPath + + // Call initConfig + initConfig() + + // Verify the config was loaded + enabled := viper.GetStringSlice("election.enabled") + assert.Contains(t, enabled, "test_service") + + // Clean up + cfgFile = "" + viper.Reset() +} + +func TestInitConfig_WithoutConfigFile(t *testing.T) { + // Reset viper + viper.Reset() + cfgFile = "" + + // This should not panic even without a config file + initConfig() +} + +func TestRootCmd_PersistentFlags(t *testing.T) { + flag := rootCmd.PersistentFlags().Lookup("config") + assert.NotNil(t, flag) + assert.Equal(t, "config", flag.Name) +} diff --git a/cmd/run.go b/cmd/run.go index 0b4b061..c708927 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -17,6 +17,7 @@ package cmd import ( "context" + "fmt" "os" "os/signal" "sync" @@ -32,41 +33,56 @@ import ( var runCmd = &cobra.Command{ Use: "run", Short: "Run the ballot and starts all the defined elections", - Run: func(cmd *cobra.Command, args []string) { - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - - var wg sync.WaitGroup - enabledServices := viper.GetStringSlice("election.enabled") - - for _, name := range enabledServices { - b, err := ballot.New(ctx, name) - if err != nil { - log.WithFields(log.Fields{ - "caller": "run", - "step": "New", - "service": name, - }).Error(err) - os.Exit(1) - } + RunE: runElection, +} + +func runElection(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + return runElectionWithContext(ctx) +} + +func runElectionWithContext(ctx context.Context) error { + var wg sync.WaitGroup + enabledServices := viper.GetStringSlice("election.enabled") + + if len(enabledServices) == 0 { + return fmt.Errorf("no services enabled for election") + } - wg.Add(1) - go func(b *ballot.Ballot, name string) { - defer wg.Done() - err := b.Run() - if err != nil { - log.WithFields(log.Fields{ - "caller": "run", - "step": "runCmd", - "service": name, - }).Error(err) - } - }(b, name) + errCh := make(chan error, len(enabledServices)) + + for _, name := range enabledServices { + b, err := ballot.New(ctx, name) + if err != nil { + return fmt.Errorf("failed to create ballot for service %s: %w", name, err) } - wg.Wait() - log.Info("All elections stopped, shutting down") - }, + wg.Add(1) + go func(b *ballot.Ballot, name string) { + defer wg.Done() + if err := b.Run(); err != nil { + errCh <- fmt.Errorf("service %s: %w", name, err) + } + }(b, name) + } + + wg.Wait() + close(errCh) + + // Collect any errors from running elections + var errs []error + for err := range errCh { + errs = append(errs, err) + } + + if len(errs) > 0 { + return fmt.Errorf("election errors: %v", errs) + } + + log.Info("All elections stopped, shutting down") + return nil } func init() { diff --git a/cmd/run_test.go b/cmd/run_test.go new file mode 100644 index 0000000..7bbcde3 --- /dev/null +++ b/cmd/run_test.go @@ -0,0 +1,71 @@ +/* +Copyright © 2022 Juliano Martinez + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package cmd + +import ( + "context" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestRunElectionWithContext_NoServicesEnabled(t *testing.T) { + viper.Reset() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := runElectionWithContext(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no services enabled for election") +} + +func TestRunElectionWithContext_InvalidService(t *testing.T) { + viper.Reset() + viper.Set("election.enabled", []string{"invalid_service"}) + // Don't set the service configuration, so ballot.New will fail + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + err := runElectionWithContext(ctx) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create ballot for service invalid_service") +} + +func TestRunCmd_Structure(t *testing.T) { + assert.Equal(t, "run", runCmd.Use) + assert.NotEmpty(t, runCmd.Short) + assert.NotNil(t, runCmd.RunE) +} + +func TestRunElection_CancelledContext(t *testing.T) { + viper.Reset() + viper.Set("election.enabled", []string{"test_service"}) + viper.Set("election.services.test_service.id", "test_id") + viper.Set("election.services.test_service.key", "election/test/leader") + + // Create an already cancelled context + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + // This should handle the cancelled context gracefully + err := runElectionWithContext(ctx) + // Depending on timing, may get an error from ballot.New or from Run + // The important thing is it doesn't panic + _ = err +} diff --git a/internal/ballot/ballot_test.go b/internal/ballot/ballot_test.go index 2fa66b9..7a5017a 100644 --- a/internal/ballot/ballot_test.go +++ b/internal/ballot/ballot_test.go @@ -1858,3 +1858,501 @@ func (m *MockKV) Acquire(p *api.KVPair, q *api.WriteOptions) (bool, *api.WriteMe errResult := args.Error(2) return boolResult, meta, errResult } + +func TestSessionWrapper_Destroy(t *testing.T) { + mockSession := new(MockSession) + sessionWrapper := &SessionWrapper{session: mockSession} + + sessionID := "session_id" + writeOptions := &api.WriteOptions{} + + expectedMeta := &api.WriteMeta{} + expectedErr := errors.New("destroy error") + + mockSession.On("Destroy", sessionID, writeOptions).Return(expectedMeta, expectedErr) + + meta, err := sessionWrapper.Destroy(sessionID, writeOptions) + + assert.Equal(t, expectedMeta, meta) + assert.Equal(t, expectedErr, err) + mockSession.AssertCalled(t, "Destroy", sessionID, writeOptions) +} + +func TestSessionWrapper_Info(t *testing.T) { + mockSession := new(MockSession) + sessionWrapper := &SessionWrapper{session: mockSession} + + sessionID := "session_id" + queryOptions := &api.QueryOptions{} + + expectedEntry := &api.SessionEntry{ID: sessionID} + expectedMeta := &api.QueryMeta{} + expectedErr := errors.New("info error") + + mockSession.On("Info", sessionID, queryOptions).Return(expectedEntry, expectedMeta, expectedErr) + + entry, meta, err := sessionWrapper.Info(sessionID, queryOptions) + + assert.Equal(t, expectedEntry, entry) + assert.Equal(t, expectedMeta, meta) + assert.Equal(t, expectedErr, err) + mockSession.AssertCalled(t, "Info", sessionID, queryOptions) +} + +func TestKVWrapper_Put(t *testing.T) { + mockKV := new(MockKV) + kvWrapper := &KVWrapper{kv: mockKV} + + kvPair := &api.KVPair{Key: "test_key", Value: []byte("test_value")} + writeOptions := &api.WriteOptions{} + + expectedMeta := &api.WriteMeta{} + expectedErr := errors.New("put error") + + mockKV.On("Put", kvPair, writeOptions).Return(expectedMeta, expectedErr) + + meta, err := kvWrapper.Put(kvPair, writeOptions) + + assert.Equal(t, expectedMeta, meta) + assert.Equal(t, expectedErr, err) + mockKV.AssertCalled(t, "Put", kvPair, writeOptions) +} + +func TestRun(t *testing.T) { + t.Run("Run exits on context cancellation", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + sessionID := "session_id" + b := &Ballot{ + ID: "test_service_id", + Name: "test_service", + Key: "election/test_service/leader", + PrimaryTag: "primary", + TTL: 100 * time.Millisecond, + ctx: ctx, + } + b.sessionID.Store(&sessionID) + + // Mock health checks + mockHealth := new(MockHealth) + mockHealth.On("Checks", b.Name, mock.Anything).Return([]*api.HealthCheck{ + {Status: "passing"}, + }, nil, nil) + + // Mock session + mockSession := new(MockSession) + mockSession.On("Create", mock.Anything, mock.Anything).Return(sessionID, nil, nil) + mockSession.On("RenewPeriodic", mock.Anything, sessionID, mock.Anything, mock.Anything).Return(nil) + mockSession.On("Info", sessionID, mock.Anything).Return(&api.SessionEntry{ID: sessionID}, &api.QueryMeta{}, nil) + + // Mock KV + payload := &ElectionPayload{ + Address: "127.0.0.1", + Port: 8080, + SessionID: sessionID, + } + data, _ := json.Marshal(payload) + mockKV := new(MockKV) + mockKV.On("Acquire", mock.Anything, mock.Anything).Return(true, nil, nil) + mockKV.On("Get", b.Key, mock.Anything).Return(&api.KVPair{ + Key: b.Key, + Value: data, + Session: sessionID, + }, nil, nil) + + // Mock Agent + service := &api.AgentService{ + ID: b.ID, + Service: b.Name, + Address: "127.0.0.1", + Port: 8080, + Tags: []string{}, + } + mockAgent := new(MockAgent) + mockAgent.On("Service", b.ID, mock.Anything).Return(service, nil, nil) + mockAgent.On("ServiceRegister", mock.Anything).Return(nil) + + // Mock Catalog + mockCatalog := new(MockCatalog) + mockCatalog.On("Service", b.Name, b.PrimaryTag, mock.Anything).Return([]*api.CatalogService{}, nil, nil) + mockCatalog.On("Service", b.Name, "", mock.Anything).Return([]*api.CatalogService{}, nil, nil) + mockCatalog.On("Register", mock.Anything, mock.Anything).Return(nil, nil) + + mockClient := &MockConsulClient{} + mockClient.On("Health").Return(mockHealth) + mockClient.On("Session").Return(mockSession) + mockClient.On("KV").Return(mockKV) + mockClient.On("Agent").Return(mockAgent) + mockClient.On("Catalog").Return(mockCatalog) + + b.client = mockClient + + // Run in goroutine and cancel after short delay + done := make(chan error, 1) + go func() { + done <- b.Run() + }() + + // Cancel after letting it run briefly + time.Sleep(150 * time.Millisecond) + cancel() + + err := <-done + assert.NoError(t, err) + }) +} + +func TestGetService_EmptyServiceID(t *testing.T) { + b := &Ballot{ + ID: "", + } + + service, catalogServices, err := b.getService() + assert.Error(t, err) + assert.Nil(t, service) + assert.Nil(t, catalogServices) + assert.Contains(t, err.Error(), "service ID is empty") +} + +func TestGetService_AgentServiceError(t *testing.T) { + mockAgent := new(MockAgent) + expectedErr := errors.New("agent service error") + mockAgent.On("Service", "test_id", mock.Anything).Return(nil, nil, expectedErr) + + mockClient := &MockConsulClient{} + mockClient.On("Agent").Return(mockAgent) + + b := &Ballot{ + ID: "test_id", + client: mockClient, + } + + service, catalogServices, err := b.getService() + assert.Error(t, err) + assert.Nil(t, service) + assert.Nil(t, catalogServices) + assert.Equal(t, expectedErr, err) +} + +func TestGetService_CatalogServiceError(t *testing.T) { + serviceID := "test_service_id" + serviceName := "test_service" + + mockAgent := new(MockAgent) + mockAgent.On("Service", serviceID, mock.Anything).Return(&api.AgentService{ + ID: serviceID, + Service: serviceName, + }, nil, nil) + + expectedErr := errors.New("catalog service error") + mockCatalog := new(MockCatalog) + mockCatalog.On("Service", serviceName, "primary", mock.Anything).Return(nil, nil, expectedErr) + + mockClient := &MockConsulClient{} + mockClient.On("Agent").Return(mockAgent) + mockClient.On("Catalog").Return(mockCatalog) + + b := &Ballot{ + ID: serviceID, + Name: serviceName, + PrimaryTag: "primary", + client: mockClient, + } + + service, catalogServices, err := b.getService() + assert.Error(t, err) + assert.NotNil(t, service) + assert.Nil(t, catalogServices) + assert.Equal(t, expectedErr, err) +} + +func TestUpdateServiceTags_GetServiceError(t *testing.T) { + mockAgent := new(MockAgent) + expectedErr := errors.New("get service error") + mockAgent.On("Service", "test_id", mock.Anything).Return(nil, nil, expectedErr) + + mockClient := &MockConsulClient{} + mockClient.On("Agent").Return(mockAgent) + + b := &Ballot{ + ID: "test_id", + client: mockClient, + } + + err := b.updateServiceTags(true) + assert.Error(t, err) + assert.Equal(t, expectedErr, err) +} + +func TestSession_ClientNil(t *testing.T) { + b := &Ballot{ + client: nil, + } + + err := b.session() + assert.Error(t, err) + assert.Contains(t, err.Error(), "consul client is required") +} + +func TestSession_ExistingValidSession(t *testing.T) { + sessionID := "existing_session" + + mockSession := new(MockSession) + mockSession.On("Info", sessionID, (*api.QueryOptions)(nil)).Return(&api.SessionEntry{ID: sessionID}, &api.QueryMeta{}, nil) + + mockClient := &MockConsulClient{} + mockClient.On("Session").Return(mockSession) + + b := &Ballot{ + client: mockClient, + TTL: 10 * time.Second, + ctx: context.Background(), + } + b.sessionID.Store(&sessionID) + + err := b.session() + assert.NoError(t, err) + + // Verify we didn't create a new session + mockSession.AssertNotCalled(t, "Create", mock.Anything, mock.Anything) +} + +func TestSession_InfoError(t *testing.T) { + sessionID := "session_with_error" + + expectedErr := errors.New("info error") + mockSession := new(MockSession) + mockSession.On("Info", sessionID, (*api.QueryOptions)(nil)).Return((*api.SessionEntry)(nil), (*api.QueryMeta)(nil), expectedErr) + + mockClient := &MockConsulClient{} + mockClient.On("Session").Return(mockSession) + + b := &Ballot{ + client: mockClient, + TTL: 10 * time.Second, + ctx: context.Background(), + } + b.sessionID.Store(&sessionID) + + err := b.session() + assert.Error(t, err) + assert.Equal(t, expectedErr, err) +} + +func TestElection_HandleCriticalStateError(t *testing.T) { + b := &Ballot{ + ID: "test_service_id", + Name: "test_service", + } + + mockHealth := new(MockHealth) + expectedErr := errors.New("health check error") + mockHealth.On("Checks", b.Name, mock.Anything).Return(nil, nil, expectedErr) + + mockClient := &MockConsulClient{} + mockClient.On("Health").Return(mockHealth) + + b.client = mockClient + + err := b.election() + assert.Error(t, err) + assert.Contains(t, err.Error(), expectedErr.Error()) +} + +func TestElection_GetServiceError(t *testing.T) { + b := &Ballot{ + ID: "test_service_id", + Name: "test_service", + } + + mockHealth := new(MockHealth) + mockHealth.On("Checks", b.Name, mock.Anything).Return([]*api.HealthCheck{ + {Status: "passing"}, + }, nil, nil) + + mockAgent := new(MockAgent) + expectedErr := errors.New("agent service error") + mockAgent.On("Service", b.ID, mock.Anything).Return(nil, nil, expectedErr) + + mockClient := &MockConsulClient{} + mockClient.On("Health").Return(mockHealth) + mockClient.On("Agent").Return(mockAgent) + + b.client = mockClient + + err := b.election() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to get service") +} + +func TestElection_SessionError(t *testing.T) { + b := &Ballot{ + ID: "test_service_id", + Name: "test_service", + TTL: 10 * time.Second, + ctx: context.Background(), + } + + mockHealth := new(MockHealth) + mockHealth.On("Checks", b.Name, mock.Anything).Return([]*api.HealthCheck{ + {Status: "passing"}, + }, nil, nil) + + mockAgent := new(MockAgent) + mockAgent.On("Service", b.ID, mock.Anything).Return(&api.AgentService{ + ID: b.ID, + Service: b.Name, + }, nil, nil) + + mockCatalog := new(MockCatalog) + mockCatalog.On("Service", b.Name, "", mock.Anything).Return([]*api.CatalogService{}, nil, nil) + + expectedErr := errors.New("session create error") + mockSession := new(MockSession) + mockSession.On("Create", mock.Anything, mock.Anything).Return("", nil, expectedErr) + + mockClient := &MockConsulClient{} + mockClient.On("Health").Return(mockHealth) + mockClient.On("Agent").Return(mockAgent) + mockClient.On("Catalog").Return(mockCatalog) + mockClient.On("Session").Return(mockSession) + + b.client = mockClient + + err := b.election() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create session") +} + +func TestElection_NilSessionID(t *testing.T) { + sessionID := "session_id" + b := &Ballot{ + ID: "test_service_id", + Name: "test_service", + Key: "election/test/leader", + PrimaryTag: "primary", + TTL: 10 * time.Second, + ctx: context.Background(), + } + + mockHealth := new(MockHealth) + mockHealth.On("Checks", b.Name, mock.Anything).Return([]*api.HealthCheck{ + {Status: "passing"}, + }, nil, nil) + + mockAgent := new(MockAgent) + mockAgent.On("Service", b.ID, mock.Anything).Return(&api.AgentService{ + ID: b.ID, + Service: b.Name, + }, nil, nil) + mockAgent.On("ServiceRegister", mock.Anything).Return(nil) + + mockCatalog := new(MockCatalog) + mockCatalog.On("Service", b.Name, "", mock.Anything).Return([]*api.CatalogService{}, nil, nil) + mockCatalog.On("Service", b.Name, b.PrimaryTag, mock.Anything).Return([]*api.CatalogService{}, nil, nil) + + // Session creation returns a valid session ID + mockSession := new(MockSession) + mockSession.On("Create", mock.Anything, mock.Anything).Return(sessionID, nil, nil) + mockSession.On("RenewPeriodic", mock.Anything, sessionID, mock.Anything, mock.Anything).Return(nil) + + // Mock KV + payload := &ElectionPayload{ + Address: "", + Port: 0, + SessionID: sessionID, + } + data, _ := json.Marshal(payload) + mockKV := new(MockKV) + mockKV.On("Acquire", mock.Anything, mock.Anything).Return(true, nil, nil) + mockKV.On("Get", b.Key, mock.Anything).Return(&api.KVPair{ + Key: b.Key, + Value: data, + Session: sessionID, + }, nil, nil) + + mockClient := &MockConsulClient{} + mockClient.On("Health").Return(mockHealth) + mockClient.On("Agent").Return(mockAgent) + mockClient.On("Catalog").Return(mockCatalog) + mockClient.On("Session").Return(mockSession) + mockClient.On("KV").Return(mockKV) + + b.client = mockClient + + err := b.election() + assert.NoError(t, err) +} + +func TestCleanup_CatalogError(t *testing.T) { + primaryTag := "primary" + serviceName := "test_service" + + leaderPayload := &ElectionPayload{ + Address: "127.0.0.1", + Port: 8080, + SessionID: "session_id", + } + + mockCatalog := new(MockCatalog) + expectedErr := errors.New("catalog error") + mockCatalog.On("Service", serviceName, "", mock.Anything).Return(nil, nil, expectedErr) + + mockClient := &MockConsulClient{} + mockClient.On("Catalog").Return(mockCatalog) + + b := &Ballot{ + client: mockClient, + Name: serviceName, + PrimaryTag: primaryTag, + } + + sessionID := leaderPayload.SessionID + b.leader.Store(true) + b.sessionID.Store(&sessionID) + + err := b.cleanup(leaderPayload) + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to retrieve services from the catalog") +} + +func TestVerifyAndUpdateLeadershipStatus_NilSessionData(t *testing.T) { + sessionID := "session_id" + b := &Ballot{ + Key: "election/test/leader", + } + b.sessionID.Store(&sessionID) + + mockKV := new(MockKV) + mockKV.On("Get", b.Key, (*api.QueryOptions)(nil)).Return(nil, nil, nil) + + mockClient := &MockConsulClient{} + mockClient.On("KV").Return(mockKV) + + b.client = mockClient + + err := b.verifyAndUpdateLeadershipStatus() + assert.NoError(t, err) +} + +func TestHandleServiceCriticalState_WarningState(t *testing.T) { + serviceID := "test_service_id" + serviceName := "test_service" + + mockHealth := new(MockHealth) + mockHealth.On("Checks", serviceName, (*api.QueryOptions)(nil)).Return([]*api.HealthCheck{ + {ServiceID: serviceID, CheckID: "check1", Status: "warning"}, + }, nil, nil) + + mockClient := &MockConsulClient{} + mockClient.On("Health").Return(mockHealth) + + b := &Ballot{ + client: mockClient, + ID: serviceID, + Name: serviceName, + } + + err := b.handleServiceCriticalState() + assert.NoError(t, err) +}