Skip to content

Commit b5c38e2

Browse files
authored
Merge pull request #25 from ncode/juliano/tests
refactor: use RunE for testable commands and improve coverage
2 parents 04bf30f + 9c296ad commit b5c38e2

File tree

4 files changed

+715
-32
lines changed

4 files changed

+715
-32
lines changed

cmd/root_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/*
2+
Copyright © 2022 Juliano Martinez <[email protected]>
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package cmd
17+
18+
import (
19+
"bytes"
20+
"os"
21+
"path/filepath"
22+
"testing"
23+
24+
"github.com/spf13/viper"
25+
"github.com/stretchr/testify/assert"
26+
)
27+
28+
func TestRootCmd_Structure(t *testing.T) {
29+
assert.Equal(t, "ballot", rootCmd.Use)
30+
assert.NotEmpty(t, rootCmd.Short)
31+
assert.NotEmpty(t, rootCmd.Long)
32+
}
33+
34+
func TestRootCmd_HasRunSubcommand(t *testing.T) {
35+
found := false
36+
for _, cmd := range rootCmd.Commands() {
37+
if cmd.Use == "run" {
38+
found = true
39+
break
40+
}
41+
}
42+
assert.True(t, found, "rootCmd should have 'run' subcommand")
43+
}
44+
45+
func TestRootCmd_ExecuteHelp(t *testing.T) {
46+
// Capture output
47+
buf := new(bytes.Buffer)
48+
rootCmd.SetOut(buf)
49+
rootCmd.SetErr(buf)
50+
rootCmd.SetArgs([]string{"--help"})
51+
52+
err := rootCmd.Execute()
53+
assert.NoError(t, err)
54+
assert.Contains(t, buf.String(), "ballot")
55+
}
56+
57+
func TestInitConfig_WithConfigFile(t *testing.T) {
58+
// Create a temporary config file
59+
tmpDir := t.TempDir()
60+
configPath := filepath.Join(tmpDir, "test-config.yaml")
61+
configContent := []byte(`
62+
election:
63+
enabled:
64+
- test_service
65+
`)
66+
err := os.WriteFile(configPath, configContent, 0644)
67+
assert.NoError(t, err)
68+
69+
// Reset viper and set config file
70+
viper.Reset()
71+
cfgFile = configPath
72+
73+
// Call initConfig
74+
initConfig()
75+
76+
// Verify the config was loaded
77+
enabled := viper.GetStringSlice("election.enabled")
78+
assert.Contains(t, enabled, "test_service")
79+
80+
// Clean up
81+
cfgFile = ""
82+
viper.Reset()
83+
}
84+
85+
func TestInitConfig_WithoutConfigFile(t *testing.T) {
86+
// Reset viper
87+
viper.Reset()
88+
cfgFile = ""
89+
90+
// This should not panic even without a config file
91+
initConfig()
92+
}
93+
94+
func TestRootCmd_PersistentFlags(t *testing.T) {
95+
flag := rootCmd.PersistentFlags().Lookup("config")
96+
assert.NotNil(t, flag)
97+
assert.Equal(t, "config", flag.Name)
98+
}

cmd/run.go

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package cmd
1717

1818
import (
1919
"context"
20+
"fmt"
2021
"os"
2122
"os/signal"
2223
"sync"
@@ -32,41 +33,56 @@ import (
3233
var runCmd = &cobra.Command{
3334
Use: "run",
3435
Short: "Run the ballot and starts all the defined elections",
35-
Run: func(cmd *cobra.Command, args []string) {
36-
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
37-
defer cancel()
38-
39-
var wg sync.WaitGroup
40-
enabledServices := viper.GetStringSlice("election.enabled")
41-
42-
for _, name := range enabledServices {
43-
b, err := ballot.New(ctx, name)
44-
if err != nil {
45-
log.WithFields(log.Fields{
46-
"caller": "run",
47-
"step": "New",
48-
"service": name,
49-
}).Error(err)
50-
os.Exit(1)
51-
}
36+
RunE: runElection,
37+
}
38+
39+
func runElection(cmd *cobra.Command, args []string) error {
40+
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
41+
defer cancel()
42+
43+
return runElectionWithContext(ctx)
44+
}
45+
46+
func runElectionWithContext(ctx context.Context) error {
47+
var wg sync.WaitGroup
48+
enabledServices := viper.GetStringSlice("election.enabled")
49+
50+
if len(enabledServices) == 0 {
51+
return fmt.Errorf("no services enabled for election")
52+
}
5253

53-
wg.Add(1)
54-
go func(b *ballot.Ballot, name string) {
55-
defer wg.Done()
56-
err := b.Run()
57-
if err != nil {
58-
log.WithFields(log.Fields{
59-
"caller": "run",
60-
"step": "runCmd",
61-
"service": name,
62-
}).Error(err)
63-
}
64-
}(b, name)
54+
errCh := make(chan error, len(enabledServices))
55+
56+
for _, name := range enabledServices {
57+
b, err := ballot.New(ctx, name)
58+
if err != nil {
59+
return fmt.Errorf("failed to create ballot for service %s: %w", name, err)
6560
}
6661

67-
wg.Wait()
68-
log.Info("All elections stopped, shutting down")
69-
},
62+
wg.Add(1)
63+
go func(b *ballot.Ballot, name string) {
64+
defer wg.Done()
65+
if err := b.Run(); err != nil {
66+
errCh <- fmt.Errorf("service %s: %w", name, err)
67+
}
68+
}(b, name)
69+
}
70+
71+
wg.Wait()
72+
close(errCh)
73+
74+
// Collect any errors from running elections
75+
var errs []error
76+
for err := range errCh {
77+
errs = append(errs, err)
78+
}
79+
80+
if len(errs) > 0 {
81+
return fmt.Errorf("election errors: %v", errs)
82+
}
83+
84+
log.Info("All elections stopped, shutting down")
85+
return nil
7086
}
7187

7288
func init() {

cmd/run_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
Copyright © 2022 Juliano Martinez <[email protected]>
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package cmd
17+
18+
import (
19+
"context"
20+
"testing"
21+
22+
"github.com/spf13/viper"
23+
"github.com/stretchr/testify/assert"
24+
)
25+
26+
func TestRunElectionWithContext_NoServicesEnabled(t *testing.T) {
27+
viper.Reset()
28+
29+
ctx, cancel := context.WithCancel(context.Background())
30+
defer cancel()
31+
32+
err := runElectionWithContext(ctx)
33+
assert.Error(t, err)
34+
assert.Contains(t, err.Error(), "no services enabled for election")
35+
}
36+
37+
func TestRunElectionWithContext_InvalidService(t *testing.T) {
38+
viper.Reset()
39+
viper.Set("election.enabled", []string{"invalid_service"})
40+
// Don't set the service configuration, so ballot.New will fail
41+
42+
ctx, cancel := context.WithCancel(context.Background())
43+
defer cancel()
44+
45+
err := runElectionWithContext(ctx)
46+
assert.Error(t, err)
47+
assert.Contains(t, err.Error(), "failed to create ballot for service invalid_service")
48+
}
49+
50+
func TestRunCmd_Structure(t *testing.T) {
51+
assert.Equal(t, "run", runCmd.Use)
52+
assert.NotEmpty(t, runCmd.Short)
53+
assert.NotNil(t, runCmd.RunE)
54+
}
55+
56+
func TestRunElection_CancelledContext(t *testing.T) {
57+
viper.Reset()
58+
viper.Set("election.enabled", []string{"test_service"})
59+
viper.Set("election.services.test_service.id", "test_id")
60+
viper.Set("election.services.test_service.key", "election/test/leader")
61+
62+
// Create an already cancelled context
63+
ctx, cancel := context.WithCancel(context.Background())
64+
cancel()
65+
66+
// This should handle the cancelled context gracefully
67+
err := runElectionWithContext(ctx)
68+
// Depending on timing, may get an error from ballot.New or from Run
69+
// The important thing is it doesn't panic
70+
_ = err
71+
}

0 commit comments

Comments
 (0)