217 lines
6.8 KiB
Go
217 lines
6.8 KiB
Go
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}
|
|
}
|
|
}
|