package main // These tests exercise bot.Announcer — since the default "Announcer" identity's // retirement, the engine behind a custom recipe's `changelog` rule // (docs/bot-recipes.md). The identity name in here is just a label. import ( "bytes" "encoding/json" "net/http" "os" "testing" "lchat/internal/bot" "lchat/internal/e2ee" ) // provisionTestBot mints an agent invite via the real admin API and claims it // with the real provisioning client. func provisionTestBot(t *testing.T, tsURL, name string) *bot.Session { t.Helper() secret := mintAgentInviteForTest(t, tsURL) sess, err := bot.Provision(tsURL, secret, name) if err != nil { t.Fatalf("provision %s: %v", name, err) } return sess } // createGroupWith makes owner create a group thread with the announcer, both // keys wrapped client-side — exactly what a member's browser does when adding // a bot to a group. Returns the thread id and the raw thread key. func createGroupWith(t *testing.T, tsURL string, s *Server, owner, announcer *bot.Session) (string, []byte) { t.Helper() threadKey, err := e2ee.GenerateKey() if err != nil { t.Fatal(err) } ownerID, _ := s.db.deviceTokenUser(owner.Token) annID, _ := s.db.deviceTokenUser(announcer.Token) members := []map[string]string{} for id, sess := range map[string]*bot.Session{ownerID: owner, annID: announcer} { wrapped, err := e2ee.WrapThreadKey(e2ee.B64uEncode(threadKey), sess.UserPub) if err != nil { t.Fatal(err) } members = append(members, map[string]string{"user_id": id, "wrapped": wrapped}) } body, _ := json.Marshal(map[string]any{"token": owner.Token, "kind": "group", "members": members}) resp, err := http.Post(tsURL+"/api/threads", "application/json", bytes.NewReader(body)) if err != nil { t.Fatal(err) } defer resp.Body.Close() var out struct { ThreadID string `json:"thread_id"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.ThreadID == "" { t.Fatalf("create group: status %d err %v id %q", resp.StatusCode, err, out.ThreadID) } return out.ThreadID, threadKey } func TestAnnouncerAgainstRealServer(t *testing.T) { ts, s := newTestServer(t) if len(s.changelog) < 1 { t.Fatal("embedded changelog is empty — the announcer has nothing to test with") } owner := provisionTestBot(t, ts.URL, "Owner") annSess := provisionTestBot(t, ts.URL, "Announcer") threadID, threadKey := createGroupWith(t, ts.URL, s, owner, annSess) statePath := t.TempDir() + "/announcer-session.json.state.json" a := &bot.Announcer{Base: ts.URL, Session: annSess, StatePath: statePath, Logf: t.Logf} // First tick baselines silently: state written, nothing posted. if err := a.Tick(); err != nil { t.Fatalf("baseline tick: %v", err) } msgs, err := s.db.messagesAfter(threadID, 0, 100) if err != nil { t.Fatal(err) } if len(msgs) != 0 { t.Fatalf("baseline tick posted %d messages, want 0", len(msgs)) } // Forget the two newest entries (Node state shape) → next tick announces // them, batched into one message. raw, err := os.ReadFile(statePath) if err != nil { t.Fatal(err) } var state struct { Announced []string `json:"announced"` } if err := json.Unmarshal(raw, &state); err != nil { t.Fatal(err) } if len(state.Announced) != len(s.changelog) { t.Fatalf("baselined %d ids, want %d", len(state.Announced), len(s.changelog)) } forget := 1 if len(state.Announced) >= 2 { forget = 2 } state.Announced = state.Announced[:len(state.Announced)-forget] raw, _ = json.Marshal(state) if err := os.WriteFile(statePath, raw, 0o600); err != nil { t.Fatal(err) } if err := a.Tick(); err != nil { t.Fatalf("announce tick: %v", err) } msgs, err = s.db.messagesAfter(threadID, 0, 100) if err != nil { t.Fatal(err) } if len(msgs) != 1 { t.Fatalf("announce tick posted %d messages, want 1 (batched)", len(msgs)) } // The blob must decrypt under the thread key to the announcer's payload, // carrying the forgotten entries' verbatim text. var payload struct { Name string `json:"name"` Text string `json:"text"` TS int64 `json:"ts"` } if err := e2ee.DecryptJSON(threadKey, msgs[0].Blob, &payload); err != nil { t.Fatalf("decrypt announcement: %v", err) } if payload.Name != "Announcer" || payload.TS == 0 { t.Errorf("bad payload envelope: %+v", payload) } if !bytes.Contains([]byte(payload.Text), []byte("🚀 Just shipped:")) { t.Errorf("announcement missing header: %q", payload.Text) } for _, e := range s.changelog[len(s.changelog)-forget:] { if !bytes.Contains([]byte(payload.Text), []byte(e.Text)) { t.Errorf("announcement missing entry %s", e.ID) } } // Kind self-declared as deterministic bot. uid, _ := s.db.deviceTokenUser(annSess.Token) var kind string if err := s.db.sql.QueryRow("SELECT COALESCE(kind, '') FROM bot_configs WHERE user_id = ?", uid).Scan(&kind); err != nil || kind != "bot" { t.Errorf("announcer kind: got %q (err %v), want \"bot\"", kind, err) } // A third tick has nothing new: no further messages. if err := a.Tick(); err != nil { t.Fatalf("idle tick: %v", err) } msgs, _ = s.db.messagesAfter(threadID, 0, 100) if len(msgs) != 1 { t.Errorf("idle tick posted more messages: %d total", len(msgs)) } }