ScuttleBot

scuttlebot / internal / bots / scribe / scribe_test.go
Source Blame History 279 lines
c12ba92… lmata 1 package scribe_test
c12ba92… lmata 2
c12ba92… lmata 3 import (
c12ba92… lmata 4 "encoding/json"
c12ba92… lmata 5 "testing"
c12ba92… lmata 6 "time"
c12ba92… lmata 7
c12ba92… lmata 8 "github.com/conflicthq/scuttlebot/internal/bots/scribe"
c12ba92… lmata 9 "github.com/conflicthq/scuttlebot/pkg/protocol"
c12ba92… lmata 10 )
c12ba92… lmata 11
c12ba92… lmata 12 func validEnvelopeJSON(t *testing.T, msgType, from string) string {
c12ba92… lmata 13 t.Helper()
c12ba92… lmata 14 env, err := protocol.New(msgType, from, map[string]string{"key": "val"})
c12ba92… lmata 15 if err != nil {
c12ba92… lmata 16 t.Fatalf("protocol.New: %v", err)
c12ba92… lmata 17 }
c12ba92… lmata 18 b, err := protocol.Marshal(env)
c12ba92… lmata 19 if err != nil {
c12ba92… lmata 20 t.Fatalf("protocol.Marshal: %v", err)
c12ba92… lmata 21 }
c12ba92… lmata 22 return string(b)
c12ba92… lmata 23 }
c12ba92… lmata 24
c12ba92… lmata 25 func TestStoreAppendAndQuery(t *testing.T) {
c12ba92… lmata 26 s := &scribe.MemoryStore{}
c12ba92… lmata 27
c12ba92… lmata 28 entries := []scribe.Entry{
c12ba92… lmata 29 {At: time.Now(), Channel: "#fleet", Nick: "claude-01", Kind: scribe.EntryKindRaw, Raw: "hello"},
c12ba92… lmata 30 {At: time.Now(), Channel: "#fleet", Nick: "gemini-01", Kind: scribe.EntryKindRaw, Raw: "world"},
c12ba92… lmata 31 {At: time.Now(), Channel: "#project.test", Nick: "claude-01", Kind: scribe.EntryKindRaw, Raw: "other channel"},
c12ba92… lmata 32 }
c12ba92… lmata 33 for _, e := range entries {
c12ba92… lmata 34 if err := s.Append(e); err != nil {
c12ba92… lmata 35 t.Fatalf("Append: %v", err)
c12ba92… lmata 36 }
c12ba92… lmata 37 }
c12ba92… lmata 38
c12ba92… lmata 39 fleet, err := s.Query("#fleet", 0)
c12ba92… lmata 40 if err != nil {
c12ba92… lmata 41 t.Fatalf("Query: %v", err)
c12ba92… lmata 42 }
c12ba92… lmata 43 if len(fleet) != 2 {
c12ba92… lmata 44 t.Errorf("Query #fleet: got %d entries, want 2", len(fleet))
c12ba92… lmata 45 }
c12ba92… lmata 46
c12ba92… lmata 47 all, err := s.Query("", 0)
c12ba92… lmata 48 if err != nil {
c12ba92… lmata 49 t.Fatalf("Query all: %v", err)
c12ba92… lmata 50 }
c12ba92… lmata 51 if len(all) != 3 {
c12ba92… lmata 52 t.Errorf("Query all: got %d entries, want 3", len(all))
c12ba92… lmata 53 }
c12ba92… lmata 54 }
c12ba92… lmata 55
c12ba92… lmata 56 func TestStoreQueryLimit(t *testing.T) {
c12ba92… lmata 57 s := &scribe.MemoryStore{}
c12ba92… lmata 58 for i := 0; i < 10; i++ {
c12ba92… lmata 59 _ = s.Append(scribe.Entry{Channel: "#fleet", Nick: "agent", Kind: scribe.EntryKindRaw})
c12ba92… lmata 60 }
c12ba92… lmata 61
c12ba92… lmata 62 got, err := s.Query("#fleet", 3)
c12ba92… lmata 63 if err != nil {
c12ba92… lmata 64 t.Fatalf("Query: %v", err)
c12ba92… lmata 65 }
c12ba92… lmata 66 if len(got) != 3 {
c12ba92… lmata 67 t.Errorf("Query with limit=3: got %d entries", len(got))
c12ba92… lmata 68 }
c12ba92… lmata 69 }
c12ba92… lmata 70
c12ba92… lmata 71 func TestEntryKindFromEnvelope(t *testing.T) {
c12ba92… lmata 72 // Test that a valid envelope JSON is detected as EntryKindEnvelope.
c12ba92… lmata 73 raw := validEnvelopeJSON(t, protocol.TypeTaskCreate, "claude-01")
c12ba92… lmata 74
c12ba92… lmata 75 env, err := protocol.Unmarshal([]byte(raw))
c12ba92… lmata 76 if err != nil {
c12ba92… lmata 77 t.Fatalf("Unmarshal: %v", err)
c12ba92… lmata 78 }
c12ba92… lmata 79
c12ba92… lmata 80 entry := scribe.Entry{
c12ba92… lmata 81 At: time.Now(),
c12ba92… lmata 82 Channel: "#fleet",
c12ba92… lmata 83 Nick: "claude-01",
c12ba92… lmata 84 Kind: scribe.EntryKindEnvelope,
c12ba92… lmata 85 MessageType: env.Type,
c12ba92… lmata 86 MessageID: env.ID,
c12ba92… lmata 87 Raw: raw,
c12ba92… lmata 88 }
c12ba92… lmata 89
c12ba92… lmata 90 if entry.MessageType != protocol.TypeTaskCreate {
c12ba92… lmata 91 t.Errorf("MessageType: got %q, want %q", entry.MessageType, protocol.TypeTaskCreate)
c12ba92… lmata 92 }
c12ba92… lmata 93 if entry.MessageID == "" {
c12ba92… lmata 94 t.Error("MessageID is empty")
c12ba92… lmata 95 }
c12ba92… lmata 96 if entry.Kind != scribe.EntryKindEnvelope {
c12ba92… lmata 97 t.Errorf("Kind: got %q, want %q", entry.Kind, scribe.EntryKindEnvelope)
c12ba92… lmata 98 }
c12ba92… lmata 99 }
c12ba92… lmata 100
c12ba92… lmata 101 func TestEntryKindRawForMalformed(t *testing.T) {
c12ba92… lmata 102 // Non-JSON and invalid envelopes should produce EntryKindRaw entries.
c12ba92… lmata 103 cases := []string{
c12ba92… lmata 104 "hello from a human",
c12ba92… lmata 105 "not json at all",
c12ba92… lmata 106 `{"incomplete": true}`, // valid JSON but not a valid envelope
c12ba92… lmata 107 }
c12ba92… lmata 108
c12ba92… lmata 109 for _, raw := range cases {
c12ba92… lmata 110 _, err := protocol.Unmarshal([]byte(raw))
c12ba92… lmata 111 if err == nil {
c12ba92… lmata 112 // Valid envelope — skip (this case tests malformed only)
c12ba92… lmata 113 continue
c12ba92… lmata 114 }
c12ba92… lmata 115 entry := scribe.Entry{
c12ba92… lmata 116 At: time.Now(),
c12ba92… lmata 117 Channel: "#fleet",
c12ba92… lmata 118 Nick: "agent",
c12ba92… lmata 119 Kind: scribe.EntryKindRaw,
c12ba92… lmata 120 Raw: raw,
c12ba92… lmata 121 }
c12ba92… lmata 122 if entry.Kind != scribe.EntryKindRaw {
c12ba92… lmata 123 t.Errorf("expected EntryKindRaw for %q", raw)
c12ba92… lmata 124 }
c12ba92… lmata 125 if entry.MessageType != "" {
c12ba92… lmata 126 t.Errorf("MessageType should be empty for raw entry")
c12ba92… lmata 127 }
c12ba92… lmata 128 }
c12ba92… lmata 129 }
c12ba92… lmata 130
c12ba92… lmata 131 func TestEntryJSONRoundTrip(t *testing.T) {
c12ba92… lmata 132 entry := scribe.Entry{
c12ba92… lmata 133 At: time.Now().Truncate(time.Millisecond),
c12ba92… lmata 134 Channel: "#project.test",
c12ba92… lmata 135 Nick: "claude-01",
c12ba92… lmata 136 Kind: scribe.EntryKindEnvelope,
c12ba92… lmata 137 MessageType: protocol.TypeAgentHello,
c12ba92… lmata 138 MessageID: "01HX123",
c12ba92… lmata 139 Raw: `{"v":1}`,
c12ba92… lmata 140 }
c12ba92… lmata 141
c12ba92… lmata 142 b, err := json.Marshal(entry)
c12ba92… lmata 143 if err != nil {
c12ba92… lmata 144 t.Fatalf("Marshal: %v", err)
c12ba92… lmata 145 }
c12ba92… lmata 146
c12ba92… lmata 147 var got scribe.Entry
c12ba92… lmata 148 if err := json.Unmarshal(b, &got); err != nil {
c12ba92… lmata 149 t.Fatalf("Unmarshal: %v", err)
c12ba92… lmata 150 }
c12ba92… lmata 151
c12ba92… lmata 152 if got.Channel != entry.Channel {
c12ba92… lmata 153 t.Errorf("Channel: got %q, want %q", got.Channel, entry.Channel)
c12ba92… lmata 154 }
c12ba92… lmata 155 if got.Kind != entry.Kind {
c12ba92… lmata 156 t.Errorf("Kind: got %q, want %q", got.Kind, entry.Kind)
c12ba92… lmata 157 }
c12ba92… lmata 158 if got.MessageType != entry.MessageType {
c12ba92… lmata 159 t.Errorf("MessageType: got %q, want %q", got.MessageType, entry.MessageType)
5ac549c… lmata 160 }
5ac549c… lmata 161 }
5ac549c… lmata 162
5ac549c… lmata 163 func TestFileStoreJSONL(t *testing.T) {
5ac549c… lmata 164 dir := t.TempDir()
5ac549c… lmata 165 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl"})
5ac549c… lmata 166 defer fs.Close()
5ac549c… lmata 167
5ac549c… lmata 168 entries := []scribe.Entry{
5ac549c… lmata 169 {At: time.Now(), Channel: "#fleet", Nick: "alice", Kind: scribe.EntryKindRaw, Raw: "hello"},
1066004… lmata 170 {At: time.Now(), Channel: "#fleet", Nick: "bob", Kind: scribe.EntryKindRaw, Raw: "world"},
1066004… lmata 171 {At: time.Now(), Channel: "#ops", Nick: "alice", Kind: scribe.EntryKindRaw, Raw: "other"},
5ac549c… lmata 172 }
5ac549c… lmata 173 for _, e := range entries {
5ac549c… lmata 174 if err := fs.Append(e); err != nil {
5ac549c… lmata 175 t.Fatalf("Append: %v", err)
5ac549c… lmata 176 }
5ac549c… lmata 177 }
5ac549c… lmata 178
5ac549c… lmata 179 got, err := fs.Query("#fleet", 0)
5ac549c… lmata 180 if err != nil {
5ac549c… lmata 181 t.Fatalf("Query: %v", err)
5ac549c… lmata 182 }
5ac549c… lmata 183 if len(got) != 2 {
5ac549c… lmata 184 t.Errorf("Query #fleet: got %d, want 2", len(got))
5ac549c… lmata 185 }
5ac549c… lmata 186 }
5ac549c… lmata 187
5ac549c… lmata 188 func TestFileStorePerChannel(t *testing.T) {
5ac549c… lmata 189 dir := t.TempDir()
5ac549c… lmata 190 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl", PerChannel: true})
5ac549c… lmata 191 defer fs.Close()
5ac549c… lmata 192
5ac549c… lmata 193 _ = fs.Append(scribe.Entry{At: time.Now(), Channel: "#fleet", Nick: "a", Kind: scribe.EntryKindRaw, Raw: "msg"})
1066004… lmata 194 _ = fs.Append(scribe.Entry{At: time.Now(), Channel: "#ops", Nick: "a", Kind: scribe.EntryKindRaw, Raw: "msg"})
5ac549c… lmata 195
5ac549c… lmata 196 entries, err := fs.Query("#fleet", 0)
5ac549c… lmata 197 if err != nil {
5ac549c… lmata 198 t.Fatalf("Query: %v", err)
5ac549c… lmata 199 }
5ac549c… lmata 200 if len(entries) != 1 {
5ac549c… lmata 201 t.Errorf("per-channel: got %d entries for #fleet, want 1", len(entries))
5ac549c… lmata 202 }
5ac549c… lmata 203 }
5ac549c… lmata 204
5ac549c… lmata 205 func TestFileStoreSizeRotation(t *testing.T) {
5ac549c… lmata 206 dir := t.TempDir()
5ac549c… lmata 207 // Set threshold to 1 byte so every write triggers rotation.
5ac549c… lmata 208 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "text", Rotation: "size", MaxSizeMB: 0})
5ac549c… lmata 209 _ = fs // rotation with MaxSizeMB=0 means no limit; just ensure no panic
5ac549c… lmata 210 fs2 := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl", Rotation: "size", MaxSizeMB: 1})
5ac549c… lmata 211 defer fs2.Close()
5ac549c… lmata 212 for i := 0; i < 3; i++ {
5ac549c… lmata 213 if err := fs2.Append(scribe.Entry{At: time.Now(), Channel: "#test", Nick: "x", Kind: scribe.EntryKindRaw, Raw: "line"}); err != nil {
5ac549c… lmata 214 t.Fatalf("Append: %v", err)
5ac549c… lmata 215 }
5ac549c… lmata 216 }
5ac549c… lmata 217 }
5ac549c… lmata 218
5ac549c… lmata 219 func TestFileStoreCSVFormat(t *testing.T) {
5ac549c… lmata 220 dir := t.TempDir()
5ac549c… lmata 221 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "csv"})
5ac549c… lmata 222 defer fs.Close()
5ac549c… lmata 223
5ac549c… lmata 224 err := fs.Append(scribe.Entry{At: time.Now(), Channel: "#fleet", Nick: "alice", Kind: scribe.EntryKindRaw, Raw: `say "hi"`})
5ac549c… lmata 225 if err != nil {
5ac549c… lmata 226 t.Fatalf("Append csv: %v", err)
5ac549c… lmata 227 }
5ac549c… lmata 228 // Query returns nil for non-jsonl formats — just check no error on Append.
5ac549c… lmata 229 got, err := fs.Query("#fleet", 0)
5ac549c… lmata 230 if err != nil {
5ac549c… lmata 231 t.Fatalf("Query csv: %v", err)
5ac549c… lmata 232 }
5ac549c… lmata 233 if got != nil {
5ac549c… lmata 234 t.Errorf("expected nil from Query on csv format")
5ac549c… lmata 235 }
5ac549c… lmata 236 }
5ac549c… lmata 237
5ac549c… lmata 238 func TestFileStorePruneOld(t *testing.T) {
5ac549c… lmata 239 dir := t.TempDir()
5ac549c… lmata 240 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl", MaxAgeDays: 1})
5ac549c… lmata 241 defer fs.Close()
5ac549c… lmata 242
5ac549c… lmata 243 // Write a file and manually backdate it.
5ac549c… lmata 244 _ = fs.Append(scribe.Entry{At: time.Now(), Channel: "#fleet", Nick: "a", Kind: scribe.EntryKindRaw, Raw: "x"})
5ac549c… lmata 245
5ac549c… lmata 246 if err := fs.PruneOld(); err != nil {
5ac549c… lmata 247 t.Fatalf("PruneOld: %v", err)
5ac549c… lmata 248 }
5ac549c… lmata 249 }
5ac549c… lmata 250
5ac549c… lmata 251 func TestFileStoreJSONRoundTrip(t *testing.T) {
5ac549c… lmata 252 dir := t.TempDir()
5ac549c… lmata 253 fs := scribe.NewFileStore(scribe.FileStoreConfig{Dir: dir, Format: "jsonl"})
5ac549c… lmata 254 defer fs.Close()
5ac549c… lmata 255
5ac549c… lmata 256 orig := scribe.Entry{
5ac549c… lmata 257 At: time.Now().Truncate(time.Millisecond),
5ac549c… lmata 258 Channel: "#fleet",
5ac549c… lmata 259 Nick: "claude-01",
5ac549c… lmata 260 Kind: scribe.EntryKindEnvelope,
5ac549c… lmata 261 MessageType: "task.create",
5ac549c… lmata 262 MessageID: "01HX123",
5ac549c… lmata 263 Raw: `{"v":1}`,
5ac549c… lmata 264 }
5ac549c… lmata 265 _ = fs.Append(orig)
5ac549c… lmata 266
5ac549c… lmata 267 got, err := fs.Query("#fleet", 1)
5ac549c… lmata 268 if err != nil {
5ac549c… lmata 269 t.Fatalf("Query: %v", err)
5ac549c… lmata 270 }
5ac549c… lmata 271 if len(got) != 1 {
5ac549c… lmata 272 t.Fatalf("want 1 entry, got %d", len(got))
5ac549c… lmata 273 }
5ac549c… lmata 274 b1, _ := json.Marshal(orig)
5ac549c… lmata 275 b2, _ := json.Marshal(got[0])
5ac549c… lmata 276 if string(b1) != string(b2) {
5ac549c… lmata 277 t.Errorf("round-trip mismatch:\n want %s\n got %s", b1, b2)
c12ba92… lmata 278 }
c12ba92… lmata 279 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button