ScuttleBot

scuttlebot / internal / config / config_test.go
Blame History Raw 289 lines
1
package config
2
3
import (
4
"encoding/json"
5
"os"
6
"path/filepath"
7
"testing"
8
"time"
9
)
10
11
func TestTopologyConfigParse(t *testing.T) {
12
yaml := `
13
topology:
14
channels:
15
- name: "#general"
16
topic: "Fleet coordination"
17
ops: [bridge, oracle]
18
autojoin: [bridge, oracle, scribe]
19
- name: "#alerts"
20
autojoin: [bridge, sentinel]
21
types:
22
- name: task
23
prefix: "task."
24
autojoin: [bridge, scribe]
25
supervision: "#general"
26
ephemeral: true
27
ttl: 72h
28
- name: sprint
29
prefix: "sprint."
30
autojoin: [bridge, oracle, herald]
31
- name: incident
32
prefix: "incident."
33
autojoin: [bridge, sentinel, steward]
34
supervision: "#alerts"
35
ephemeral: true
36
ttl: 168h
37
`
38
f := filepath.Join(t.TempDir(), "scuttlebot.yaml")
39
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
40
t.Fatal(err)
41
}
42
43
var cfg Config
44
cfg.Defaults()
45
if err := cfg.LoadFile(f); err != nil {
46
t.Fatalf("LoadFile: %v", err)
47
}
48
49
top := cfg.Topology
50
51
// static channels
52
if len(top.Channels) != 2 {
53
t.Fatalf("want 2 static channels, got %d", len(top.Channels))
54
}
55
general := top.Channels[0]
56
if general.Name != "#general" {
57
t.Errorf("channel[0].Name = %q, want #general", general.Name)
58
}
59
if general.Topic != "Fleet coordination" {
60
t.Errorf("channel[0].Topic = %q", general.Topic)
61
}
62
if len(general.Autojoin) != 3 {
63
t.Errorf("channel[0].Autojoin len = %d, want 3", len(general.Autojoin))
64
}
65
66
// types
67
if len(top.Types) != 3 {
68
t.Fatalf("want 3 types, got %d", len(top.Types))
69
}
70
task := top.Types[0]
71
if task.Name != "task" {
72
t.Errorf("types[0].Name = %q, want task", task.Name)
73
}
74
if task.Prefix != "task." {
75
t.Errorf("types[0].Prefix = %q, want task.", task.Prefix)
76
}
77
if task.Supervision != "#general" {
78
t.Errorf("types[0].Supervision = %q, want #general", task.Supervision)
79
}
80
if !task.Ephemeral {
81
t.Error("types[0].Ephemeral should be true")
82
}
83
if task.TTL.Duration != 72*time.Hour {
84
t.Errorf("types[0].TTL = %v, want 72h", task.TTL.Duration)
85
}
86
87
incident := top.Types[2]
88
if incident.TTL.Duration != 168*time.Hour {
89
t.Errorf("types[2].TTL = %v, want 168h", incident.TTL.Duration)
90
}
91
}
92
93
func TestTopologyConfigEmpty(t *testing.T) {
94
yaml := `bridge:
95
enabled: true
96
`
97
f := filepath.Join(t.TempDir(), "scuttlebot.yaml")
98
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
99
t.Fatal(err)
100
}
101
102
var cfg Config
103
cfg.Defaults()
104
if err := cfg.LoadFile(f); err != nil {
105
t.Fatalf("LoadFile: %v", err)
106
}
107
108
// No topology section — should be zero value, not an error.
109
if len(cfg.Topology.Channels) != 0 {
110
t.Errorf("expected no static channels, got %d", len(cfg.Topology.Channels))
111
}
112
if len(cfg.Topology.Types) != 0 {
113
t.Errorf("expected no types, got %d", len(cfg.Topology.Types))
114
}
115
}
116
117
func TestDurationUnmarshal(t *testing.T) {
118
cases := []struct {
119
input string
120
want time.Duration
121
}{
122
{"72h", 72 * time.Hour},
123
{"30m", 30 * time.Minute},
124
{"168h", 168 * time.Hour},
125
{"0s", 0},
126
}
127
for _, tc := range cases {
128
yaml := `topology:
129
types:
130
- name: x
131
prefix: "x."
132
ttl: ` + tc.input + "\n"
133
f := filepath.Join(t.TempDir(), "cfg.yaml")
134
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
135
t.Fatal(err)
136
}
137
var cfg Config
138
if err := cfg.LoadFile(f); err != nil {
139
t.Fatalf("input %q: %v", tc.input, err)
140
}
141
got := cfg.Topology.Types[0].TTL.Duration
142
if got != tc.want {
143
t.Errorf("input %q: got %v, want %v", tc.input, got, tc.want)
144
}
145
}
146
}
147
148
func TestDurationJSONRoundTrip(t *testing.T) {
149
cases := []struct {
150
dur time.Duration
151
want string
152
}{
153
{72 * time.Hour, `"72h0m0s"`},
154
{30 * time.Minute, `"30m0s"`},
155
{0, `"0s"`},
156
}
157
for _, tc := range cases {
158
d := Duration{tc.dur}
159
b, err := json.Marshal(d)
160
if err != nil {
161
t.Fatalf("Marshal(%v): %v", tc.dur, err)
162
}
163
if string(b) != tc.want {
164
t.Errorf("Marshal(%v) = %s, want %s", tc.dur, b, tc.want)
165
}
166
var back Duration
167
if err := json.Unmarshal(b, &back); err != nil {
168
t.Fatalf("Unmarshal(%s): %v", b, err)
169
}
170
if back.Duration != tc.dur {
171
t.Errorf("round-trip(%v): got %v", tc.dur, back.Duration)
172
}
173
}
174
}
175
176
func TestDurationJSONUnmarshalErrors(t *testing.T) {
177
cases := []struct{ input string }{
178
{`123`}, // not a quoted string
179
{`"notadur"`}, // not parseable
180
{`""`}, // empty string
181
}
182
for _, tc := range cases {
183
var d Duration
184
if err := json.Unmarshal([]byte(tc.input), &d); err == nil {
185
t.Errorf("Unmarshal(%s): expected error, got nil", tc.input)
186
}
187
}
188
}
189
190
func TestApplyEnv(t *testing.T) {
191
cases := []struct {
192
envKey string
193
check func(c Config) bool
194
}{
195
{"SCUTTLEBOT_API_ADDR", func(c Config) bool { return c.APIAddr == ":9999" }},
196
{"SCUTTLEBOT_MCP_ADDR", func(c Config) bool { return c.MCPAddr == ":9998" }},
197
{"SCUTTLEBOT_DB_DRIVER", func(c Config) bool { return c.Datastore.Driver == "postgres" }},
198
{"SCUTTLEBOT_DB_DSN", func(c Config) bool { return c.Datastore.DSN == "postgres://test" }},
199
{"SCUTTLEBOT_ERGO_EXTERNAL", func(c Config) bool { return c.Ergo.External }},
200
{"SCUTTLEBOT_ERGO_API_ADDR", func(c Config) bool { return c.Ergo.APIAddr == "http://ergo:8089" }},
201
{"SCUTTLEBOT_ERGO_API_TOKEN", func(c Config) bool { return c.Ergo.APIToken == "tok123" }},
202
{"SCUTTLEBOT_ERGO_IRC_ADDR", func(c Config) bool { return c.Ergo.IRCAddr == "ergo:6667" }},
203
{"SCUTTLEBOT_ERGO_NETWORK_NAME", func(c Config) bool { return c.Ergo.NetworkName == "testnet" }},
204
{"SCUTTLEBOT_ERGO_SERVER_NAME", func(c Config) bool { return c.Ergo.ServerName == "irc.test.local" }},
205
}
206
207
envValues := map[string]string{
208
"SCUTTLEBOT_API_ADDR": ":9999",
209
"SCUTTLEBOT_MCP_ADDR": ":9998",
210
"SCUTTLEBOT_DB_DRIVER": "postgres",
211
"SCUTTLEBOT_DB_DSN": "postgres://test",
212
"SCUTTLEBOT_ERGO_EXTERNAL": "true",
213
"SCUTTLEBOT_ERGO_API_ADDR": "http://ergo:8089",
214
"SCUTTLEBOT_ERGO_API_TOKEN": "tok123",
215
"SCUTTLEBOT_ERGO_IRC_ADDR": "ergo:6667",
216
"SCUTTLEBOT_ERGO_NETWORK_NAME": "testnet",
217
"SCUTTLEBOT_ERGO_SERVER_NAME": "irc.test.local",
218
}
219
220
for _, tc := range cases {
221
t.Run(tc.envKey, func(t *testing.T) {
222
t.Setenv(tc.envKey, envValues[tc.envKey])
223
var c Config
224
c.Defaults()
225
c.ApplyEnv()
226
if !tc.check(c) {
227
t.Errorf("%s=%q did not apply correctly", tc.envKey, envValues[tc.envKey])
228
}
229
})
230
}
231
}
232
233
func TestApplyEnvErgoExternalFalseByDefault(t *testing.T) {
234
// SCUTTLEBOT_ERGO_EXTERNAL absent — should not force External=true.
235
var c Config
236
c.Defaults()
237
c.ApplyEnv()
238
if c.Ergo.External {
239
t.Error("Ergo.External should be false when env var is absent")
240
}
241
}
242
243
func TestConfigSaveAndLoad(t *testing.T) {
244
dir := t.TempDir()
245
path := filepath.Join(dir, "scuttlebot.yaml")
246
247
var orig Config
248
orig.Defaults()
249
orig.Bridge.WebUserTTLMinutes = 42
250
orig.AgentPolicy.RequireCheckin = true
251
orig.AgentPolicy.CheckinChannel = "#fleet"
252
orig.Logging.Enabled = true
253
orig.Logging.Format = "jsonl"
254
255
if err := orig.Save(path); err != nil {
256
t.Fatalf("Save: %v", err)
257
}
258
259
var loaded Config
260
loaded.Defaults()
261
if err := loaded.LoadFile(path); err != nil {
262
t.Fatalf("LoadFile: %v", err)
263
}
264
265
if loaded.Bridge.WebUserTTLMinutes != 42 {
266
t.Errorf("WebUserTTLMinutes = %d, want 42", loaded.Bridge.WebUserTTLMinutes)
267
}
268
if !loaded.AgentPolicy.RequireCheckin {
269
t.Error("AgentPolicy.RequireCheckin should be true")
270
}
271
if loaded.AgentPolicy.CheckinChannel != "#fleet" {
272
t.Errorf("CheckinChannel = %q, want #fleet", loaded.AgentPolicy.CheckinChannel)
273
}
274
if !loaded.Logging.Enabled {
275
t.Error("Logging.Enabled should be true")
276
}
277
if loaded.Logging.Format != "jsonl" {
278
t.Errorf("Logging.Format = %q, want jsonl", loaded.Logging.Format)
279
}
280
}
281
282
func TestLoadFileMissingIsNotError(t *testing.T) {
283
var c Config
284
c.Defaults()
285
if err := c.LoadFile("/nonexistent/path/scuttlebot.yaml"); err != nil {
286
t.Errorf("LoadFile on missing file should return nil, got %v", err)
287
}
288
}
289

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button