Skip to content

Commit 7be2c73

Browse files
committed
test: add comprehensive unit and E2E test suites
- Unit tests: 79 tests covering timeutil, output, config, cli helpers, and main entry point (100% timeutil, 95% output, 91% config coverage) - Command-level tests: 9 tests using mock client injection for edge cases (empty results, response shape variants, PersonInfos display) - E2E tests: 170 tests across 13 domain files covering auth, global flags, incident lifecycle, resource listing/filtering, templates, status pages, escalation rules, and edge cases - E2E CI workflow: manual-trigger GitHub Actions workflow with secrets Production code improvements: - Add flashdutyClient interface and newClientFn for testable dependency injection - Route all command output through cmd.OutOrStdout() for capture - Suppress footer lines (Total:, Showing N results) in --json mode - Add writeResult helper for JSON-aware write command output - Reject negative durations in timeutil.Parse - Support --json on template get-preset via printer - Bump flashduty-sdk to v0.4.1 (fixes member/team filter field names)
1 parent 1708d7d commit 7be2c73

36 files changed

Lines changed: 4337 additions & 71 deletions

.github/workflows/e2e.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: E2E Tests
2+
3+
on:
4+
workflow_dispatch:
5+
6+
permissions:
7+
contents: read
8+
9+
jobs:
10+
e2e:
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 10
13+
14+
steps:
15+
- name: Check out code
16+
uses: actions/checkout@v4
17+
18+
- name: Set up Go
19+
uses: actions/setup-go@v5
20+
with:
21+
go-version-file: "go.mod"
22+
23+
- name: Download dependencies
24+
run: go mod download
25+
26+
- name: Run E2E tests
27+
env:
28+
FLASHDUTY_E2E_APP_KEY: ${{ secrets.FLASHDUTY_E2E_APP_KEY }}
29+
FLASHDUTY_E2E_BASE_URL: ${{ secrets.FLASHDUTY_E2E_BASE_URL }}
30+
run: go test -tags e2e -race -v -timeout 10m ./e2e/

