ScuttleBot

scuttlebot / pkg / sessionrelay / sessionrelay_test.go
Blame History Raw 287 lines
1
package sessionrelay
2
3
import (
4
"context"
5
"encoding/json"
6
"net/http"
7
"net/http/httptest"
8
"os"
9
"slices"
10
"testing"
11
"time"
12
)
13
14
func TestHTTPConnectorPostMessagesAndTouch(t *testing.T) {
15
t.Helper()
16
17
base := time.Date(2026, 3, 31, 22, 0, 0, 0, time.UTC)
18
var gotAuth []string
19
var posted []string
20
var touched []string
21
22
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
23
gotAuth = append(gotAuth, r.Header.Get("Authorization"))
24
switch {
25
case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/general/messages":
26
var body map[string]string
27
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
28
t.Fatalf("decode general post body: %v", err)
29
}
30
posted = append(posted, "general:"+body["nick"]+":"+body["text"])
31
w.WriteHeader(http.StatusNoContent)
32
case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/release/messages":
33
var body map[string]string
34
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
35
t.Fatalf("decode release post body: %v", err)
36
}
37
posted = append(posted, "release:"+body["nick"]+":"+body["text"])
38
w.WriteHeader(http.StatusNoContent)
39
case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/general/presence":
40
var body map[string]string
41
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
42
t.Fatalf("decode general touch body: %v", err)
43
}
44
touched = append(touched, "general:"+body["nick"])
45
w.WriteHeader(http.StatusNoContent)
46
case r.Method == http.MethodPost && r.URL.Path == "/v1/channels/release/presence":
47
var body map[string]string
48
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
49
t.Fatalf("decode release touch body: %v", err)
50
}
51
touched = append(touched, "release:"+body["nick"])
52
w.WriteHeader(http.StatusNoContent)
53
case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/general/messages":
54
_ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{
55
{"at": base.Add(-time.Second).Format(time.RFC3339Nano), "nick": "old", "text": "ignore"},
56
{"at": base.Add(time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: check README"},
57
}})
58
case r.Method == http.MethodGet && r.URL.Path == "/v1/channels/release/messages":
59
_ = json.NewEncoder(w).Encode(map[string]any{"messages": []map[string]string{
60
{"at": base.Add(2 * time.Second).Format(time.RFC3339Nano), "nick": "glengoolie", "text": "codex-test: /join #task-42"},
61
}})
62
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/register":
63
w.WriteHeader(http.StatusCreated)
64
default:
65
http.NotFound(w, r)
66
}
67
}))
68
defer srv.Close()
69
70
conn, err := New(Config{
71
Transport: TransportHTTP,
72
URL: srv.URL,
73
Token: "test-token",
74
Channel: "general",
75
Channels: []string{"general", "release"},
76
Nick: "codex-test",
77
HTTPClient: srv.Client(),
78
})
79
if err != nil {
80
t.Fatal(err)
81
}
82
if err := conn.Connect(context.Background()); err != nil {
83
t.Fatal(err)
84
}
85
if err := conn.Post(context.Background(), "online"); err != nil {
86
t.Fatal(err)
87
}
88
if want := []string{"general:codex-test:online", "release:codex-test:online"}; !slices.Equal(posted, want) {
89
t.Fatalf("posted = %#v, want %#v", posted, want)
90
}
91
for _, auth := range gotAuth {
92
if auth != "Bearer test-token" {
93
t.Fatalf("authorization = %q", auth)
94
}
95
}
96
97
msgs, err := conn.MessagesSince(context.Background(), base)
98
if err != nil {
99
t.Fatal(err)
100
}
101
if len(msgs) != 2 {
102
t.Fatalf("MessagesSince len = %d, want 2", len(msgs))
103
}
104
if msgs[0].Channel != "#general" || msgs[1].Channel != "#release" {
105
t.Fatalf("MessagesSince channels = %#v", msgs)
106
}
107
108
if err := conn.Touch(context.Background()); err != nil {
109
t.Fatal(err)
110
}
111
if want := []string{"general:codex-test", "release:codex-test"}; !slices.Equal(touched, want) {
112
t.Fatalf("touches = %#v, want %#v", touched, want)
113
}
114
}
115
116
func TestHTTPConnectorJoinPartAndControlChannel(t *testing.T) {
117
t.Helper()
118
119
conn, err := New(Config{
120
Transport: TransportHTTP,
121
URL: "http://example.com",
122
Token: "test-token",
123
Channel: "general",
124
Channels: []string{"general", "release"},
125
Nick: "codex-test",
126
})
127
if err != nil {
128
t.Fatal(err)
129
}
130
131
if got := conn.ControlChannel(); got != "#general" {
132
t.Fatalf("ControlChannel = %q, want #general", got)
133
}
134
if err := conn.JoinChannel(context.Background(), "#task-42"); err != nil {
135
t.Fatal(err)
136
}
137
if want := []string{"#general", "#release", "#task-42"}; !slices.Equal(conn.Channels(), want) {
138
t.Fatalf("Channels after join = %#v, want %#v", conn.Channels(), want)
139
}
140
if err := conn.PartChannel(context.Background(), "#general"); err == nil {
141
t.Fatal("PartChannel(control) = nil, want error")
142
}
143
if err := conn.PartChannel(context.Background(), "#release"); err != nil {
144
t.Fatal(err)
145
}
146
if want := []string{"#general", "#task-42"}; !slices.Equal(conn.Channels(), want) {
147
t.Fatalf("Channels after part = %#v, want %#v", conn.Channels(), want)
148
}
149
}
150
151
func TestIRCRegisterOrRotateCreatesAndDeletes(t *testing.T) {
152
t.Helper()
153
154
var deletedPath string
155
var registerChannels []string
156
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
157
switch {
158
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/register":
159
var body struct {
160
Channels []string `json:"channels"`
161
}
162
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
163
t.Fatalf("decode register body: %v", err)
164
}
165
registerChannels = body.Channels
166
w.WriteHeader(http.StatusCreated)
167
_ = json.NewEncoder(w).Encode(map[string]any{
168
"credentials": map[string]string{"passphrase": "created-pass"},
169
})
170
case r.Method == http.MethodDelete && r.URL.Path == "/v1/agents/codex-1234":
171
deletedPath = r.URL.Path
172
w.WriteHeader(http.StatusNoContent)
173
default:
174
http.NotFound(w, r)
175
}
176
}))
177
defer srv.Close()
178
179
conn := &ircConnector{
180
http: srv.Client(),
181
apiURL: srv.URL,
182
token: "test-token",
183
nick: "codex-1234",
184
primary: "#general",
185
channels: []string{"#general", "#release"},
186
agentType: "worker",
187
deleteOnClose: true,
188
}
189
190
created, pass, err := conn.registerOrRotate(context.Background())
191
if err != nil {
192
t.Fatal(err)
193
}
194
if !created || pass != "created-pass" {
195
t.Fatalf("registerOrRotate = (%v, %q), want (true, created-pass)", created, pass)
196
}
197
if want := []string{"#general", "#release"}; !slices.Equal(registerChannels, want) {
198
t.Fatalf("register channels = %#v, want %#v", registerChannels, want)
199
}
200
conn.registeredByRelay = created
201
if err := conn.cleanupRegistration(context.Background()); err != nil {
202
t.Fatal(err)
203
}
204
if deletedPath != "/v1/agents/codex-1234" {
205
t.Fatalf("delete path = %q", deletedPath)
206
}
207
}
208
209
func TestIRCRegisterOrRotateFallsBackToRotate(t *testing.T) {
210
t.Helper()
211
212
var rotateCalled bool
213
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
214
switch {
215
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/register":
216
w.WriteHeader(http.StatusConflict)
217
case r.Method == http.MethodPost && r.URL.Path == "/v1/agents/codex-1234/rotate":
218
rotateCalled = true
219
_ = json.NewEncoder(w).Encode(map[string]string{"passphrase": "rotated-pass"})
220
default:
221
http.NotFound(w, r)
222
}
223
}))
224
defer srv.Close()
225
226
conn := &ircConnector{
227
http: srv.Client(),
228
apiURL: srv.URL,
229
token: "test-token",
230
nick: "codex-1234",
231
primary: "#general",
232
channels: []string{"#general"},
233
agentType: "worker",
234
}
235
236
created, pass, err := conn.registerOrRotate(context.Background())
237
if err != nil {
238
t.Fatal(err)
239
}
240
if created {
241
t.Fatal("created = true, want false when register conflicts")
242
}
243
if !rotateCalled || pass != "rotated-pass" {
244
t.Fatalf("rotate fallback = (called=%v, pass=%q)", rotateCalled, pass)
245
}
246
}
247
248
func TestWriteChannelStateFile(t *testing.T) {
249
t.Helper()
250
251
dir := t.TempDir()
252
path := dir + "/channels.env"
253
if err := WriteChannelStateFile(path, "general", []string{"#general", "#release"}); err != nil {
254
t.Fatal(err)
255
}
256
data, err := os.ReadFile(path)
257
if err != nil {
258
t.Fatal(err)
259
}
260
want := "SCUTTLEBOT_CHANNEL=general\nSCUTTLEBOT_CHANNELS=general,release\n"
261
if string(data) != want {
262
t.Fatalf("state file = %q, want %q", string(data), want)
263
}
264
}
265
266
func TestParseBrokerCommand(t *testing.T) {
267
t.Helper()
268
269
tests := []struct {
270
input string
271
want BrokerCommand
272
ok bool
273
}{
274
{input: "/channels", want: BrokerCommand{Name: "channels"}, ok: true},
275
{input: "/join task-42", want: BrokerCommand{Name: "join", Channel: "#task-42"}, ok: true},
276
{input: "/part #release", want: BrokerCommand{Name: "part", Channel: "#release"}, ok: true},
277
{input: "please read README", ok: false},
278
}
279
280
for _, tt := range tests {
281
got, ok := ParseBrokerCommand(tt.input)
282
if ok != tt.ok || got != tt.want {
283
t.Fatalf("ParseBrokerCommand(%q) = (%#v, %v), want (%#v, %v)", tt.input, got, ok, tt.want, tt.ok)
284
}
285
}
286
}
287

Keyboard Shortcuts

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