/ internal / session / smoke_test.go
smoke_test.go
  1  package session
  2  
  3  import (
  4  	"os"
  5  	"path/filepath"
  6  	"testing"
  7  
  8  	"github.com/Kocoro-lab/ShanClaw/internal/client"
  9  )
 10  
 11  // TestSmoke_EndToEnd verifies the full session search pipeline:
 12  // create sessions → index → list → search → resume → delete → re-index.
 13  func TestSmoke_EndToEnd(t *testing.T) {
 14  	dir := t.TempDir()
 15  
 16  	// 1. Create manager (auto-opens SQLite index)
 17  	mgr := NewManager(dir)
 18  
 19  	// 2. Save 3 sessions with different content
 20  	type msg struct{ role, text string }
 21  	data := []struct {
 22  		title string
 23  		msgs  []msg
 24  	}{
 25  		{"Kubernetes deployment", []msg{
 26  			{"user", "How do I deploy a kubernetes cluster on AWS?"},
 27  			{"assistant", "You can use EKS or kops to deploy kubernetes on AWS."},
 28  		}},
 29  		{"WebSocket reconnection", []msg{
 30  			{"user", "The websocket connection keeps dropping after 30 seconds"},
 31  			{"assistant", "This is likely a timeout issue. Set the ping interval to 15 seconds."},
 32  		}},
 33  		{"Go testing patterns", []msg{
 34  			{"user", "What's the best way to write table-driven tests in Go?"},
 35  			{"assistant", "Use a slice of test cases with subtests via t.Run()"},
 36  			{"user", "Can I run them in parallel?"},
 37  			{"assistant", "Yes, call t.Parallel() inside each subtest"},
 38  		}},
 39  	}
 40  
 41  	for _, d := range data {
 42  		sess := mgr.NewSession()
 43  		sess.Title = d.title
 44  		for _, m := range d.msgs {
 45  			sess.Messages = append(sess.Messages,
 46  				client.Message{Role: m.role, Content: client.NewTextContent(m.text)})
 47  		}
 48  		if err := mgr.Save(); err != nil {
 49  			t.Fatalf("Save %q: %v", d.title, err)
 50  		}
 51  	}
 52  	mgr.Close()
 53  
 54  	// 3. Reopen manager (simulates restart — should find existing index)
 55  	mgr2 := NewManager(dir)
 56  	defer mgr2.Close()
 57  
 58  	// 4. List — should return 3 sessions via index fast path
 59  	summaries, err := mgr2.List()
 60  	if err != nil {
 61  		t.Fatalf("List: %v", err)
 62  	}
 63  	if len(summaries) != 3 {
 64  		t.Fatalf("expected 3 sessions, got %d", len(summaries))
 65  	}
 66  
 67  	// 5. Keyword search
 68  	results, err := mgr2.Search("kubernetes", 10)
 69  	if err != nil {
 70  		t.Fatalf("Search kubernetes: %v", err)
 71  	}
 72  	if len(results) == 0 {
 73  		t.Fatal("expected results for 'kubernetes'")
 74  	}
 75  	if results[0].SessionTitle != "Kubernetes deployment" {
 76  		t.Errorf("expected session title 'Kubernetes deployment', got %q", results[0].SessionTitle)
 77  	}
 78  
 79  	// 6. Stemming: "deploy" matches "deployment" and "deploy"
 80  	results, err = mgr2.Search("deploy", 10)
 81  	if err != nil {
 82  		t.Fatalf("Search deploy: %v", err)
 83  	}
 84  	if len(results) < 2 {
 85  		t.Errorf("stemming: expected >=2 results for 'deploy', got %d", len(results))
 86  	}
 87  
 88  	// 7. Phrase search
 89  	results, err = mgr2.Search(`"ping interval"`, 10)
 90  	if err != nil {
 91  		t.Fatalf("Search phrase: %v", err)
 92  	}
 93  	if len(results) != 1 {
 94  		t.Errorf("phrase: expected 1 result, got %d", len(results))
 95  	}
 96  
 97  	// 8. No match
 98  	results, err = mgr2.Search("terraform", 10)
 99  	if err != nil {
100  		t.Fatalf("Search no-match: %v", err)
101  	}
102  	if len(results) != 0 {
103  		t.Errorf("expected 0 results for 'terraform', got %d", len(results))
104  	}
105  
106  	// 9. Malformed query returns clean error
107  	_, err = mgr2.Search(`"unbalanced`, 10)
108  	if err == nil {
109  		// Some FTS5 versions handle this gracefully — either way is fine
110  		t.Log("FTS5 handled unbalanced quote without error")
111  	}
112  
113  	// 10. ResumeLatest — should resume the most recently saved session
114  	sess, err := mgr2.ResumeLatest()
115  	if err != nil {
116  		t.Fatalf("ResumeLatest: %v", err)
117  	}
118  	if sess == nil {
119  		t.Fatal("ResumeLatest returned nil")
120  	}
121  	if sess.Title != "Go testing patterns" {
122  		t.Errorf("expected latest session 'Go testing patterns', got %q", sess.Title)
123  	}
124  
125  	// 11. Verify sessions.db exists alongside JSON files
126  	dbPath := filepath.Join(dir, "sessions.db")
127  	if _, err := os.Stat(dbPath); os.IsNotExist(err) {
128  		t.Error("sessions.db should exist")
129  	}
130  
131  	entries, _ := os.ReadDir(dir)
132  	jsonCount := 0
133  	for _, e := range entries {
134  		if filepath.Ext(e.Name()) == ".json" {
135  			jsonCount++
136  		}
137  	}
138  	if jsonCount != 3 {
139  		t.Errorf("expected 3 JSON files, got %d", jsonCount)
140  	}
141  
142  	// 12. Delete a session — verify removed from both index and disk
143  	mgr2.Delete(summaries[0].ID)
144  	results, _ = mgr2.Search("kubernetes", 10)
145  	// The deleted session may or may not show up depending on which summary[0] is
146  	// (list is sorted by created_at DESC, so [0] is the newest = "Go testing patterns")
147  	afterList, _ := mgr2.List()
148  	if len(afterList) != 2 {
149  		t.Errorf("expected 2 sessions after delete, got %d", len(afterList))
150  	}
151  
152  	// 13. Rebuild index from scratch (simulates index corruption recovery)
153  	mgr2.RebuildIndex()
154  	afterRebuild, _ := mgr2.List()
155  	if len(afterRebuild) != 2 {
156  		t.Errorf("expected 2 sessions after rebuild, got %d", len(afterRebuild))
157  	}
158  
159  	// 14. Cross-agent isolation — different dir = different index
160  	agentDir := t.TempDir()
161  	agentMgr := NewManager(agentDir)
162  	defer agentMgr.Close()
163  
164  	agentSess := agentMgr.NewSession()
165  	agentSess.Title = "Agent-only session"
166  	agentSess.Messages = append(agentSess.Messages,
167  		client.Message{Role: "user", Content: client.NewTextContent("flamingo waterfall content")})
168  	agentMgr.Save()
169  
170  	// Agent search finds its own content
171  	agentResults, _ := agentMgr.Search("flamingo", 10)
172  	if len(agentResults) != 1 {
173  		t.Errorf("agent search: expected 1 result, got %d", len(agentResults))
174  	}
175  
176  	// Main manager does NOT see agent content
177  	mainResults, _ := mgr2.Search("flamingo", 10)
178  	if len(mainResults) != 0 {
179  		t.Errorf("cross-agent leak: expected 0 results, got %d", len(mainResults))
180  	}
181  }
182  
183  // TestSmoke_ResumeLatest_OrphanedIndex verifies that ResumeLatest recovers
184  // when the index points to a deleted JSON file.
185  func TestSmoke_ResumeLatest_OrphanedIndex(t *testing.T) {
186  	dir := t.TempDir()
187  	mgr := NewManager(dir)
188  
189  	// Create two sessions
190  	s1 := mgr.NewSession()
191  	s1.Title = "Session A"
192  	s1.Messages = append(s1.Messages,
193  		client.Message{Role: "user", Content: client.NewTextContent("hello from A")})
194  	mgr.Save()
195  
196  	s2 := mgr.NewSession()
197  	s2.Title = "Session B"
198  	s2.Messages = append(s2.Messages,
199  		client.Message{Role: "user", Content: client.NewTextContent("hello from B")})
200  	mgr.Save()
201  	mgr.Close()
202  
203  	// Delete the latest session's JSON file (simulates corruption/manual deletion)
204  	os.Remove(filepath.Join(dir, s2.ID+".json"))
205  
206  	// Reopen and resume — should fall back to Session A
207  	mgr2 := NewManager(dir)
208  	defer mgr2.Close()
209  
210  	sess, err := mgr2.ResumeLatest()
211  	if err != nil {
212  		t.Fatalf("ResumeLatest with orphaned index: %v", err)
213  	}
214  	if sess == nil {
215  		t.Fatal("ResumeLatest returned nil — should have fallen back to Session A")
216  	}
217  	if sess.Title != "Session A" {
218  		t.Errorf("expected fallback to 'Session A', got %q", sess.Title)
219  	}
220  }