diff --git a/nestapi_test.go b/nestapi_test.go new file mode 100644 index 0000000..acef3f4 --- /dev/null +++ b/nestapi_test.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" +) + +// newTestNestServer is newTestServer plus a message store. +func newTestNestServer(t *testing.T) (*httptest.Server, *Server) { + t.Helper() + ts, s := newTestServer(t, DefaultGuardrails(), time.Minute) + ms, err := LoadMessageStore(filepath.Join(t.TempDir(), "messages")) + if err != nil { + t.Fatal(err) + } + s.WithMessages(ms) + return ts, s +} + +func newNest(t *testing.T, ts *httptest.Server, name, crows string) string { + t.Helper() + code, out := post(t, ts.URL+"/api/nests", `{"name":"`+name+`","crows":[`+crows+`]}`) + if code != http.StatusCreated { + t.Fatalf("create nest: %d %v", code, out) + } + return out["nest_id"].(string) +} + +func nestMessages(t *testing.T, ts *httptest.Server, nestID string) []map[string]any { + t.Helper() + resp, err := http.Get(ts.URL + "/api/nests/" + nestID + "/messages") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("get messages: %d", resp.StatusCode) + } + var msgs []map[string]any + if err := json.NewDecoder(resp.Body).Decode(&msgs); err != nil { + t.Fatal(err) + } + return msgs +} + +// awaitMessages polls until the nest has at least n messages or the deadline passes. +func awaitMessages(t *testing.T, ts *httptest.Server, nestID string, n int) []map[string]any { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for { + msgs := nestMessages(t, ts, nestID) + if len(msgs) >= n || time.Now().After(deadline) { + return msgs + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestPostMessageToNest(t *testing.T) { + ts, _ := newTestNestServer(t) + id := newNest(t, ts, "Lab", `"Hugin"`) + code, msg := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"owner","text":"hello, flock"}`) + if code != http.StatusCreated { + t.Fatalf("want 201, got %d %v", code, msg) + } + if msg["kind"] != "say" || msg["author"] != "owner" || msg["text"] != "hello, flock" { + t.Fatalf("bad record: %v", msg) + } + if msg["msg_id"] != id+"-1" { + t.Fatalf("want msg_id %s-1, got %v", id, msg["msg_id"]) + } + got := nestMessages(t, ts, id) + if len(got) != 1 || got[0]["text"] != "hello, flock" { + t.Fatalf("log missing the message: %v", got) + } +} + +func TestPostMessageUnknownNest(t *testing.T) { + ts, _ := newTestNestServer(t) + code, out := post(t, ts.URL+"/api/nests/nope/messages", `{"author":"owner","text":"hi"}`) + if code != http.StatusNotFound || out["message"] == nil { + t.Fatalf("want 404 with message, got %d %v", code, out) + } +} + +func TestPostMessageTriggersOneResponse(t *testing.T) { + ts, _ := newTestNestServer(t) + id := newNest(t, ts, "Lab", `"Hugin"`) + code, req := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"owner","kind":"request","text":"count the eggs","to":"Hugin"}`) + if code != http.StatusCreated { + t.Fatalf("request post: %d %v", code, req) + } + msgs := awaitMessages(t, ts, id, 2) + replies := 0 + for _, m := range msgs { + if m["author"] == "Hugin" { + replies++ + if m["kind"] != "say" && m["kind"] != "claim" && m["kind"] != "deliver" { + t.Fatalf("crow reply has a bad kind: %v", m["kind"]) + } + if m["refers_to"] != req["msg_id"] { + t.Fatalf("crow reply must point at the request, got %v", m["refers_to"]) + } + } + } + if replies != 1 { + t.Fatalf("want exactly one Hugin message, got %d in %v", replies, msgs) + } +} + +func TestNoIdleResponse(t *testing.T) { + ts, _ := newTestNestServer(t) + id := newNest(t, ts, "Lab", `"Hugin"`) + code, out := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"owner","text":"just thinking out loud"}`) + if code != http.StatusCreated { + t.Fatalf("say post: %d %v", code, out) + } + time.Sleep(300 * time.Millisecond) + if got := nestMessages(t, ts, id); len(got) != 1 { + t.Fatalf("an unaddressed say must not summon a crow, log: %v", got) + } +} diff --git a/nestsse_test.go b/nestsse_test.go new file mode 100644 index 0000000..5197306 --- /dev/null +++ b/nestsse_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "bufio" + "net/http" + "testing" + "time" +) + +// TestNestStreamSSE checks GET /api/events?nest_id= streams nest.message events +// and replays history from Last-Event-ID (the EVENTS.md nest SSE contract). +func TestNestStreamSSE(t *testing.T) { + ts, s := newTestNestServer(t) + id := newNest(t, ts, "Lab", `"Hugin"`) + s.hub.OpenNest(id) + if _, out := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"owner","text":"hello"}`); out["msg_id"] == nil { + t.Fatalf("post failed: %v", out) + } + + req, err := http.NewRequest("GET", ts.URL+"/api/events?nest_id="+id, nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" { + t.Fatalf("content type %q", ct) + } + + // Read in a goroutine with a deadline so a stuck stream fails, not hangs. + got := make(chan bool, 1) + go func() { + sc := bufio.NewScanner(resp.Body) + for sc.Scan() { + if sc.Text() == "event: nest.message" { + got <- true + return + } + } + got <- false + }() + select { + case ok := <-got: + if !ok { + t.Fatal("stream ended before nest.message appeared") + } + case <-time.After(2 * time.Second): + t.Fatal("no nest.message event within 2s") + } +}