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