/ internal / session / store_test.go
store_test.go
  1  package session
  2  
  3  import (
  4  	"encoding/json"
  5  	"os"
  6  	"path/filepath"
  7  	"testing"
  8  	"time"
  9  
 10  	"github.com/Kocoro-lab/ShanClaw/internal/client"
 11  )
 12  
 13  func TestStore_SaveLoad(t *testing.T) {
 14  	dir := t.TempDir()
 15  	store := NewStore(dir)
 16  	defer store.Close()
 17  
 18  	sess := &Session{
 19  		ID:    "test-123",
 20  		Title: "Test session",
 21  		CWD:   "/tmp/test",
 22  		Messages: []client.Message{
 23  			{Role: "user", Content: client.NewTextContent("hello")},
 24  			{Role: "assistant", Content: client.NewTextContent("hi there")},
 25  		},
 26  	}
 27  
 28  	if err := store.Save(sess); err != nil {
 29  		t.Fatalf("save failed: %v", err)
 30  	}
 31  
 32  	loaded, err := store.Load("test-123")
 33  	if err != nil {
 34  		t.Fatalf("load failed: %v", err)
 35  	}
 36  	if loaded.Title != "Test session" {
 37  		t.Errorf("expected 'Test session', got %q", loaded.Title)
 38  	}
 39  	if len(loaded.Messages) != 2 {
 40  		t.Errorf("expected 2 messages, got %d", len(loaded.Messages))
 41  	}
 42  }
 43  
 44  func TestStore_List(t *testing.T) {
 45  	dir := t.TempDir()
 46  	store := NewStore(dir)
 47  	defer store.Close()
 48  
 49  	store.Save(&Session{ID: "aaa", Title: "First"})
 50  	store.Save(&Session{ID: "bbb", Title: "Second"})
 51  
 52  	sessions, err := store.List()
 53  	if err != nil {
 54  		t.Fatalf("list failed: %v", err)
 55  	}
 56  	if len(sessions) != 2 {
 57  		t.Errorf("expected 2 sessions, got %d", len(sessions))
 58  	}
 59  }
 60  
 61  func TestStore_Delete(t *testing.T) {
 62  	dir := t.TempDir()
 63  	store := NewStore(dir)
 64  	defer store.Close()
 65  
 66  	store.Save(&Session{ID: "del-me", Title: "Delete me"})
 67  
 68  	if err := store.Delete("del-me"); err != nil {
 69  		t.Fatalf("delete failed: %v", err)
 70  	}
 71  
 72  	if _, err := store.Load("del-me"); err == nil {
 73  		t.Error("expected error loading deleted session")
 74  	}
 75  
 76  	// Verify file is gone
 77  	path := filepath.Join(dir, "del-me.json")
 78  	if fileExists(path) {
 79  		t.Error("session file should be deleted")
 80  	}
 81  }
 82  
 83  func TestStore_SaveLoadWithImageContent(t *testing.T) {
 84  	dir := t.TempDir()
 85  	store := NewStore(dir)
 86  	defer store.Close()
 87  
 88  	sess := &Session{
 89  		ID:    "vision-test",
 90  		Title: "Vision test",
 91  		CWD:   "/tmp",
 92  		Messages: []client.Message{
 93  			{Role: "user", Content: client.NewTextContent("take a screenshot")},
 94  			{Role: "user", Content: client.NewBlockContent([]client.ContentBlock{
 95  				{Type: "text", Text: "Screenshot captured"},
 96  				{Type: "image", Source: &client.ImageSource{
 97  					Type:      "base64",
 98  					MediaType: "image/png",
 99  					Data:      "iVBORfake",
100  				}},
101  			})},
102  			{Role: "assistant", Content: client.NewTextContent("I see a desktop")},
103  		},
104  	}
105  
106  	if err := store.Save(sess); err != nil {
107  		t.Fatalf("save failed: %v", err)
108  	}
109  
110  	loaded, err := store.Load("vision-test")
111  	if err != nil {
112  		t.Fatalf("load failed: %v", err)
113  	}
114  
115  	if len(loaded.Messages) != 3 {
116  		t.Fatalf("expected 3 messages, got %d", len(loaded.Messages))
117  	}
118  
119  	// First message: plain string
120  	if loaded.Messages[0].Content.Text() != "take a screenshot" {
121  		t.Errorf("msg[0] text mismatch: %q", loaded.Messages[0].Content.Text())
122  	}
123  
124  	// Second message: content blocks with image
125  	if !loaded.Messages[1].Content.HasBlocks() {
126  		t.Fatal("msg[1] should have blocks")
127  	}
128  	blocks := loaded.Messages[1].Content.Blocks()
129  	if len(blocks) != 2 {
130  		t.Fatalf("expected 2 blocks, got %d", len(blocks))
131  	}
132  	if blocks[1].Source == nil || blocks[1].Source.Data != "iVBORfake" {
133  		t.Error("image block data not preserved")
134  	}
135  
136  	// Third message: plain string
137  	if loaded.Messages[2].Content.Text() != "I see a desktop" {
138  		t.Errorf("msg[2] text mismatch: %q", loaded.Messages[2].Content.Text())
139  	}
140  }
141  
142  func TestStore_SaveLoadWithUsageSummary(t *testing.T) {
143  	dir := t.TempDir()
144  	store := NewStore(dir)
145  	defer store.Close()
146  
147  	sess := &Session{
148  		ID:    "usage-test",
149  		Title: "Usage test",
150  		CWD:   "/tmp",
151  		Usage: &UsageSummary{
152  			LLMCalls:              3,
153  			InputTokens:           150,
154  			OutputTokens:          45,
155  			TotalTokens:           195,
156  			CostUSD:               0.67,
157  			CacheReadTokens:       80,
158  			CacheCreationTokens:   300,
159  			CacheCreation5mTokens: 100,
160  			CacheCreation1hTokens: 200,
161  			Model:                 "claude-test",
162  		},
163  	}
164  
165  	if err := store.Save(sess); err != nil {
166  		t.Fatalf("save failed: %v", err)
167  	}
168  
169  	loaded, err := store.Load("usage-test")
170  	if err != nil {
171  		t.Fatalf("load failed: %v", err)
172  	}
173  	if loaded.Usage == nil {
174  		t.Fatal("expected usage summary to be loaded")
175  	}
176  	if loaded.Usage.CacheCreationTokens != 300 {
177  		t.Fatalf("expected legacy cache creation total 300, got %d", loaded.Usage.CacheCreationTokens)
178  	}
179  	if loaded.Usage.CacheCreation5mTokens != 100 || loaded.Usage.CacheCreation1hTokens != 200 {
180  		t.Fatalf("expected split cache creation 100/200, got %d/%d", loaded.Usage.CacheCreation5mTokens, loaded.Usage.CacheCreation1hTokens)
181  	}
182  }
183  
184  func TestStore_SearchIntegration(t *testing.T) {
185  	dir := t.TempDir()
186  	store := NewStore(dir) // auto-creates index + rebuilds (nothing to rebuild)
187  	defer store.Close()
188  
189  	sess := &Session{
190  		ID:    "int-test",
191  		Title: "Integration",
192  		CWD:   "/tmp",
193  		Messages: []client.Message{
194  			{Role: "user", Content: client.NewTextContent("deploy the kubernetes cluster")},
195  			{Role: "assistant", Content: client.NewTextContent("I'll help you deploy k8s")},
196  		},
197  	}
198  	store.Save(sess)
199  
200  	// Search should find it
201  	results, err := store.Search("kubernetes", 10)
202  	if err != nil {
203  		t.Fatalf("Search: %v", err)
204  	}
205  	if len(results) != 1 {
206  		t.Fatalf("expected 1 result, got %d", len(results))
207  	}
208  
209  	// List should use index (fast path)
210  	summaries, err := store.List()
211  	if err != nil {
212  		t.Fatalf("List: %v", err)
213  	}
214  	if len(summaries) != 1 {
215  		t.Fatalf("expected 1 session, got %d", len(summaries))
216  	}
217  
218  	// Delete should clean up index
219  	store.Delete("int-test")
220  	results, _ = store.Search("kubernetes", 10)
221  	if len(results) != 0 {
222  		t.Errorf("expected 0 results after delete, got %d", len(results))
223  	}
224  }
225  
226  func TestStore_GracefulDegradation(t *testing.T) {
227  	dir := t.TempDir()
228  	store := &Store{dir: dir, index: nil} // simulate index failure
229  
230  	sess := &Session{ID: "no-idx", Title: "No index", CWD: "/tmp"}
231  	if err := store.Save(sess); err != nil {
232  		t.Fatalf("Save should work without index: %v", err)
233  	}
234  
235  	summaries, err := store.List()
236  	if err != nil {
237  		t.Fatalf("List should fall back to JSON scan: %v", err)
238  	}
239  	if len(summaries) != 1 {
240  		t.Errorf("expected 1 session from JSON fallback, got %d", len(summaries))
241  	}
242  
243  	_, err = store.Search("anything", 10)
244  	if err == nil {
245  		t.Error("Search should return error when index is nil")
246  	}
247  }
248  
249  func TestStore_ListEmptyDir(t *testing.T) {
250  	dir := t.TempDir()
251  	store := NewStore(dir)
252  	defer store.Close()
253  
254  	summaries, err := store.List()
255  	if err != nil {
256  		t.Fatalf("List on empty dir: %v", err)
257  	}
258  	if len(summaries) != 0 {
259  		t.Errorf("expected 0 sessions, got %d", len(summaries))
260  	}
261  }
262  
263  func TestStore_FirstLaunchMigration(t *testing.T) {
264  	dir := t.TempDir()
265  
266  	// Write JSON files WITHOUT an index (simulate pre-SQLite sessions)
267  	rawStore := &Store{dir: dir, index: nil}
268  	rawStore.Save(&Session{
269  		ID:    "legacy-1",
270  		Title: "Legacy session",
271  		CWD:   "/tmp",
272  		Messages: []client.Message{
273  			{Role: "user", Content: client.NewTextContent("legacy migration test")},
274  		},
275  	})
276  
277  	// Now create store normally — should detect empty index and rebuild
278  	store := NewStore(dir)
279  	defer store.Close()
280  
281  	// Should be searchable after migration
282  	results, err := store.Search("legacy", 10)
283  	if err != nil {
284  		t.Fatalf("Search after migration: %v", err)
285  	}
286  	if len(results) != 1 {
287  		t.Fatalf("expected 1 result after migration, got %d", len(results))
288  	}
289  }
290  
291  func TestSessionMessageMetaSerialization(t *testing.T) {
292  	sess := &Session{
293  		ID:        "test-meta",
294  		CreatedAt: time.Now(),
295  		UpdatedAt: time.Now(),
296  		Title:     "Test",
297  		Messages: []client.Message{
298  			{Role: "user", Content: client.NewTextContent("hello")},
299  			{Role: "assistant", Content: client.NewTextContent("hi")},
300  		},
301  		MessageMeta: []MessageMeta{
302  			{Source: "slack"},
303  			{Source: "slack"},
304  		},
305  	}
306  
307  	data, err := json.Marshal(sess)
308  	if err != nil {
309  		t.Fatal(err)
310  	}
311  
312  	var loaded Session
313  	if err := json.Unmarshal(data, &loaded); err != nil {
314  		t.Fatal(err)
315  	}
316  
317  	if len(loaded.MessageMeta) != 2 {
318  		t.Fatalf("expected 2 meta entries, got %d", len(loaded.MessageMeta))
319  	}
320  	if loaded.MessageMeta[0].Source != "slack" {
321  		t.Fatalf("expected source 'slack', got %q", loaded.MessageMeta[0].Source)
322  	}
323  }
324  
325  func TestSessionMessageMetaBackwardCompat(t *testing.T) {
326  	// Old session JSON without message_meta should deserialize cleanly
327  	oldJSON := `{"id":"old","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","title":"Old","messages":[]}`
328  
329  	var sess Session
330  	if err := json.Unmarshal([]byte(oldJSON), &sess); err != nil {
331  		t.Fatal(err)
332  	}
333  	if sess.MessageMeta != nil {
334  		t.Fatal("expected nil MessageMeta for old session")
335  	}
336  }
337  
338  func TestSessionSourceAtSafety(t *testing.T) {
339  	sess := &Session{
340  		Messages: []client.Message{
341  			{Role: "user", Content: client.NewTextContent("hello")},
342  		},
343  		// No MessageMeta — simulates legacy session
344  	}
345  
346  	// Negative index should not panic
347  	if sess.SourceAt(-1) != "unknown" {
348  		t.Fatal("expected 'unknown' for negative index")
349  	}
350  
351  	// Out of bounds should not panic
352  	if sess.SourceAt(999) != "unknown" {
353  		t.Fatal("expected 'unknown' for out-of-bounds index")
354  	}
355  
356  	// Valid index but no meta
357  	if sess.SourceAt(0) != "unknown" {
358  		t.Fatal("expected 'unknown' for missing meta")
359  	}
360  
361  	// With meta
362  	sess.MessageMeta = []MessageMeta{{Source: "slack"}}
363  	if sess.SourceAt(0) != "slack" {
364  		t.Fatalf("expected 'slack', got %q", sess.SourceAt(0))
365  	}
366  }
367  
368  func TestStore_LoadLegacyStringContent(t *testing.T) {
369  	dir := t.TempDir()
370  	legacyJSON := `{
371  		"id": "legacy-test",
372  		"title": "Legacy",
373  		"cwd": "/tmp",
374  		"messages": [
375  			{"role": "user", "content": "hello"},
376  			{"role": "assistant", "content": "hi there"}
377  		]
378  	}`
379  	os.WriteFile(filepath.Join(dir, "legacy-test.json"), []byte(legacyJSON), 0600)
380  
381  	store := NewStore(dir)
382  	defer store.Close()
383  	loaded, err := store.Load("legacy-test")
384  	if err != nil {
385  		t.Fatalf("load legacy failed: %v", err)
386  	}
387  	if loaded.Messages[0].Content.Text() != "hello" {
388  		t.Errorf("expected 'hello', got %q", loaded.Messages[0].Content.Text())
389  	}
390  	if loaded.Messages[1].Content.Text() != "hi there" {
391  		t.Errorf("expected 'hi there', got %q", loaded.Messages[1].Content.Text())
392  	}
393  }
394  
395  func TestHistoryForLoop_FiltersInjected(t *testing.T) {
396  	sess := &Session{
397  		Messages: []client.Message{
398  			{Role: "user", Content: client.NewTextContent("real user 1")},
399  			{Role: "assistant", Content: client.NewTextContent("real assistant 1")},
400  			{Role: "user", Content: client.NewTextContent("STOP. You wrote out tool calls as text…")},
401  			{Role: "assistant", Content: client.NewTextContent("sorry, trying again")},
402  			{Role: "user", Content: client.NewTextContent("real user 2")},
403  		},
404  		MessageMeta: []MessageMeta{
405  			{Source: "local"},
406  			{Source: "local"},
407  			{Source: "local", SystemInjected: true},
408  			{Source: "local"},
409  			{Source: "local"},
410  		},
411  	}
412  	got := sess.HistoryForLoop()
413  	if len(got) != 4 {
414  		t.Fatalf("got %d messages, want 4 (injected nudge must be filtered)", len(got))
415  	}
416  	for _, m := range got {
417  		if m.Content.Text() == "STOP. You wrote out tool calls as text…" {
418  			t.Errorf("injected nudge leaked into HistoryForLoop output: %q", m.Content.Text())
419  		}
420  	}
421  	// Real user/assistant messages must survive intact in order.
422  	want := []string{"real user 1", "real assistant 1", "sorry, trying again", "real user 2"}
423  	for i, m := range got {
424  		if m.Content.Text() != want[i] {
425  			t.Errorf("msg[%d] = %q, want %q", i, m.Content.Text(), want[i])
426  		}
427  	}
428  }
429  
430  func TestHistoryForLoop_NoMeta(t *testing.T) {
431  	// Legacy sessions with no meta must pass through unchanged.
432  	sess := &Session{
433  		Messages: []client.Message{
434  			{Role: "user", Content: client.NewTextContent("hi")},
435  			{Role: "assistant", Content: client.NewTextContent("hello")},
436  		},
437  	}
438  	got := sess.HistoryForLoop()
439  	if len(got) != 2 {
440  		t.Errorf("got %d messages, want 2", len(got))
441  	}
442  }
443  
444  func TestHistoryForLoop_ShortMeta(t *testing.T) {
445  	// Meta shorter than Messages (partial legacy migration): keep unannotated tail.
446  	sess := &Session{
447  		Messages: []client.Message{
448  			{Role: "user", Content: client.NewTextContent("legacy1")},
449  			{Role: "user", Content: client.NewTextContent("legacy2")},
450  			{Role: "user", Content: client.NewTextContent("new nudge")},
451  		},
452  		MessageMeta: []MessageMeta{
453  			{}, // legacy1 — no flag
454  			// legacy2 and new nudge have no meta entries at all
455  		},
456  	}
457  	got := sess.HistoryForLoop()
458  	if len(got) != 3 {
459  		t.Errorf("got %d messages, want 3 (unannotated positions must survive)", len(got))
460  	}
461  }
462  
463  func TestFilterInjected_NoFlagsFastPath(t *testing.T) {
464  	// When nothing is flagged, FilterInjected takes the fast path: it aliases
465  	// the input slice (no allocation) but caps the capacity so a later append
466  	// on the result cannot silently extend into the caller's backing array
467  	// past the visible length.
468  	backing := make([]client.Message, 2, 10) // extra capacity on purpose
469  	backing[0] = client.Message{Role: "user", Content: client.NewTextContent("a")}
470  	backing[1] = client.Message{Role: "assistant", Content: client.NewTextContent("b")}
471  	meta := []MessageMeta{{Source: "x"}, {Source: "x"}}
472  
473  	got := FilterInjected(backing, meta)
474  	if len(got) != 2 {
475  		t.Fatalf("got len %d, want 2", len(got))
476  	}
477  	if &got[0] != &backing[0] {
478  		t.Error("expected fast path to alias the original slice (no allocation)")
479  	}
480  	if cap(got) != len(got) {
481  		t.Errorf("expected capped capacity %d, got %d — append on result could corrupt caller's backing array", len(got), cap(got))
482  	}
483  }
484  
485  func TestFilterInjected_NoMetaFastPath(t *testing.T) {
486  	// The len(meta) == 0 branch must also cap capacity, same reasoning.
487  	backing := make([]client.Message, 1, 5)
488  	backing[0] = client.Message{Role: "user", Content: client.NewTextContent("a")}
489  	got := FilterInjected(backing, nil)
490  	if cap(got) != len(got) {
491  		t.Errorf("expected capped capacity %d, got %d", len(got), cap(got))
492  	}
493  }