|
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 |
} |