cmd/flashduty/main_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// Test 77: When Execute returns an error, stderr contains "Error: <message>\n".
13+
//
14+
// main() calls os.Exit(1) on error, so we cannot invoke it directly in-process.
15+
// Instead we re-execute the test binary with a helper environment variable
16+
// that triggers main(). The subprocess stderr is then inspected.
17+
func TestErrorFormatToStderr(t *testing.T) {
18+
if os.Getenv("TEST_MAIN_ERROR") == "1" {
19+
// Inside the subprocess: run main() with an invalid subcommand.
20+
// os.Args is already set by the exec.Command below.
21+
main()
22+
return
23+
}
24+
25+
// Re-invoke ourselves as a subprocess so that os.Exit does not kill the
26+
// test runner. Pass an unknown subcommand to force an error from cobra.
27+
cmd := exec.Command(os.Args[0], "-test.run=^TestErrorFormatToStderr$")
28+
cmd.Env = append(os.Environ(), "TEST_MAIN_ERROR=1")
29+
// Override os.Args inside the subprocess by passing the invalid command
30+
// as a trailing argument after the test flags. We achieve this by setting
31+
// Args on the Cmd directly -- but we need the subprocess to call main()
32+
// with the right os.Args. The trick: we build the real binary instead.
33+
//
34+
// A simpler approach: build the CLI binary and invoke it.
35+
// But the cleanest approach for unit tests: just verify the formatting
36+
// contract and that cli.Execute() returns an error for bad input.
37+
38+
// We use a different strategy: build the binary, run it with a bad
39+
// subcommand, and inspect stderr.
40+
binPath := t.TempDir() + "/flashduty-test"
41+
build := exec.Command("go", "build", "-o", binPath, ".")
42+
build.Dir = "/Users/bowen/go/src/github.com/flashcatcloud/flashduty-cli/cmd/flashduty"
43+
if out, err := build.CombinedOutput(); err != nil {
44+
t.Fatalf("[#77] failed to build test binary: %v\n%s", err, out)
45+
}
46+
47+
// Run the binary with an unknown subcommand.
48+
run := exec.Command(binPath, "nonexistent-subcommand-xyz")
49+
var stderr bytes.Buffer
50+
run.Stderr = &stderr
51+
err := run.Run()
52+
53+
// The binary should exit non-zero.
54+
if err == nil {
55+
t.Fatalf("[#77] expected non-zero exit code for invalid subcommand, got success")
56+
}
57+
58+
// Verify stderr contains the expected error format.
59+
got := stderr.String()
60+
if !strings.HasPrefix(got, "Error: ") {
61+
t.Errorf("[#77] stderr should start with \"Error: \", got %q", got)
62+
}
63+
if !strings.HasSuffix(got, "\n") {
64+
t.Errorf("[#77] stderr should end with newline, got %q", got)
65+
}
66+
// The error message from cobra for unknown subcommands contains "unknown command".
67+
if !strings.Contains(got, "unknown command") {
68+
t.Errorf("[#77] stderr should mention \"unknown command\", got %q", got)
69+
}
70+
71+
// Also verify the exact format pattern: "Error: <message>\n"
72+
// by checking it matches fmt.Sprintf("Error: %s\n", <something>).
73+
trimmed := strings.TrimPrefix(got, "Error: ")
74+
trimmed = strings.TrimSuffix(trimmed, "\n")
75+
reconstructed := fmt.Sprintf("Error: %s\n", trimmed)
76+
if reconstructed != got {
77+
t.Errorf("[#77] stderr does not match format \"Error: <msg>\\n\":\n got: %q\n expect: %q", got, reconstructed)
78+
}
79+
}
80+
81+
// Test 78: SetVersionInfo before Execute -- version/commit/date set by main
82+
// are reflected in the `version` subcommand output.
83+
func TestSetVersionInfoBeforeExecute(t *testing.T) {
84+
binPath := t.TempDir() + "/flashduty-test"
85+
86+
// Build with custom ldflags to inject known version info, just like the
87+
// real release build does.
88+
ldflags := fmt.Sprintf(
89+
"-X main.version=1.2.3-test -X main.commit=abc1234 -X main.date=2026-04-13",
90+
)
91+
build := exec.Command("go", "build", "-ldflags", ldflags, "-o", binPath, ".")
92+
build.Dir = "/Users/bowen/go/src/github.com/flashcatcloud/flashduty-cli/cmd/flashduty"
93+
if out, err := build.CombinedOutput(); err != nil {
94+
t.Fatalf("[#78] failed to build test binary: %v\n%s", err, out)
95+
}
96+
97+
// Run the binary with the "version" subcommand.
98+
run := exec.Command(binPath, "version")
99+
out, err := run.CombinedOutput()
100+
if err != nil {
101+
t.Fatalf("[#78] version command failed: %v\n%s", err, out)
102+
}
103+
104+
got := string(out)
105+
want := "flashduty version 1.2.3-test (abc1234) built 2026-04-13\n"
106+
if got != want {
107+
t.Errorf("[#78] version output mismatch:\n got: %q\n want: %q", got, want)
108+
}
109+
110+
// Verify each component is present individually for clearer diagnostics.
111+
for _, sub := range []string{"1.2.3-test", "abc1234", "2026-04-13"} {
112+
if !strings.Contains(got, sub) {
113+
t.Errorf("[#78] version output missing %q in %q", sub, got)
114+
}
115+
}
116+
}

