package main import ( "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "time" ) 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 } // scriptedBrain answers nest requests with a canned reply per call. type scriptedBrain struct { replies []string calls int } func (b *scriptedBrain) Name() string { return "scripted" } func (b *scriptedBrain) Ask(ctx context.Context, system, prompt string) (string, int, error) { r := "[mock] fallback" if b.calls < len(b.replies) { r = b.replies[b.calls] } b.calls++ return r, 10, nil } func newScriptedServer(t *testing.T, replies ...string) (*httptest.Server, *Server) { t.Helper() ts, s := newTestNestServer(t) s.p.Flock.brain = &scriptedBrain{replies: replies} return ts, s } func nestOf(s *Server, id string) Nest { n, _ := s.nests.Get(id) return n } 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"] != "claim" && m["kind"] != "deliver" && m["kind"] != "decline" { t.Fatalf("crow reply must be a responsive kind, got %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) } } func TestParseAddressing(t *testing.T) { members := []string{"Hugin", "Munin", "owner"} if got := parseAddressing("Taking it, @Hugin", members); got != "Hugin" { t.Fatalf("want Hugin, got %q", got) } if got := parseAddressing("hello @Nobody", members); got != "" { t.Fatalf("unknown handle must not resolve, got %q", got) } if got := parseAddressing("ask @Munin.", members); got != "Munin" { t.Fatalf("want trailing period stripped, got %q", got) } } func TestAddressedCrowMustRespond(t *testing.T) { ts, s := newScriptedServer(t, "some idle chatter", "again not a reply") id := newNest(t, ts, "Lab", `"Hugin"`) s.msgs.Append(Message{NestID: id, Kind: "request", Author: "owner", To: "Hugin", Text: "count the eggs"}) req := s.msgs.All(id)[0] s.respondToRequest(nestOf(s, id), req) msgs := s.msgs.All(id) if len(msgs) != 2 || msgs[1].Kind != "flag" || msgs[1].Author != "Sentry" || msgs[1].To != "Hugin" { t.Fatalf("two non-responsive replies must end in a Sentry flag, got %+v", msgs) } if msgs[1].RefersTo != req.MsgID() { t.Fatalf("flag must point at the ignored request, got %q", msgs[1].RefersTo) } } func TestRespondedRequestNoFlag(t *testing.T) { ts, s := newScriptedServer(t, "KIND: claim\nOn it, @owner.") id := newNest(t, ts, "Lab", `"Hugin"`) s.msgs.Append(Message{NestID: id, Kind: "request", Author: "owner", To: "Hugin", Text: "count the eggs"}) req := s.msgs.All(id)[0] s.respondToRequest(nestOf(s, id), req) msgs := s.msgs.All(id) if len(msgs) != 2 || msgs[1].Kind != "claim" || msgs[1].Text != "On it, @owner." { t.Fatalf("a responsive first reply must post the claim, got %+v", msgs) } } func TestDeliverAddressesRequester(t *testing.T) { ts, _ := newTestNestServer(t) id := newNest(t, ts, "Lab", `"Munin"`) _, req := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"owner","kind":"request","text":"count the eggs","to":"Munin"}`) code, d := post(t, ts.URL+"/api/nests/"+id+"/messages", `{"author":"Munin","kind":"deliver","text":"three eggs","refers_to":"`+req["msg_id"].(string)+`"}`) if code != http.StatusCreated { t.Fatalf("deliver: %d %v", code, d) } if d["to"] != "owner" { t.Fatalf("deliver must go back to the requester, got to=%v", d["to"]) } } // promote earns crows the right to speak unprompted (trust ladder): their own // record on task type "other" reaches Regular. func promote(s *Server, crows ...string) { for _, c := range crows { s.p.Ledger.Crows[c+"|other"] = &Record{Approved: 10, Streak: 10, Level: Regular} } }