package main import ( "encoding/json" "errors" "fmt" "net/http" "path/filepath" "slices" "strings" "testing" "time" ) func TestPickCoreFirstThenBorrow(t *testing.T) { f := NewFlock(MockBrain{}, DefaultGuardrails(), nil) nest := Nest{ID: "launch", Crows: []string{"Pepper", "Inky"}} // Within the core, picks rotate. if got := f.pick(nest, 1); !slices.Equal(got, []string{"Pepper"}) { t.Fatalf("first solo pick: %v", got) } if got := f.pick(nest, 1); !slices.Equal(got, []string{"Inky"}) { t.Fatalf("second solo pick should rotate within the core: %v", got) } // A murder of 3 needs one more than the core: the whole core flies, plus one borrowed. got := f.pick(nest, 3) if !slices.Contains(got, "Pepper") || !slices.Contains(got, "Inky") { t.Fatalf("core crows should fly first: %v", got) } if got[2] != "Hugin" { t.Fatalf("first borrowed crow should be the first roster crow outside the core: %v", got) } // Borrowing rotates too, and never picks a core crow. if got := f.pick(nest, 3); got[2] != "Munin" { t.Fatalf("borrowing should rotate: %v", got) } // The todo nest keeps the old whole-flock rotation, unaffected by other nests. if got := f.pick(defaultNest, 3); !slices.Equal(got, roster[:3]) { t.Fatalf("todo nest should rotate the whole roster: %v", got) } } func TestPickWholeFlockCore(t *testing.T) { f := NewFlock(MockBrain{}, DefaultGuardrails(), nil) nest := Nest{ID: "all", Crows: slices.Clone(roster)} got := f.pick(nest, 3) if !slices.Equal(got, roster[:3]) { t.Fatalf("got %v", got) } } func TestNestValidation(t *testing.T) { s, _ := LoadNests(filepath.Join(t.TempDir(), "nests.json")) bad := []struct { name string crows []string }{ {" ", []string{"Pepper"}}, {strings.Repeat("a", 41), []string{"Pepper"}}, {"!!!", []string{"Pepper"}}, {"No crows", nil}, {"Too many", append(slices.Clone(roster), "Hugin")}, {"Stranger", []string{"Pepper", "Tweety"}}, {"Sentry inside", []string{"Sentry"}}, {"Twice", []string{"Pepper", "Pepper"}}, } for _, b := range bad { _, err := s.Create(b.name, b.crows) var ne *NestError if err == nil || !errors.As(err, &ne) || ne.Clash { t.Errorf("Create(%q, %v): want invalid, got %v", b.name, b.crows, err) } } n, err := s.Create(" Launch -- Copy! ", []string{"Pepper", "Inky"}) if err != nil || n.ID != "launch-copy" || n.Name != "Launch -- Copy!" { t.Fatalf("got %+v, %v", n, err) } if _, err := s.Create(strings.Repeat("a", 40), []string{"Jet"}); err != nil { t.Fatalf("40 characters should be fine: %v", err) } for _, name := range []string{"launch copy", "TODO"} { _, err := s.Create(name, []string{"Jet"}) var ne *NestError if !errors.As(err, &ne) || !ne.Clash { t.Errorf("Create(%q): want clash, got %v", name, err) } } } func TestNestsPersistAndCap(t *testing.T) { path := filepath.Join(t.TempDir(), "nests.json") s, _ := LoadNests(path) for i := 1; i < maxNests; i++ { // todo is nest number one if _, err := s.Create(fmt.Sprintf("Nest %d", i), []string{"Soot"}); err != nil { t.Fatalf("nest %d: %v", i, err) } } if _, err := s.Create("One too many", []string{"Soot"}); err == nil { t.Fatal("want the nest cap to refuse") } reloaded, err := LoadNests(path) if err != nil { t.Fatal(err) } all := reloaded.All() if len(all) != maxNests || all[0].ID != "todo" || all[1].ID != "nest-1" || all[maxNests-1].ID != "nest-19" { t.Fatalf("reload lost order or nests: %d nests, first %v", len(all), all[:2]) } if n, ok := reloaded.Get("nest-7"); !ok || !slices.Equal(n.Crows, []string{"Soot"}) { t.Fatalf("Get after reload: %+v %v", n, ok) } } func TestServerNests(t *testing.T) { ts, _ := newTestServer(t, DefaultGuardrails(), time.Minute) var got struct { Roster []string `json:"roster"` Nests []Nest `json:"nests"` } getJSON(t, ts.URL+"/api/nests", &got) if !slices.Equal(got.Roster, roster) || len(got.Nests) != 1 || got.Nests[0].ID != "todo" || got.Nests[0].Crows == nil { t.Fatalf("GET /api/nests: %+v", got) } code, out := post(t, ts.URL+"/api/nests", `{"name":"Launch copy","crows":["Pepper","Inky"]}`) if code != http.StatusCreated || out["nest_id"] != "launch-copy" { t.Fatalf("create: %d %v", code, out) } if code, out := post(t, ts.URL+"/api/nests", `{"name":"launch COPY","crows":["Jet"]}`); code != http.StatusConflict || out["message"] == nil { t.Fatalf("clash: want 409 with message, got %d %v", code, out) } if code, out := post(t, ts.URL+"/api/nests", `{"name":"Bad","crows":["Elder"]}`); code != http.StatusBadRequest || out["message"] == nil { t.Fatalf("bad crow: want 400 with message, got %d %v", code, out) } if code, _ := post(t, ts.URL+"/api/nests", `{"name":"x","crows":`+strings.Repeat(" ", maxBody)+`[]}`); code != http.StatusRequestEntityTooLarge { t.Fatalf("want 413, got %d", code) } getJSON(t, ts.URL+"/api/nests", &got) if len(got.Nests) != 2 || got.Nests[1].ID != "launch-copy" { t.Fatalf("after create: %+v", got.Nests) } if code, out := post(t, ts.URL+"/api/lists", `{"text":"Write a poem","requester":"t","nest_id":"nope"}`); code != http.StatusNotFound || out["message"] == nil { t.Fatalf("unknown nest: want 404 with message, got %d %v", code, out) } } func TestServerBorrowedEvents(t *testing.T) { ts, _ := newTestServer(t, DefaultGuardrails(), time.Minute) post(t, ts.URL+"/api/nests", `{"name":"Launch copy","crows":["Pepper","Inky"]}`) yes := true // "should" makes the mock Sentry call a murder of 3: the 2-crow core plus one borrowed. _, out := post(t, ts.URL+"/api/lists", `{"text":"Should we launch on a Tuesday","requester":"t","nest_id":"launch-copy"}`) events, _ := stream(t, ts, out["list_id"].(string), &yes) if events[0].Data["nest_id"] != "launch-copy" { t.Fatalf("list.started nest_id: %v", events[0].Data) } borrowed := flights(t, events) if !slices.Equal(borrowed["Pepper"], []bool{false}) || !slices.Equal(borrowed["Inky"], []bool{false}) || !slices.Equal(borrowed["Hugin"], []bool{true}) || !slices.Equal(borrowed["Sentry"], []bool{false}) || !slices.Equal(borrowed["Elder"], []bool{false}) { t.Fatalf("borrowed flags: %v", borrowed) } // The todo nest (the default) never borrows. _, out = post(t, ts.URL+"/api/lists", `{"text":"Should we launch on a Tuesday","requester":"t"}`) events, _ = stream(t, ts, out["list_id"].(string), &yes) if events[0].Data["nest_id"] != "todo" { t.Fatalf("default nest_id: %v", events[0].Data) } for crow, flags := range flights(t, events) { if slices.Contains(flags, true) { t.Fatalf("%s borrowed in the todo nest", crow) } } } // flights maps each crow to the borrowed flag of each of its crow.flying events. func flights(t *testing.T, events []sseEvent) map[string][]bool { m := map[string][]bool{} for _, e := range events { if e.Name != "crow.flying" { continue } b, ok := e.Data["borrowed"].(bool) if !ok { t.Fatalf("crow.flying without a borrowed bool: %v", e.Data) } c := e.Data["crow"].(string) m[c] = append(m[c], b) } return m } func getJSON(t *testing.T, url string, v any) { resp, err := http.Get(url) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("GET %s: %d", url, resp.StatusCode) } if err := json.NewDecoder(resp.Body).Decode(v); err != nil { t.Fatal(err) } }