e2e/auth_global_test.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
//go:build e2e
2+
3+
package e2e_test
4+
5+
import (
6+
"os"
7+
"strings"
8+
"testing"
9+
)
10+
11+
// ---------------------------------------------------------------------------
12+
// Version & Help (tests 105-110)
13+
// ---------------------------------------------------------------------------
14+
15+
func TestVersionOutput(t *testing.T) {
16+
// Test 105: `version` contains "flashduty version"
17+
r := runCLIPublic(t, "version")
18+
requireSuccess(t, r)
19+
requireContains(t, r.Stdout, "flashduty version")
20+
}
21+
22+
func TestVersionBuildInfo(t *testing.T) {
23+
// Test 106: version output contains version, commit, date
24+
r := runCLIPublic(t, "version")
25+
requireSuccess(t, r)
26+
// At minimum it should have the format "flashduty version X (Y) built Z"
27+
requireContains(t, r.Stdout, "built")
28+
}
29+
30+
func TestRootHelp(t *testing.T) {
31+
// Test 107: --help contains command names
32+
r := runCLIPublic(t, "--help")
33+
requireSuccess(t, r)
34+
requireContains(t, r.Stdout, "incident")
35+
requireContains(t, r.Stdout, "channel")
36+
requireContains(t, r.Stdout, "member")
37+
requireContains(t, r.Stdout, "team")
38+
requireContains(t, r.Stdout, "config")
39+
requireContains(t, r.Stdout, "login")
40+
}
41+
42+
func TestSubcommandHelp(t *testing.T) {
43+
// Test 108: incident --help lists subcommands
44+
r := runCLIPublic(t, "incident", "--help")
45+
requireSuccess(t, r)
46+
requireContains(t, r.Stdout, "list")
47+
requireContains(t, r.Stdout, "get")
48+
requireContains(t, r.Stdout, "create")
49+
requireContains(t, r.Stdout, "update")
50+
requireContains(t, r.Stdout, "ack")
51+
requireContains(t, r.Stdout, "close")
52+
requireContains(t, r.Stdout, "timeline")
53+
requireContains(t, r.Stdout, "alerts")
54+
requireContains(t, r.Stdout, "similar")
55+
}
56+
57+
func TestHelpForEverySubcommand(t *testing.T) {
58+
// Test 110: all top-level commands show help without errors
59+
commands := []string{
60+
"channel", "member", "team", "field", "escalation-rule",
61+
"statuspage", "template", "change", "config", "login",
62+
}
63+
for _, cmd := range commands {
64+
t.Run(cmd, func(t *testing.T) {
65+
r := runCLIPublic(t, cmd, "--help")
66+
requireSuccess(t, r)
67+
})
68+
}
69+
}
70+
71+
// ---------------------------------------------------------------------------
72+
// Exit Codes (tests 100-104)
73+
// ---------------------------------------------------------------------------
74+
75+
func TestSuccessExitCode(t *testing.T) {
76+
// Test 100: `version` exits 0
77+
r := runCLIPublic(t, "version")
78+
if r.ExitCode != 0 {
79+
t.Errorf("expected exit 0, got %d", r.ExitCode)
80+
}
81+
}
82+
83+
func TestAuthErrorExitCode(t *testing.T) {
84+
// Test 101: no app key -> exit 1
85+
r := runCLINoAuth(t, "channel", "list")
86+
requireFailure(t, r)
87+
}
88+
89+
func TestInvalidArgsExitCode(t *testing.T) {
90+
// Test 102: `incident get` with no ID -> exit 1
91+
r := runCLIPublic(t, "incident", "get")
92+
requireFailure(t, r)
93+
}
94+
95+
func TestInvalidCommandExitCode(t *testing.T) {
96+
// Test 103: unknown command -> exit 1
97+
r := runCLIPublic(t, "nonexistent")
98+
requireFailure(t, r)
99+
}
100+
101+
func TestMissingRequiredFlagExitCode(t *testing.T) {
102+
// Test 104: missing --channel for escalation-rule list -> exit 1
103+
r := runCLI(t, "escalation-rule", "list")
104+
requireFailure(t, r)
105+
}
106+
107+
// ---------------------------------------------------------------------------
108+
// Auth Error (tests 123-125)
109+
// ---------------------------------------------------------------------------
110+
111+
func TestNoAuthSuggestsLogin(t *testing.T) {
112+
// Tests 123-125: no auth suggests running login
113+
commands := [][]string{
114+
{"channel", "list"},
115+
{"incident", "list"},
116+
{"member", "list"},
117+
}
118+
for _, args := range commands {
119+
t.Run(strings.Join(args, "_"), func(t *testing.T) {
120+
r := runCLINoAuth(t, args...)
121+
requireFailure(t, r)
122+
requireContains(t, r.Stderr, "flashduty login")
123+
})
124+
}
125+
}
126+
127+
// ---------------------------------------------------------------------------
128+
// Output Format (tests 95, 97-99, 314)
129+
// ---------------------------------------------------------------------------
130+
131+
func TestErrorsGoToStderr(t *testing.T) {
132+
// Test 97: failed command -> stderr has error, stdout is empty
133+
r := runCLINoAuth(t, "channel", "list")
134+
requireFailure(t, r)
135+
if r.Stderr == "" {
136+
t.Error("expected stderr to contain error message")
137+
}
138+
}
139+
140+
func TestErrorPrefixFormat(t *testing.T) {
141+
// Test 99: stderr starts with "Error: "
142+
r := runCLINoAuth(t, "channel", "list")
143+
requireFailure(t, r)
144+
requireContains(t, r.Stderr, "Error: ")
145+
}
146+
147+
func TestAppKeyHiddenFromHelp(t *testing.T) {
148+
// Test 314: --help does NOT show --app-key
149+
r := runCLIPublic(t, "--help")
150+
requireSuccess(t, r)
151+
requireNotContains(t, r.Stdout, "--app-key")
152+
}
153+
154+
// ---------------------------------------------------------------------------
155+
// Global Flags (tests 79, 91-94)
156+
// ---------------------------------------------------------------------------
157+
158+
func TestJSONOnListCommand(t *testing.T) {
159+
// Test 79: --json on channel list -> valid JSON
160+
r := runCLI(t, "channel", "list", "--json")
161+
requireSuccess(t, r)
162+
requireValidJSON(t, r.Stdout)
163+
}
164+
165+
func TestBaseURLValidOverride(t *testing.T) {
166+
// Test 91: --base-url with valid URL succeeds
167+
r := runCLI(t, "channel", "list", "--base-url", "https://api.flashcat.cloud")
168+
requireSuccess(t, r)
169+
}
170+
171+
func TestBaseURLInvalid(t *testing.T) {
172+
// Test 92: --base-url with invalid URL -> error
173+
r := runCLI(t, "channel", "list", "--base-url", "https://invalid.example.com")
174+
requireFailure(t, r)
175+
}
176+
177+
func TestAppKeyOverride(t *testing.T) {
178+
// Test 93: --app-key with valid key succeeds
179+
appKey := os.Getenv("FLASHDUTY_E2E_APP_KEY")
180+
if appKey == "" {
181+
t.Skip("FLASHDUTY_E2E_APP_KEY not set")
182+
}
183+
r := runCLINoAuth(t, "channel", "list", "--app-key", appKey)
184+
requireSuccess(t, r)
185+
}
186+
187+
func TestAppKeyInvalid(t *testing.T) {
188+
// Test 94: --app-key with invalid key -> auth error
189+
r := runCLINoAuth(t, "channel", "list", "--app-key", "invalid_key_xyz")
190+
requireFailure(t, r)
191+
}

