ScuttleBot

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

Keyboard Shortcuts

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