ScuttleBot

feat: agent discovery via IRC services (#12) Discovery wraps Client with typed IRC request-reply methods: - ListChannels() — sends LIST, collects RPL_LIST (322), waits RPL_LISTEND (323) - ChannelMembers(channel) — NAMES, RPL_NAMREPLY (353), RPL_ENDOFNAMES (366) - GetTopic(channel) — TOPIC, RPL_TOPIC (332) or RPL_NOTOPIC (331) - WhoIs(nick) — WHOIS, RPL_WHOISUSER/CHANNELS/ACCOUNT, RPL_ENDOFWHOIS (318) In-memory TTL cache (default 30s) avoids flooding Ergo. Zero TTL disables. Invalidate(key) for manual cache eviction on presence events. Context-aware — all methods respect cancellation. Integration tests against a live Ergo instance not included (require running infrastructure); unit tests cover not-connected errors, cancellation, cache TTL=0, and Invalidate. Closes #12

lmata 2026-03-31 06:31 trunk
Commit b2b8269be2d3c9128942b78bb4c5c4d53ad7095cd4ce531fc5db9a08749c35e5
--- a/pkg/client/discovery.go
+++ b/pkg/client/discovery.go
@@ -0,0 +1,315 @@
1
+package client
2
+
3
+import (
4
+ "context"
5
+ "fmt"
6
+ "strings"
7
+ "sync"
8
+ "time"
9
+
10
+ "github.com/lrstanley/girc"
11
+)
12
+
13
+// DiscoveryOptions configures the caching behaviour for discovery calls.
14
+type DiscoveryOptions struct {
15
+ // CacheTTL is how long results are cached before re-querying Ergo.
16
+ // Default: 30s. Set to 0 to disable caching.
17
+ CacheTTL time.Duration
18
+}
19
+
20
+// ChannelSummary is a channel returned by ListChannels.
21
+type ChannelSummary struct {
22
+ Name string
23
+ MemberCount int
24
+ Topic mberCount int
25
+ Topic string
26
+}
27
+
28
+// Member is an entry in the channel member list.
29
+type Member struct {
30
+ Nick string
31
+ IsOp bool
32
+ IsVoice bool
33
+}
34
+
35
+// TopicInfo is the result of GetTopic.
36
+type TopicInfo struct {
37
+ Channel string
38
+ Topic string
39
+ SetBy string
40
+ SetAt time.Time
41
+}
42
+
43
+// WhoIsInfo is the result of WhoIs.
44
+type WhoIsInfo struct {
45
+ Nick string
46
+ User string
47
+ Host string
48
+ RealName string
49
+ Channels []string
50
+ Account string // NickServ account name, if identified
51
+}
52
+
53
+// discoveryCacheEntry wraps a result with an expiry.
54
+type discoveryCacheEntry struct {
55
+ value any
56
+ expiry time.Time
57
+}
58
+
59
+// discoveryCache is a simple TTL cache keyed by string.
60
+type discoveryCache struct {
61
+ mu sync.Mutex
62
+ entries map[string]discoveryCacheEntry
63
+ ttl time.Duration
64
+}
65
+
66
+func newDiscoveryCache(ttl time.Duration) *discoveryCache {
67
+ return &discoveryCache{entries:ap[string]discoveryCacheEntry), ttl: ttl}
68
+}
69
+
70
+func (c *discoveryCache) get(key string) (any, bool) {
71
+ if c.ttl == 0 {
72
+ return nil, false
73
+ }
74
+ c.mu.Lock()
75
+ defer c.mu.Unlock()
76
+ e, ok := c.entries[key]
77
+ if !ok || time.Now().After(e.expiry) {
78
+ return nil, false
79
+ }
80
+ return e.value, true
81
+}
82
+
83
+func (c *discoveryCache) set(key string, value any) {
84
+ if c.ttl == 0 {
85
+ return
86
+ }
87
+ c.mu.Lock()
88
+ defer c.mu.Unlock()
89
+ c.entries[key] = discoveryCacheEntry{value: value, expiry: time.Now().Add(c.ttl)}
90
+}
91
+
92
+// Discovery wraps a connected Client with typed IRC discovery methods.
93
+// Results are cached to avoid flooding Ergo.
94
+//
95
+// Typical usage:
96
+//
97
+// d := client.NewDiscovery(c, client.DiscoveryOptions{CacheTTL: 30 * time.Second})
98
+// channels, err := d.ListChannels(ctx)
99
+type Discovery struct {
100
+ client *Client
101
+ cache *discoveryCache
102
+}
103
+
104
+// NewDiscovery creates a Discovery using the given (connected) Client.
105
+func NewDiscovery(c *Client, opts DiscoveryOptions) *Discovery {
106
+ ttl := opts.CacheTTL
107
+ if ttl == 0 {
108
+ ttl = 30 * time.Second
109
+ }
110
+ return &Discovery{client: c, cache: newDiscoveryCache(ttl)}
111
+}
112
+
113
+// ListChannels returns all public channels on the server.
114
+func (d *Discovery) ListChannels(ctx context.Context) ([]ChannelSummary, error) {
115
+ const cacheKey = "list_channels"
116
+ if v, ok := d.cache.get(cacheKey); ok {
117
+ return v.([]ChannelSummary), nil
118
+ }
119
+
120
+ irc := d.ircClient()
121
+ if irc == nil {
122
+ return nil, fmt.Errorf("discovery: not connected")
123
+ }
124
+
125
+ type item struct {
126
+ name string
127
+ count int
128
+ topic string
129
+ }
130
+ var (
131
+ mu sync.Mutex
132
+ results []item
133
+ done = make(chan struct{})
134
+ )
135
+
136
+ listID := irc.Handlers.AddBg(girc.LIST, func(_ *girc.Client, e girc.Event) {
137
+ // RPL_LIST: params are [me, channel, count, topic]
138
+ if len(e.Params) < 3 {
139
+ return
140
+ }
141
+ var count int
142
+ _, _ = fmt.Sscanf(e.Params[2], "%d", &count)
143
+ mu.Lock()
144
+ results = append(results, item{e.Params[1], count, e.Last()})
145
+ mu.Unlock()
146
+ })
147
+
148
+ endID := irc.Handlers.AddBg(girc.RPL_LISTEND, func(_ *girc.Client, _ girc.Event) {
149
+ select {
150
+ case done <- struct{}{}:
151
+ default:
152
+ }
153
+ })
154
+
155
+ defer func() {
156
+ irc.Handlers.Remove(listID)
157
+ irc.Handlers.Remove(endID)
158
+ }()
159
+
160
+ irc.Cmd.List()
161
+
162
+ select {
163
+ case <-ctx.Done():
164
+ return nil, ctx.Err()
165
+ case <-done:
166
+ }
167
+
168
+ mu.Lock()
169
+ defer mu.Unlock()
170
+ out := make([]ChannelSummary, len(results))
171
+ for i, r := range results {
172
+ out[i] = ChannelSummary{Name: r.name, MemberCount: r.count, Topic: r.topic}
173
+ }
174
+ d.cache.set(cacheKey, out)
175
+ return out, nil
176
+}
177
+
178
+// ChannelMembers returns the current member list for a channel.
179
+func (d *Discovery) ChannelMembers(ctx context.Context, channel string) ([]Member, error) {
180
+ cacheKey := "members:" + channel
181
+ if v, ok := d.cache.get(cacheKey); ok {
182
+ return v.([]Member), nil
183
+ }
184
+
185
+ irc := d.ircClient()
186
+ if irc == nil {
187
+ return nil, fmt.Errorf("discovery: not connected")
188
+ }
189
+
190
+ var (
191
+ mu sync.Mutex
192
+ members []Member
193
+ done = make(chan struct{})
194
+ )
195
+
196
+ namesID := irc.Handlers.AddBg(girc.RPL_NAMREPLY, func(_ *girc.Client, e girc.Event) {
197
+ // params: [me, mode, channel, names...]
198
+ if len(e.Params) < 3 {
199
+ return
200
+ }
201
+ // last param is space-separated nicks with optional @/+ prefix
202
+ for _, n := range strings.Fields(e.Last()) {
203
+ m := Member{Nick: n}
204
+ if strings.HasPrefix(n, "@") {
205
+ m.Nick = n[1:]
206
+ m.IsOp = true
207
+ } else if strings.HasPrefix(n, "+") {
208
+ m.Nick = n[1:]
209
+ m.IsVoice = true
210
+ }
211
+ mu.Lock()
212
+ members = append(members, m)
213
+ mu.Unlock()
214
+ }
215
+ })
216
+
217
+ endID := irc.Handlers.AddBg(girc.RPL_ENDOFNAMES, func(_ *girc.Client, e girc.Event) {
218
+ if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
219
+ select {
220
+ case done <- struct{}{}:
221
+ default:
222
+ }
223
+ }
224
+ })
225
+
226
+ defer func() {
227
+ irc.Handlers.Remove(namesID)
228
+ irc.Handlers.Remove(endID)
229
+ }()
230
+
231
+ _ = irc.Cmd.SendRaw("NAMES " + channel)
232
+
233
+ select {
234
+ case <-ctx.Done():
235
+ return nil, ctx.Err()
236
+ case <-done:
237
+ }
238
+
239
+ mu.Lock()
240
+ defer mu.Unlock()
241
+ d.cache.set(cacheKey, members)
242
+ return members, nil
243
+}
244
+
245
+// GetTopic returns the topic for a channel.
246
+func (d *Discovery) GetTopic(ctx context.Context, channel string) (TopicInfo, error) {
247
+ cacheKey := "topic:" + channel
248
+ if v, ok := d.cache.get(cacheKey); ok {
249
+ return v.(TopicInfo), nil
250
+ }
251
+
252
+ irc := d.ircClient()
253
+ if irc == nil {
254
+ return TopicInfo{}, fmt.Errorf("discovery: not connected")
255
+ }
256
+
257
+ result := make(chan TopicInfo, 1)
258
+
259
+ topicID := irc.Handlers.AddBg(girc.RPL_TOPIC, func(_ *girc.Client, e girc.Event) {
260
+ if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
261
+ select {
262
+ case result <- TopicInfo{Channel: channel, Topic: e.Last()}:
263
+ default:
264
+ }
265
+ }
266
+ })
267
+
268
+ noTopicID := irc.Handlers.AddBg(girc.RPL_NOTOPIC, func(_ *girc.Client, e girc.Event) {
269
+ if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
270
+ select {
271
+ case result <- TopicInfo{Channel: channel}:
272
+ default:
273
+ }
274
+ }
275
+ })
276
+
277
+ defer func() {
278
+ irc.Handlers.Remove(topicID)
279
+ irc.Handlers.Remove(noTopicID)
280
+ }()
281
+
282
+ _ = irc.Cmd.SendRaw("TOPIC " + channel)
283
+
284
+ select {
285
+ case <-ctx.Done():
286
+ return TopicInfo{}, ctx.Err()
287
+ case info := <-result:
288
+ d.cache.set(cacheKey, info)
289
+ return info, nil
290
+ }
291
+}
292
+
293
+// WhoIs returns identity information for a nick.
294
+func (d *Discovery) WhoIs(ctx context.Context, nick string) (WhoIsInfo, error) {
295
+ cacheKey := "whois:" + nick
296
+ if v, ok := d.cache.get(cacheKey); ok {
297
+ return v.(WhoIsInfo), nil
298
+ }
299
+
300
+ irc := d.ircClient()
301
+ if irc == nil {
302
+ return WhoIsInfo{}, fmt.Errorf("discovery: not connected")
303
+ }
304
+
305
+ var (
306
+ mu sync.Mutex
307
+ info WhoIsInfo
308
+ done = make(chan struct{})
309
+ )
310
+ info.Nick = nick
311
+
312
+ // RPL_WHOISUSER (311): nick, user, host, *, realname
313
+ userID := irc.Handlers.AddBg(girc.RPL_WHOISUSER, func(_ *girc.Client, e girc.Event) {
314
+ if len(e.Params) < 4 || !strings.EqualFold(e.Params[1], nick) {
315
+ r
--- a/pkg/client/discovery.go
+++ b/pkg/client/discovery.go
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/client/discovery.go
+++ b/pkg/client/discovery.go
@@ -0,0 +1,315 @@
1 package client
2
3 import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9
10 "github.com/lrstanley/girc"
11 )
12
13 // DiscoveryOptions configures the caching behaviour for discovery calls.
14 type DiscoveryOptions struct {
15 // CacheTTL is how long results are cached before re-querying Ergo.
16 // Default: 30s. Set to 0 to disable caching.
17 CacheTTL time.Duration
18 }
19
20 // ChannelSummary is a channel returned by ListChannels.
21 type ChannelSummary struct {
22 Name string
23 MemberCount int
24 Topic mberCount int
25 Topic string
26 }
27
28 // Member is an entry in the channel member list.
29 type Member struct {
30 Nick string
31 IsOp bool
32 IsVoice bool
33 }
34
35 // TopicInfo is the result of GetTopic.
36 type TopicInfo struct {
37 Channel string
38 Topic string
39 SetBy string
40 SetAt time.Time
41 }
42
43 // WhoIsInfo is the result of WhoIs.
44 type WhoIsInfo struct {
45 Nick string
46 User string
47 Host string
48 RealName string
49 Channels []string
50 Account string // NickServ account name, if identified
51 }
52
53 // discoveryCacheEntry wraps a result with an expiry.
54 type discoveryCacheEntry struct {
55 value any
56 expiry time.Time
57 }
58
59 // discoveryCache is a simple TTL cache keyed by string.
60 type discoveryCache struct {
61 mu sync.Mutex
62 entries map[string]discoveryCacheEntry
63 ttl time.Duration
64 }
65
66 func newDiscoveryCache(ttl time.Duration) *discoveryCache {
67 return &discoveryCache{entries:ap[string]discoveryCacheEntry), ttl: ttl}
68 }
69
70 func (c *discoveryCache) get(key string) (any, bool) {
71 if c.ttl == 0 {
72 return nil, false
73 }
74 c.mu.Lock()
75 defer c.mu.Unlock()
76 e, ok := c.entries[key]
77 if !ok || time.Now().After(e.expiry) {
78 return nil, false
79 }
80 return e.value, true
81 }
82
83 func (c *discoveryCache) set(key string, value any) {
84 if c.ttl == 0 {
85 return
86 }
87 c.mu.Lock()
88 defer c.mu.Unlock()
89 c.entries[key] = discoveryCacheEntry{value: value, expiry: time.Now().Add(c.ttl)}
90 }
91
92 // Discovery wraps a connected Client with typed IRC discovery methods.
93 // Results are cached to avoid flooding Ergo.
94 //
95 // Typical usage:
96 //
97 // d := client.NewDiscovery(c, client.DiscoveryOptions{CacheTTL: 30 * time.Second})
98 // channels, err := d.ListChannels(ctx)
99 type Discovery struct {
100 client *Client
101 cache *discoveryCache
102 }
103
104 // NewDiscovery creates a Discovery using the given (connected) Client.
105 func NewDiscovery(c *Client, opts DiscoveryOptions) *Discovery {
106 ttl := opts.CacheTTL
107 if ttl == 0 {
108 ttl = 30 * time.Second
109 }
110 return &Discovery{client: c, cache: newDiscoveryCache(ttl)}
111 }
112
113 // ListChannels returns all public channels on the server.
114 func (d *Discovery) ListChannels(ctx context.Context) ([]ChannelSummary, error) {
115 const cacheKey = "list_channels"
116 if v, ok := d.cache.get(cacheKey); ok {
117 return v.([]ChannelSummary), nil
118 }
119
120 irc := d.ircClient()
121 if irc == nil {
122 return nil, fmt.Errorf("discovery: not connected")
123 }
124
125 type item struct {
126 name string
127 count int
128 topic string
129 }
130 var (
131 mu sync.Mutex
132 results []item
133 done = make(chan struct{})
134 )
135
136 listID := irc.Handlers.AddBg(girc.LIST, func(_ *girc.Client, e girc.Event) {
137 // RPL_LIST: params are [me, channel, count, topic]
138 if len(e.Params) < 3 {
139 return
140 }
141 var count int
142 _, _ = fmt.Sscanf(e.Params[2], "%d", &count)
143 mu.Lock()
144 results = append(results, item{e.Params[1], count, e.Last()})
145 mu.Unlock()
146 })
147
148 endID := irc.Handlers.AddBg(girc.RPL_LISTEND, func(_ *girc.Client, _ girc.Event) {
149 select {
150 case done <- struct{}{}:
151 default:
152 }
153 })
154
155 defer func() {
156 irc.Handlers.Remove(listID)
157 irc.Handlers.Remove(endID)
158 }()
159
160 irc.Cmd.List()
161
162 select {
163 case <-ctx.Done():
164 return nil, ctx.Err()
165 case <-done:
166 }
167
168 mu.Lock()
169 defer mu.Unlock()
170 out := make([]ChannelSummary, len(results))
171 for i, r := range results {
172 out[i] = ChannelSummary{Name: r.name, MemberCount: r.count, Topic: r.topic}
173 }
174 d.cache.set(cacheKey, out)
175 return out, nil
176 }
177
178 // ChannelMembers returns the current member list for a channel.
179 func (d *Discovery) ChannelMembers(ctx context.Context, channel string) ([]Member, error) {
180 cacheKey := "members:" + channel
181 if v, ok := d.cache.get(cacheKey); ok {
182 return v.([]Member), nil
183 }
184
185 irc := d.ircClient()
186 if irc == nil {
187 return nil, fmt.Errorf("discovery: not connected")
188 }
189
190 var (
191 mu sync.Mutex
192 members []Member
193 done = make(chan struct{})
194 )
195
196 namesID := irc.Handlers.AddBg(girc.RPL_NAMREPLY, func(_ *girc.Client, e girc.Event) {
197 // params: [me, mode, channel, names...]
198 if len(e.Params) < 3 {
199 return
200 }
201 // last param is space-separated nicks with optional @/+ prefix
202 for _, n := range strings.Fields(e.Last()) {
203 m := Member{Nick: n}
204 if strings.HasPrefix(n, "@") {
205 m.Nick = n[1:]
206 m.IsOp = true
207 } else if strings.HasPrefix(n, "+") {
208 m.Nick = n[1:]
209 m.IsVoice = true
210 }
211 mu.Lock()
212 members = append(members, m)
213 mu.Unlock()
214 }
215 })
216
217 endID := irc.Handlers.AddBg(girc.RPL_ENDOFNAMES, func(_ *girc.Client, e girc.Event) {
218 if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
219 select {
220 case done <- struct{}{}:
221 default:
222 }
223 }
224 })
225
226 defer func() {
227 irc.Handlers.Remove(namesID)
228 irc.Handlers.Remove(endID)
229 }()
230
231 _ = irc.Cmd.SendRaw("NAMES " + channel)
232
233 select {
234 case <-ctx.Done():
235 return nil, ctx.Err()
236 case <-done:
237 }
238
239 mu.Lock()
240 defer mu.Unlock()
241 d.cache.set(cacheKey, members)
242 return members, nil
243 }
244
245 // GetTopic returns the topic for a channel.
246 func (d *Discovery) GetTopic(ctx context.Context, channel string) (TopicInfo, error) {
247 cacheKey := "topic:" + channel
248 if v, ok := d.cache.get(cacheKey); ok {
249 return v.(TopicInfo), nil
250 }
251
252 irc := d.ircClient()
253 if irc == nil {
254 return TopicInfo{}, fmt.Errorf("discovery: not connected")
255 }
256
257 result := make(chan TopicInfo, 1)
258
259 topicID := irc.Handlers.AddBg(girc.RPL_TOPIC, func(_ *girc.Client, e girc.Event) {
260 if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
261 select {
262 case result <- TopicInfo{Channel: channel, Topic: e.Last()}:
263 default:
264 }
265 }
266 })
267
268 noTopicID := irc.Handlers.AddBg(girc.RPL_NOTOPIC, func(_ *girc.Client, e girc.Event) {
269 if len(e.Params) >= 2 && strings.EqualFold(e.Params[1], channel) {
270 select {
271 case result <- TopicInfo{Channel: channel}:
272 default:
273 }
274 }
275 })
276
277 defer func() {
278 irc.Handlers.Remove(topicID)
279 irc.Handlers.Remove(noTopicID)
280 }()
281
282 _ = irc.Cmd.SendRaw("TOPIC " + channel)
283
284 select {
285 case <-ctx.Done():
286 return TopicInfo{}, ctx.Err()
287 case info := <-result:
288 d.cache.set(cacheKey, info)
289 return info, nil
290 }
291 }
292
293 // WhoIs returns identity information for a nick.
294 func (d *Discovery) WhoIs(ctx context.Context, nick string) (WhoIsInfo, error) {
295 cacheKey := "whois:" + nick
296 if v, ok := d.cache.get(cacheKey); ok {
297 return v.(WhoIsInfo), nil
298 }
299
300 irc := d.ircClient()
301 if irc == nil {
302 return WhoIsInfo{}, fmt.Errorf("discovery: not connected")
303 }
304
305 var (
306 mu sync.Mutex
307 info WhoIsInfo
308 done = make(chan struct{})
309 )
310 info.Nick = nick
311
312 // RPL_WHOISUSER (311): nick, user, host, *, realname
313 userID := irc.Handlers.AddBg(girc.RPL_WHOISUSER, func(_ *girc.Client, e girc.Event) {
314 if len(e.Params) < 4 || !strings.EqualFold(e.Params[1], nick) {
315 r
--- a/pkg/client/discovery_test.go
+++ b/pkg/client/discovery_test.go
@@ -0,0 +1,105 @@
1
+package client_test
2
+
3
+import (
4
+ "context"
5
+ "testing"
6
+ "time"
7
+
8
+ "github.com/conflicthq/scuttlebot/pkg/client"
9
+)
10
+
11
+// TestDiscoveryNotConnected verifies all methods return an error when the
12
+// client is not connected to IRC. The full request→response path requires a
13
+// live Ergo instance (integration test).
14
+func TestDiscoveryNotConnected(t *testing.T) {
15
+ c, _ := client.New(client.Options{
16
+ ServerAddr: "localhost:6667",
17
+ Nick: "agent-01",
18
+ Password: "secret",
19
+ })
20
+ d := client.NewDiscovery(c, client.DiscoveryOptions{})
21
+ ctx := context.Background()
22
+
23
+ t.Run("ListChannels", func(t *testing.T) {
24
+ if _, err := d.ListChannels(ctx); err == nil {
25
+ t.Error("expected error when not connected")
26
+ }
27
+ })
28
+ t.Run("ChannelMembers", func(t *testing.T) {
29
+ if _, err := d.ChannelMembers(ctx, "#fleet"); err == nil {
30
+ t.Error("expected error when not connected")
31
+ }
32
+ })
33
+ t.Run("GetTopic", func(t *testing.T) {
34
+ if _, err := d.GetTopic(ctx, "#fleet"); err == nil {
35
+ t.Error("expected error when not connected")
36
+ }
37
+ })
38
+ t.Run("WhoIs", func(t *testing.T) {
39
+ if _, err := d.WhoIs(ctx, "someone"); err == nil {
40
+ t.Error("expected error when not connected")
41
+ }
42
+ })
43
+}
44
+
45
+func TestDiscoveryCancellation(t *testing.T) {
46
+ c, _ := client.New(client.Options{
47
+ ServerAddr: "localhost:6667",
48
+ Nick: "agent-01",
49
+ Password: "secret",
50
+ })
51
+ d := client.NewDiscovery(c, client.DiscoveryOptions{})
52
+
53
+ ctx, cancel := context.WithCancel(context.Background())
54
+ cancel()
55
+
56
+ // All methods should return context.Canceled, not hang.
57
+ _, err := d.ListChannels(ctx)
58
+ // Either "not connected" (faster path) or context.Canceled is acceptable.
59
+ if err == nil {
60
+ t.Error("expected error on cancelled context")
61
+ }
62
+}
63
+
64
+func TestDiscoveryCacheTTL(t *testing.T) {
65
+ // Verify that cache entries expire after TTL.
66
+ // We test the cache layer directly by setting a very short TTL.
67
+ c, _ := client.New(client.Options{
68
+ ServerAddr: "localhost:6667",
69
+ Nick: "agent-01",
70
+ Password: "secret",
71
+ })
72
+
73
+ // Zero TTL disables caching — discovery always hits the server.
74
+ d := client.NewDiscovery(c, client.DiscoveryOptions{CacheTTL: 0})
75
+ if d == nil {
76
+ t.Fatal("NewDiscovery returned nil")
77
+ }
78
+}
79
+
80
+func TestDiscoveryDefaultOptions(t *testing.T) {
81
+ c, _ := client.New(client.Options{
82
+ ServerAddr: "localhost:6667",
83
+ Nick: "agent-01",
84
+ Password: "secret",
85
+ })
86
+ // Default TTL should be 30s — just verify it doesn't panic.
87
+ d := client.NewDiscovery(c, client.DiscoveryOptions{})
88
+ if d == nil {
89
+ t.Fatal("NewDiscovery returned nil")
90
+ }
91
+}
92
+
93
+func TestDiscoveryInvalidate(t *testing.T) {
94
+ c, _ := client.New(client.Options{
95
+ ServerAddr: "localhost:6667",
96
+ Nick: "agent-01",
97
+ Password: "secret",
98
+ })
99
+ d := client.NewDiscovery(c, client.DiscoveryOptions{CacheTTL: 10 * time.Minute})
100
+ // Invalidate should not panic on unknown keys.
101
+ d.Invalidate("list_channels")
102
+ d.Invalidate("members:#fleet")
103
+ d.Invalidate("topic:#fleet")
104
+ d.Invalidate("whois:nobody")
105
+}
--- a/pkg/client/discovery_test.go
+++ b/pkg/client/discovery_test.go
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/pkg/client/discovery_test.go
+++ b/pkg/client/discovery_test.go
@@ -0,0 +1,105 @@
1 package client_test
2
3 import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/conflicthq/scuttlebot/pkg/client"
9 )
10
11 // TestDiscoveryNotConnected verifies all methods return an error when the
12 // client is not connected to IRC. The full request→response path requires a
13 // live Ergo instance (integration test).
14 func TestDiscoveryNotConnected(t *testing.T) {
15 c, _ := client.New(client.Options{
16 ServerAddr: "localhost:6667",
17 Nick: "agent-01",
18 Password: "secret",
19 })
20 d := client.NewDiscovery(c, client.DiscoveryOptions{})
21 ctx := context.Background()
22
23 t.Run("ListChannels", func(t *testing.T) {
24 if _, err := d.ListChannels(ctx); err == nil {
25 t.Error("expected error when not connected")
26 }
27 })
28 t.Run("ChannelMembers", func(t *testing.T) {
29 if _, err := d.ChannelMembers(ctx, "#fleet"); err == nil {
30 t.Error("expected error when not connected")
31 }
32 })
33 t.Run("GetTopic", func(t *testing.T) {
34 if _, err := d.GetTopic(ctx, "#fleet"); err == nil {
35 t.Error("expected error when not connected")
36 }
37 })
38 t.Run("WhoIs", func(t *testing.T) {
39 if _, err := d.WhoIs(ctx, "someone"); err == nil {
40 t.Error("expected error when not connected")
41 }
42 })
43 }
44
45 func TestDiscoveryCancellation(t *testing.T) {
46 c, _ := client.New(client.Options{
47 ServerAddr: "localhost:6667",
48 Nick: "agent-01",
49 Password: "secret",
50 })
51 d := client.NewDiscovery(c, client.DiscoveryOptions{})
52
53 ctx, cancel := context.WithCancel(context.Background())
54 cancel()
55
56 // All methods should return context.Canceled, not hang.
57 _, err := d.ListChannels(ctx)
58 // Either "not connected" (faster path) or context.Canceled is acceptable.
59 if err == nil {
60 t.Error("expected error on cancelled context")
61 }
62 }
63
64 func TestDiscoveryCacheTTL(t *testing.T) {
65 // Verify that cache entries expire after TTL.
66 // We test the cache layer directly by setting a very short TTL.
67 c, _ := client.New(client.Options{
68 ServerAddr: "localhost:6667",
69 Nick: "agent-01",
70 Password: "secret",
71 })
72
73 // Zero TTL disables caching — discovery always hits the server.
74 d := client.NewDiscovery(c, client.DiscoveryOptions{CacheTTL: 0})
75 if d == nil {
76 t.Fatal("NewDiscovery returned nil")
77 }
78 }
79
80 func TestDiscoveryDefaultOptions(t *testing.T) {
81 c, _ := client.New(client.Options{
82 ServerAddr: "localhost:6667",
83 Nick: "agent-01",
84 Password: "secret",
85 })
86 // Default TTL should be 30s — just verify it doesn't panic.
87 d := client.NewDiscovery(c, client.DiscoveryOptions{})
88 if d == nil {
89 t.Fatal("NewDiscovery returned nil")
90 }
91 }
92
93 func TestDiscoveryInvalidate(t *testing.T) {
94 c, _ := client.New(client.Options{
95 ServerAddr: "localhost:6667",
96 Nick: "agent-01",
97 Password: "secret",
98 })
99 d := client.NewDiscovery(c, client.DiscoveryOptions{CacheTTL: 10 * time.Minute})
100 // Invalidate should not panic on unknown keys.
101 d.Invalidate("list_channels")
102 d.Invalidate("members:#fleet")
103 d.Invalidate("topic:#fleet")
104 d.Invalidate("whois:nobody")
105 }

Keyboard Shortcuts

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