Skip to content

Commit 31a099d

Browse files
committed
test: expand coverage for tunnel behaviors and config discovery
1 parent 8b1fbe2 commit 31a099d

3 files changed

Lines changed: 310 additions & 7 deletions

File tree

cmd/tunnel.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import (
1212
"github.com/stevengregory/musing-cli/internal/health"
1313
)
1414

15+
var (
16+
execCommand = exec.Command
17+
checkPort = health.CheckPort
18+
)
19+
1520
var tunnelCmd = &cobra.Command{
1621
Use: "tunnel",
1722
Short: "Manage SSH tunnel to production database",
@@ -69,15 +74,15 @@ func tunnelStart() error {
6974
}
7075

7176
// Check if tunnel is already running
72-
if health.CheckPort(prodPort).Open {
77+
if checkPort(prodPort).Open {
7378
return tunnelStatus()
7479
}
7580

7681
// Build SSH command with tunnel using shared helper
7782
sshArgs := buildSSHArgs(cfg, true) // true = with tunnel
7883

7984
// Start SSH tunnel in background
80-
cmd := exec.Command("ssh", sshArgs...)
85+
cmd := execCommand("ssh", sshArgs...)
8186

8287
// Capture output for error reporting
8388
output, err := cmd.CombinedOutput()
@@ -131,15 +136,15 @@ func tunnelStop() error {
131136
}
132137

133138
// Check if tunnel is running
134-
if !health.CheckPort(prodPort).Open {
139+
if !checkPort(prodPort).Open {
135140
warningStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("11"))
136141
fmt.Println()
137142
fmt.Println(warningStyle.Render("✓") + " SSH tunnel is not running")
138143
return nil
139144
}
140145

141146
// Find process using the port
142-
cmd := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", prodPort))
147+
cmd := execCommand("lsof", "-ti", fmt.Sprintf(":%d", prodPort))
143148
output, err := cmd.Output()
144149
if err != nil {
145150
return fmt.Errorf("failed to find tunnel process (is lsof installed?): %w", err)
@@ -151,7 +156,7 @@ func tunnelStop() error {
151156
}
152157

153158
// Kill the process
154-
if err := exec.Command("kill", pid).Run(); err != nil {
159+
if err := execCommand("kill", pid).Run(); err != nil {
155160
return fmt.Errorf("failed to stop tunnel: %w", err)
156161
}
157162

@@ -186,7 +191,7 @@ func tunnelStatus() error {
186191
fmt.Println(headerStyle.Render("SSH Tunnel Status"))
187192
fmt.Println()
188193

189-
portStatus := health.CheckPort(prodPort)
194+
portStatus := checkPort(prodPort)
190195
var statusIcon string
191196
var statusText string
192197

@@ -222,4 +227,3 @@ func tunnelStatus() error {
222227

223228
return nil
224229
}
225-

cmd/tunnel_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package cmd
2+
3+
import (
4+
"os"
5+
"os/exec"
6+
"path/filepath"
7+
"testing"
8+
9+
"github.com/stevengregory/musing-cli/internal/health"
10+
)
11+
12+
func writeTunnelProject(t *testing.T, yaml string) string {
13+
t.Helper()
14+
15+
root := t.TempDir()
16+
if err := os.WriteFile(filepath.Join(root, "compose.yaml"), []byte("services: {}\n"), 0o644); err != nil {
17+
t.Fatalf("failed writing compose.yaml: %v", err)
18+
}
19+
if err := os.WriteFile(filepath.Join(root, ".musing.yaml"), []byte(yaml), 0o644); err != nil {
20+
t.Fatalf("failed writing .musing.yaml: %v", err)
21+
}
22+
return root
23+
}
24+
25+
func chdirCmdTest(t *testing.T, dir string) {
26+
t.Helper()
27+
28+
wd, err := os.Getwd()
29+
if err != nil {
30+
t.Fatalf("failed to get cwd: %v", err)
31+
}
32+
t.Cleanup(func() { _ = os.Chdir(wd) })
33+
34+
if err := os.Chdir(dir); err != nil {
35+
t.Fatalf("failed to chdir to %s: %v", dir, err)
36+
}
37+
}
38+
39+
func TestTunnelStart_AlreadyRunning_SkipsSSHCommand(t *testing.T) {
40+
root := writeTunnelProject(t, `
41+
database:
42+
type: MongoDB
43+
name: mydb
44+
devPort: 27018
45+
prodPort: 27019
46+
dataDir: data
47+
production:
48+
server: root@example.com
49+
services: []
50+
`)
51+
chdirCmdTest(t, root)
52+
53+
originalCheck := checkPort
54+
originalExec := execCommand
55+
t.Cleanup(func() {
56+
checkPort = originalCheck
57+
execCommand = originalExec
58+
})
59+
60+
checkPort = func(port int) health.PortStatus {
61+
return health.PortStatus{Port: port, Open: true}
62+
}
63+
execCommand = func(name string, args ...string) *exec.Cmd {
64+
t.Fatalf("execCommand should not be called when tunnel is already running")
65+
return nil
66+
}
67+
68+
if err := tunnelStart(); err != nil {
69+
t.Fatalf("tunnelStart returned error: %v", err)
70+
}
71+
}
72+
73+
func TestTunnelStop_NotRunning_SkipsLsofAndKill(t *testing.T) {
74+
root := writeTunnelProject(t, `
75+
database:
76+
type: MongoDB
77+
name: mydb
78+
devPort: 27018
79+
prodPort: 27019
80+
dataDir: data
81+
production:
82+
server: root@example.com
83+
services: []
84+
`)
85+
chdirCmdTest(t, root)
86+
87+
originalCheck := checkPort
88+
originalExec := execCommand
89+
t.Cleanup(func() {
90+
checkPort = originalCheck
91+
execCommand = originalExec
92+
})
93+
94+
checkPort = func(port int) health.PortStatus {
95+
return health.PortStatus{Port: port, Open: false}
96+
}
97+
execCommand = func(name string, args ...string) *exec.Cmd {
98+
t.Fatalf("execCommand should not be called when tunnel is not running")
99+
return nil
100+
}
101+
102+
if err := tunnelStop(); err != nil {
103+
t.Fatalf("tunnelStop returned error: %v", err)
104+
}
105+
}
106+
107+
func TestTunnelStop_Running_KillsPid(t *testing.T) {
108+
root := writeTunnelProject(t, `
109+
database:
110+
type: MongoDB
111+
name: mydb
112+
devPort: 27018
113+
prodPort: 27019
114+
dataDir: data
115+
production:
116+
server: root@example.com
117+
services: []
118+
`)
119+
chdirCmdTest(t, root)
120+
121+
originalCheck := checkPort
122+
originalExec := execCommand
123+
t.Cleanup(func() {
124+
checkPort = originalCheck
125+
execCommand = originalExec
126+
})
127+
128+
checkPort = func(port int) health.PortStatus {
129+
return health.PortStatus{Port: port, Open: true}
130+
}
131+
132+
var calls []string
133+
execCommand = func(name string, args ...string) *exec.Cmd {
134+
calls = append(calls, name)
135+
136+
switch name {
137+
case "lsof":
138+
return exec.Command("sh", "-c", "echo 4321")
139+
case "kill":
140+
if len(args) != 1 || args[0] != "4321" {
141+
t.Fatalf("kill called with wrong pid: %v", args)
142+
}
143+
return exec.Command("sh", "-c", "exit 0")
144+
default:
145+
t.Fatalf("unexpected command: %s %v", name, args)
146+
return nil
147+
}
148+
}
149+
150+
if err := tunnelStop(); err != nil {
151+
t.Fatalf("tunnelStop returned error: %v", err)
152+
}
153+
154+
if len(calls) != 2 || calls[0] != "lsof" || calls[1] != "kill" {
155+
t.Fatalf("unexpected command sequence: %v", calls)
156+
}
157+
}

internal/config/config_test.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func realPath(t *testing.T, p string) string {
10+
t.Helper()
11+
rp, err := filepath.EvalSymlinks(p)
12+
if err != nil {
13+
return filepath.Clean(p)
14+
}
15+
return filepath.Clean(rp)
16+
}
17+
18+
func writeProjectFiles(t *testing.T, root string, yaml string) {
19+
t.Helper()
20+
21+
if err := os.WriteFile(filepath.Join(root, "compose.yaml"), []byte("services: {}\n"), 0o644); err != nil {
22+
t.Fatalf("failed writing compose.yaml: %v", err)
23+
}
24+
if err := os.WriteFile(filepath.Join(root, ".musing.yaml"), []byte(yaml), 0o644); err != nil {
25+
t.Fatalf("failed writing .musing.yaml: %v", err)
26+
}
27+
}
28+
29+
func chdirForTest(t *testing.T, dir string) {
30+
t.Helper()
31+
32+
wd, err := os.Getwd()
33+
if err != nil {
34+
t.Fatalf("failed to get cwd: %v", err)
35+
}
36+
t.Cleanup(func() {
37+
_ = os.Chdir(wd)
38+
currentConfig = nil
39+
})
40+
41+
if err := os.Chdir(dir); err != nil {
42+
t.Fatalf("failed to chdir to %s: %v", dir, err)
43+
}
44+
}
45+
46+
func TestFindProjectRoot_FindsParentAndLoadsConfig(t *testing.T) {
47+
root := t.TempDir()
48+
writeProjectFiles(t, root, `
49+
database:
50+
type: MongoDB
51+
name: mydb
52+
devPort: 27018
53+
prodPort: 27019
54+
dataDir: data
55+
services:
56+
- name: news-api
57+
port: 8080
58+
type: api
59+
`)
60+
61+
nested := filepath.Join(root, "a", "b")
62+
if err := os.MkdirAll(nested, 0o755); err != nil {
63+
t.Fatalf("failed to create nested dir: %v", err)
64+
}
65+
chdirForTest(t, nested)
66+
67+
foundRoot, err := FindProjectRoot()
68+
if err != nil {
69+
t.Fatalf("FindProjectRoot returned error: %v", err)
70+
}
71+
if realPath(t, foundRoot) != realPath(t, root) {
72+
t.Fatalf("root mismatch: got %q want %q", foundRoot, root)
73+
}
74+
75+
cfg := GetConfig()
76+
if cfg == nil {
77+
t.Fatal("expected config to be loaded")
78+
}
79+
if cfg.Database.Name != "mydb" {
80+
t.Fatalf("expected database name mydb, got %q", cfg.Database.Name)
81+
}
82+
}
83+
84+
func TestFindProjectRoot_NoComposeReturnsError(t *testing.T) {
85+
root := t.TempDir()
86+
if err := os.WriteFile(filepath.Join(root, ".musing.yaml"), []byte("database: {}\n"), 0o644); err != nil {
87+
t.Fatalf("failed writing .musing.yaml: %v", err)
88+
}
89+
chdirForTest(t, root)
90+
91+
_, err := FindProjectRoot()
92+
if err == nil {
93+
t.Fatal("expected error when compose.yaml is missing")
94+
}
95+
}
96+
97+
func TestGetAPIRepos_ReturnsOnlyAPIServiceRepos(t *testing.T) {
98+
root := t.TempDir()
99+
writeProjectFiles(t, root, `
100+
database:
101+
type: MongoDB
102+
name: mydb
103+
devPort: 27018
104+
prodPort: 27019
105+
dataDir: data
106+
services:
107+
- name: api-one
108+
port: 8080
109+
type: api
110+
- name: web
111+
port: 3000
112+
type: frontend
113+
- name: api-two
114+
port: 8081
115+
type: api
116+
`)
117+
chdirForTest(t, root)
118+
119+
_, err := FindProjectRoot()
120+
if err != nil {
121+
t.Fatalf("FindProjectRoot returned error: %v", err)
122+
}
123+
124+
parent := filepath.Dir(root)
125+
want1 := filepath.Join(parent, "api-one")
126+
want2 := filepath.Join(parent, "api-two")
127+
if err := os.MkdirAll(want1, 0o755); err != nil {
128+
t.Fatalf("failed to create expected api-one path: %v", err)
129+
}
130+
if err := os.MkdirAll(want2, 0o755); err != nil {
131+
t.Fatalf("failed to create expected api-two path: %v", err)
132+
}
133+
134+
repos := GetAPIRepos()
135+
if len(repos) != 2 {
136+
t.Fatalf("expected 2 api repos, got %d", len(repos))
137+
}
138+
139+
if realPath(t, repos[0]) != realPath(t, want1) || realPath(t, repos[1]) != realPath(t, want2) {
140+
t.Fatalf("unexpected repos: got %v, want [%s %s]", repos, want1, want2)
141+
}
142+
}

0 commit comments

Comments
 (0)