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 }