e2e/change_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//go:build e2e
2+
3+
package e2e_test
4+
5+
import (
6+
"testing"
7+
)
8+
9+
// Test 239: change list --since --until
10+
func TestChangeListSinceUntil(t *testing.T) {
11+
r := runCLI(t, "change", "list", "--since", "168h", "--until", "now")
12+
requireSuccess(t, r)
13+
}
14+
15+
// Test 242: change list --limit
16+
func TestChangeListLimit(t *testing.T) {
17+
r := runCLI(t, "change", "list", "--limit", "5", "--since", "168h")
18+
requireSuccess(t, r)
19+
}
20+
21+
// Test 243: change list --page
22+
func TestChangeListPage(t *testing.T) {
23+
r := runCLI(t, "change", "list", "--page", "1", "--since", "168h")
24+
requireSuccess(t, r)
25+
}
26+
27+
// Test 245: change list pagination footer
28+
func TestChangeListPaginationFooter(t *testing.T) {
29+
r := runCLI(t, "change", "list", "--since", "168h")
30+
requireSuccess(t, r)
31+
requireContains(t, r.Stdout, "Showing")
32+
}
33+
34+
// Test 246: change list --since invalid
35+
func TestChangeListSinceInvalid(t *testing.T) {
36+
r := runCLI(t, "change", "list", "--since", "garbage")
37+
requireFailure(t, r)
38+
}
39+
40+
// Test 247: change list --until invalid
41+
func TestChangeListUntilInvalid(t *testing.T) {
42+
r := runCLI(t, "change", "list", "--until", "garbage")
43+
requireFailure(t, r)
44+
}

0 commit comments

Comments
 (0)