diff --git a/boss_test.go b/boss_test.go index 0330e5c..7f991e7 100644 --- a/boss_test.go +++ b/boss_test.go @@ -3,9 +3,11 @@ package main import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "path/filepath" + "slices" "strings" "sync" "testing" @@ -333,3 +335,172 @@ func TestCrowsRegistryEmptyWithoutStore(t *testing.T) { t.Fatalf("want no crows, got %v", out.Crows) } } + +// --- #43: Boss creates nests and assigns members --- + +// listNests returns every nest from GET /api/nests. +func listNests(t *testing.T, ts *httptest.Server) []map[string]any { + t.Helper() + resp, err := http.Get(ts.URL + "/api/nests") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /api/nests: want 200, got %d", resp.StatusCode) + } + var out struct { + Nests []map[string]any `json:"nests"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatal(err) + } + return out.Nests +} + +func findNest(t *testing.T, ts *httptest.Server, name string) map[string]any { + t.Helper() + for _, n := range listNests(t, ts) { + if n["name"] == name { + return n + } + } + return nil +} + +func nestCrowNames(nest map[string]any) []string { + raw, _ := nest["crows"].([]any) + out := make([]string, 0, len(raw)) + for _, c := range raw { + out = append(out, c.(string)) + } + slices.Sort(out) + return out +} + +func TestBossCreatesNest(t *testing.T) { + s, bb := newBossServer(t, + `SPIN {"name":"Ash","persona":"Soot","trust":"stranger"}`, + `NEST {"name":"Launch","crows":["Hugin","Ash"]}`, + ) + ts := serve(s) + defer ts.Close() + labID := newNest(t, ts, "Lab", `"Hugin"`) + + // Ash must exist as a registry crow: spin it through Boss first. + askBoss(t, ts, labID, "spin up a crow named Ash") + bossSay(t, ts, labID, 2) + + askBoss(t, ts, labID, "make a nest Launch with Hugin and Ash") + + var launch map[string]any + deadline := time.Now().Add(2 * time.Second) + for launch == nil && time.Now().Before(deadline) { + launch = findNest(t, ts, "Launch") + if launch == nil { + time.Sleep(20 * time.Millisecond) + } + } + if launch == nil { + t.Fatalf("Boss must create the nest Launch, nests: %v", listNests(t, ts)) + } + if got := nestCrowNames(launch); !slices.Equal(got, []string{"Ash", "Hugin"}) { + t.Fatalf("Launch crows must be exactly [Ash Hugin], got %v", got) + } + + // Boss announces membership in the NEW nest: a say to the owner. + announced := false + for _, m := range awaitMessages(t, ts, launch["nest_id"].(string), 1) { + if m["author"] == "Boss" && m["kind"] == "say" && m["to"] == "owner" { + announced = true + } + } + if !announced { + t.Fatalf("Boss must post a say to the owner in Launch, got %v", + nestMessages(t, ts, launch["nest_id"].(string))) + } + + // And confirms in the requesting nest. + confirmed := false + for _, m := range awaitMessages(t, ts, labID, 4) { + if m["author"] == "Boss" && m["kind"] == "say" && strings.Contains(m["text"].(string), "Launch") { + confirmed = true + } + } + if !confirmed { + t.Fatalf("Boss must confirm the new nest in Lab, got %v", nestMessages(t, ts, labID)) + } + if bb.count() != 2 { + t.Fatalf("two orders are two model calls, got %d", bb.count()) + } +} + +func TestBossNestUnknownMemberRefused(t *testing.T) { + s, _ := newBossServer(t, `NEST {"name":"Nope","crows":["Gandalf"]}`) + ts := serve(s) + defer ts.Close() + labID := newNest(t, ts, "Lab", `"Hugin"`) + + before := len(listNests(t, ts)) + askBoss(t, ts, labID, "make a nest Nope with Gandalf") + + say := bossSay(t, ts, labID, 2) + text, _ := say["text"].(string) + if strings.HasPrefix(text, "NEST") { + t.Fatalf("Boss must interpret the order, not echo it back: %q", text) + } + if !strings.Contains(text, "Gandalf") { + t.Fatalf("the refusal must name the unknown member, got %q", text) + } + if after := len(listNests(t, ts)); after != before { + t.Fatalf("an unknown member must refuse the whole create: %d before, %d after", before, after) + } +} + +func TestBossNestCapRespected(t *testing.T) { + s, _ := newBossServer(t, `NEST {"name":"Overflow","crows":["Hugin"]}`) + ts := serve(s) + defer ts.Close() + labID := newNest(t, ts, "Lab", `"Hugin"`) + + // Fill the store to its cap: todo + Lab + (maxNests-2) pads. + for i := 0; 2+i < maxNests; i++ { + if _, err := s.nests.Create(fmt.Sprintf("Pad %d", i), []string{"Hugin"}); err != nil { + t.Fatalf("padding nests: %v", err) + } + } + if n := len(listNests(t, ts)); n != maxNests { + t.Fatalf("test setup: want %d nests, got %d", maxNests, n) + } + + askBoss(t, ts, labID, "make a nest Overflow with Hugin") // 201: no 500 at the front door + say := bossSay(t, ts, labID, 2) + text, _ := say["text"].(string) + if !strings.Contains(text, fmt.Sprintf("at most %d nests", maxNests)) { + t.Fatalf("Boss must report the store's cap refusal, got %q", text) + } + if findNest(t, ts, "Overflow") != nil { + t.Fatal("a capped create must not make a nest") + } +} + +func TestBossNestGoesThroughFlock(t *testing.T) { + s, _ := newBossServer(t, `NEST {"name":"Launch","crows":["Hugin","Munin"]}`) + s.p.Flock.sem = make(chan struct{}, 0) // saturated flock: ask must wait, never act + ts := serve(s) + defer ts.Close() + labID := newNest(t, ts, "Lab", `"Hugin"`) + + before := len(listNests(t, ts)) + askBoss(t, ts, labID, "make a nest Launch with Hugin and Munin") + time.Sleep(300 * time.Millisecond) + + if n := len(listNests(t, ts)); n != before { + t.Fatalf("a saturated flock must not create a nest, got %v", listNests(t, ts)) + } + for _, m := range nestMessages(t, ts, labID) { + if m["author"] == "Boss" { + t.Fatalf("a saturated flock must not produce a Boss message, got %v", m) + } + } +}