index_test.go
1 package session 2 3 import ( 4 "context" 5 "database/sql" 6 "fmt" 7 "path/filepath" 8 "strings" 9 "testing" 10 "time" 11 12 "github.com/Kocoro-lab/ShanClaw/internal/client" 13 ) 14 15 // mustUpsert wraps UpsertSession with t.Fatal on error, used by tests that 16 // only care about seeding rows (not about exercising the upsert path itself). 17 func mustUpsert(t *testing.T, idx *Index, s *Session) { 18 t.Helper() 19 if err := idx.UpsertSession(s); err != nil { 20 t.Fatalf("UpsertSession(%s): %v", s.ID, err) 21 } 22 } 23 24 func TestIndex_OpenClose(t *testing.T) { 25 dir := t.TempDir() 26 idx, err := OpenIndex(dir) 27 if err != nil { 28 t.Fatalf("OpenIndex: %v", err) 29 } 30 if err := idx.Close(); err != nil { 31 t.Fatalf("Close: %v", err) 32 } 33 } 34 35 func TestIndex_UpsertAndList(t *testing.T) { 36 dir := t.TempDir() 37 idx, err := OpenIndex(dir) 38 if err != nil { 39 t.Fatalf("OpenIndex: %v", err) 40 } 41 defer idx.Close() 42 43 now := time.Now().Truncate(time.Second) 44 sess := &Session{ 45 ID: "sess-1", 46 Title: "First session", 47 CWD: "/tmp/test", 48 CreatedAt: now, 49 UpdatedAt: now, 50 Messages: []client.Message{ 51 {Role: "user", Content: client.NewTextContent("hello world")}, 52 {Role: "assistant", Content: client.NewTextContent("hi there")}, 53 }, 54 } 55 56 if err := idx.UpsertSession(sess); err != nil { 57 t.Fatalf("UpsertSession: %v", err) 58 } 59 60 summaries, err := idx.ListSessions() 61 if err != nil { 62 t.Fatalf("ListSessions: %v", err) 63 } 64 if len(summaries) != 1 { 65 t.Fatalf("expected 1 session, got %d", len(summaries)) 66 } 67 if summaries[0].ID != "sess-1" { 68 t.Errorf("expected ID 'sess-1', got %q", summaries[0].ID) 69 } 70 if summaries[0].Title != "First session" { 71 t.Errorf("expected title 'First session', got %q", summaries[0].Title) 72 } 73 if summaries[0].MsgCount != 2 { 74 t.Errorf("expected 2 messages, got %d", summaries[0].MsgCount) 75 } 76 } 77 78 func TestIndex_ListOrder(t *testing.T) { 79 dir := t.TempDir() 80 idx, err := OpenIndex(dir) 81 if err != nil { 82 t.Fatalf("OpenIndex: %v", err) 83 } 84 defer idx.Close() 85 86 older := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) 87 newer := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) 88 89 if err := idx.UpsertSession(&Session{ 90 ID: "old", Title: "Old session", CreatedAt: older, UpdatedAt: older, 91 }); err != nil { 92 t.Fatal(err) 93 } 94 if err := idx.UpsertSession(&Session{ 95 ID: "new", Title: "New session", CreatedAt: newer, UpdatedAt: newer, 96 }); err != nil { 97 t.Fatal(err) 98 } 99 100 summaries, err := idx.ListSessions() 101 if err != nil { 102 t.Fatalf("ListSessions: %v", err) 103 } 104 if len(summaries) != 2 { 105 t.Fatalf("expected 2, got %d", len(summaries)) 106 } 107 if summaries[0].ID != "new" { 108 t.Errorf("expected newest first, got %q", summaries[0].ID) 109 } 110 if summaries[1].ID != "old" { 111 t.Errorf("expected oldest second, got %q", summaries[1].ID) 112 } 113 } 114 115 func TestIndex_Search(t *testing.T) { 116 dir := t.TempDir() 117 idx, err := OpenIndex(dir) 118 if err != nil { 119 t.Fatalf("OpenIndex: %v", err) 120 } 121 defer idx.Close() 122 123 now := time.Now().Truncate(time.Second) 124 sess := &Session{ 125 ID: "search-1", Title: "Search test", CreatedAt: now, UpdatedAt: now, 126 Messages: []client.Message{ 127 {Role: "user", Content: client.NewTextContent("tell me about websocket reconnect logic")}, 128 {Role: "assistant", Content: client.NewTextContent("the reconnect uses exponential backoff")}, 129 }, 130 } 131 if err := idx.UpsertSession(sess); err != nil { 132 t.Fatal(err) 133 } 134 135 results, err := idx.Search("websocket", 20) 136 if err != nil { 137 t.Fatalf("Search: %v", err) 138 } 139 if len(results) == 0 { 140 t.Fatal("expected at least one result") 141 } 142 if results[0].SessionID != "search-1" { 143 t.Errorf("expected session 'search-1', got %q", results[0].SessionID) 144 } 145 if results[0].SessionTitle != "Search test" { 146 t.Errorf("expected title 'Search test', got %q", results[0].SessionTitle) 147 } 148 if results[0].Role != "user" { 149 t.Errorf("expected role 'user', got %q", results[0].Role) 150 } 151 if !strings.Contains(results[0].Snippet, "websocket") { 152 t.Errorf("snippet should contain 'websocket', got %q", results[0].Snippet) 153 } 154 } 155 156 func TestIndex_SearchStemming(t *testing.T) { 157 dir := t.TempDir() 158 idx, err := OpenIndex(dir) 159 if err != nil { 160 t.Fatalf("OpenIndex: %v", err) 161 } 162 defer idx.Close() 163 164 now := time.Now().Truncate(time.Second) 165 sess := &Session{ 166 ID: "stem-1", Title: "Stemming", CreatedAt: now, UpdatedAt: now, 167 Messages: []client.Message{ 168 {Role: "user", Content: client.NewTextContent("the server is running on port 8080")}, 169 }, 170 } 171 if err := idx.UpsertSession(sess); err != nil { 172 t.Fatal(err) 173 } 174 175 results, err := idx.Search("run", 20) 176 if err != nil { 177 t.Fatalf("Search: %v", err) 178 } 179 if len(results) == 0 { 180 t.Fatal("expected stemmed match for 'run' -> 'running'") 181 } 182 } 183 184 func TestIndex_SearchNoResults(t *testing.T) { 185 dir := t.TempDir() 186 idx, err := OpenIndex(dir) 187 if err != nil { 188 t.Fatalf("OpenIndex: %v", err) 189 } 190 defer idx.Close() 191 192 now := time.Now().Truncate(time.Second) 193 if err := idx.UpsertSession(&Session{ 194 ID: "no-match", Title: "No match", CreatedAt: now, UpdatedAt: now, 195 Messages: []client.Message{ 196 {Role: "user", Content: client.NewTextContent("hello world")}, 197 }, 198 }); err != nil { 199 t.Fatal(err) 200 } 201 202 results, err := idx.Search("xyzzynonexistent", 20) 203 if err != nil { 204 t.Fatalf("Search: %v", err) 205 } 206 if len(results) != 0 { 207 t.Errorf("expected 0 results, got %d", len(results)) 208 } 209 } 210 211 func TestIndex_SearchPhraseQuery(t *testing.T) { 212 dir := t.TempDir() 213 idx, err := OpenIndex(dir) 214 if err != nil { 215 t.Fatalf("OpenIndex: %v", err) 216 } 217 defer idx.Close() 218 219 now := time.Now().Truncate(time.Second) 220 if err := idx.UpsertSession(&Session{ 221 ID: "phrase-1", Title: "Phrase test", CreatedAt: now, UpdatedAt: now, 222 Messages: []client.Message{ 223 {Role: "user", Content: client.NewTextContent("fix the websocket reconnect issue")}, 224 {Role: "user", Content: client.NewTextContent("websocket is fine but reconnect is broken elsewhere")}, 225 }, 226 }); err != nil { 227 t.Fatal(err) 228 } 229 230 results, err := idx.Search(`"websocket reconnect"`, 20) 231 if err != nil { 232 t.Fatalf("Search: %v", err) 233 } 234 if len(results) == 0 { 235 t.Fatal("expected phrase match") 236 } 237 // The phrase "websocket reconnect" only appears adjacent in msg_index 0 238 if results[0].MsgIndex != 0 { 239 t.Errorf("expected msg_index 0 for phrase match, got %d", results[0].MsgIndex) 240 } 241 } 242 243 func TestIndex_SearchMalformed(t *testing.T) { 244 dir := t.TempDir() 245 idx, err := OpenIndex(dir) 246 if err != nil { 247 t.Fatalf("OpenIndex: %v", err) 248 } 249 defer idx.Close() 250 251 // Insert data so FTS table is non-empty (empty FTS skips MATCH evaluation) 252 now := time.Now().Truncate(time.Second) 253 if err := idx.UpsertSession(&Session{ 254 ID: "mal-1", Title: "Malformed", CreatedAt: now, UpdatedAt: now, 255 Messages: []client.Message{ 256 {Role: "user", Content: client.NewTextContent("some content")}, 257 }, 258 }); err != nil { 259 t.Fatal(err) 260 } 261 262 _, err = idx.Search(`"unbalanced`, 20) 263 if err == nil { 264 t.Fatal("expected error for malformed query") 265 } 266 if !strings.Contains(err.Error(), "invalid search query") { 267 t.Errorf("expected clean error message, got: %v", err) 268 } 269 } 270 271 func TestIndex_Delete(t *testing.T) { 272 dir := t.TempDir() 273 idx, err := OpenIndex(dir) 274 if err != nil { 275 t.Fatalf("OpenIndex: %v", err) 276 } 277 defer idx.Close() 278 279 now := time.Now().Truncate(time.Second) 280 sess := &Session{ 281 ID: "del-1", Title: "Delete me", CreatedAt: now, UpdatedAt: now, 282 Messages: []client.Message{ 283 {Role: "user", Content: client.NewTextContent("unique deletable content")}, 284 }, 285 } 286 if err := idx.UpsertSession(sess); err != nil { 287 t.Fatal(err) 288 } 289 290 if err := idx.DeleteSession("del-1"); err != nil { 291 t.Fatalf("DeleteSession: %v", err) 292 } 293 294 // Verify gone from list 295 summaries, err := idx.ListSessions() 296 if err != nil { 297 t.Fatal(err) 298 } 299 if len(summaries) != 0 { 300 t.Errorf("expected 0 sessions after delete, got %d", len(summaries)) 301 } 302 303 // Verify gone from FTS 304 results, err := idx.Search("deletable", 20) 305 if err != nil { 306 t.Fatal(err) 307 } 308 if len(results) != 0 { 309 t.Errorf("expected 0 FTS results after delete, got %d", len(results)) 310 } 311 } 312 313 func TestIndex_UpsertUpdatesExisting(t *testing.T) { 314 dir := t.TempDir() 315 idx, err := OpenIndex(dir) 316 if err != nil { 317 t.Fatalf("OpenIndex: %v", err) 318 } 319 defer idx.Close() 320 321 now := time.Now().Truncate(time.Second) 322 323 // First upsert 324 if err := idx.UpsertSession(&Session{ 325 ID: "upd-1", Title: "Original", CreatedAt: now, UpdatedAt: now, 326 Messages: []client.Message{ 327 {Role: "user", Content: client.NewTextContent("original content alpha")}, 328 }, 329 }); err != nil { 330 t.Fatal(err) 331 } 332 333 // Second upsert with updated title and new messages 334 if err := idx.UpsertSession(&Session{ 335 ID: "upd-1", Title: "Updated", CreatedAt: now, UpdatedAt: now.Add(time.Minute), 336 Messages: []client.Message{ 337 {Role: "user", Content: client.NewTextContent("original content alpha")}, 338 {Role: "assistant", Content: client.NewTextContent("new content bravo")}, 339 }, 340 }); err != nil { 341 t.Fatal(err) 342 } 343 344 summaries, err := idx.ListSessions() 345 if err != nil { 346 t.Fatal(err) 347 } 348 if len(summaries) != 1 { 349 t.Fatalf("expected 1 session, got %d", len(summaries)) 350 } 351 if summaries[0].Title != "Updated" { 352 t.Errorf("expected title 'Updated', got %q", summaries[0].Title) 353 } 354 if summaries[0].MsgCount != 2 { 355 t.Errorf("expected 2 messages, got %d", summaries[0].MsgCount) 356 } 357 358 // Both terms should be searchable 359 r1, err := idx.Search("alpha", 20) 360 if err != nil { 361 t.Fatal(err) 362 } 363 if len(r1) == 0 { 364 t.Error("expected 'alpha' to still be searchable") 365 } 366 367 r2, err := idx.Search("bravo", 20) 368 if err != nil { 369 t.Fatal(err) 370 } 371 if len(r2) == 0 { 372 t.Error("expected 'bravo' to be searchable after upsert") 373 } 374 } 375 376 func TestIndex_Rebuild(t *testing.T) { 377 dir := t.TempDir() 378 store := &Store{dir: dir} 379 380 // Create sessions via Store (JSON files) 381 now := time.Now().Truncate(time.Second) 382 s1 := &Session{ 383 ID: "rb-1", Title: "Rebuild one", CreatedAt: now, UpdatedAt: now, 384 Messages: []client.Message{ 385 {Role: "user", Content: client.NewTextContent("rebuild test alpha")}, 386 }, 387 } 388 s2 := &Session{ 389 ID: "rb-2", Title: "Rebuild two", CreatedAt: now.Add(time.Second), UpdatedAt: now.Add(time.Second), 390 Messages: []client.Message{ 391 {Role: "user", Content: client.NewTextContent("rebuild test bravo")}, 392 }, 393 } 394 if err := store.Save(s1); err != nil { 395 t.Fatal(err) 396 } 397 if err := store.Save(s2); err != nil { 398 t.Fatal(err) 399 } 400 401 idx, err := OpenIndex(dir) 402 if err != nil { 403 t.Fatal(err) 404 } 405 defer idx.Close() 406 407 if err := idx.Rebuild(store); err != nil { 408 t.Fatalf("Rebuild: %v", err) 409 } 410 411 summaries, err := idx.ListSessions() 412 if err != nil { 413 t.Fatal(err) 414 } 415 if len(summaries) != 2 { 416 t.Fatalf("expected 2 sessions after rebuild, got %d", len(summaries)) 417 } 418 419 // Verify FTS works 420 results, err := idx.Search("bravo", 20) 421 if err != nil { 422 t.Fatal(err) 423 } 424 if len(results) == 0 { 425 t.Error("expected FTS results after rebuild") 426 } 427 } 428 429 func TestIndex_LatestUpdated(t *testing.T) { 430 dir := t.TempDir() 431 idx, err := OpenIndex(dir) 432 if err != nil { 433 t.Fatal(err) 434 } 435 defer idx.Close() 436 437 t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) 438 t2 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC) 439 440 if err := idx.UpsertSession(&Session{ 441 ID: "old", Title: "Old", CreatedAt: t1, UpdatedAt: t1, 442 }); err != nil { 443 t.Fatal(err) 444 } 445 if err := idx.UpsertSession(&Session{ 446 ID: "new", Title: "New", CreatedAt: t1, UpdatedAt: t2, 447 }); err != nil { 448 t.Fatal(err) 449 } 450 451 id, err := idx.LatestUpdatedID() 452 if err != nil { 453 t.Fatalf("LatestUpdatedID: %v", err) 454 } 455 if id != "new" { 456 t.Errorf("expected 'new', got %q", id) 457 } 458 } 459 460 func TestIndex_SearchFTSSyntaxError(t *testing.T) { 461 dir := t.TempDir() 462 idx, err := OpenIndex(dir) 463 if err != nil { 464 t.Fatalf("OpenIndex: %v", err) 465 } 466 defer idx.Close() 467 468 // Insert data so FTS actually runs the query 469 idx.UpsertSession(&Session{ 470 ID: "data", Title: "Data", CreatedAt: time.Now(), UpdatedAt: time.Now(), 471 Messages: []client.Message{ 472 {Role: "user", Content: client.NewTextContent("some data")}, 473 }, 474 }) 475 476 // Various malformed queries 477 badQueries := []string{ 478 `"unbalanced`, 479 `AND`, 480 `OR OR`, 481 `NOT`, 482 } 483 for _, q := range badQueries { 484 _, err := idx.Search(q, 10) 485 if err == nil { 486 continue // some "bad" queries may be valid in FTS5, that's OK 487 } 488 // If it errors, the message should be clean (not raw sqlite) 489 if !strings.Contains(err.Error(), "invalid search query") { 490 t.Errorf("query %q: expected clean error, got: %v", q, err) 491 } 492 } 493 } 494 495 func TestIndex_UpsertSkipsSystemInjected(t *testing.T) { 496 dir := t.TempDir() 497 idx, err := OpenIndex(dir) 498 if err != nil { 499 t.Fatalf("OpenIndex: %v", err) 500 } 501 defer idx.Close() 502 503 now := time.Now().Truncate(time.Second) 504 505 tests := []struct { 506 name string 507 messages []client.Message 508 meta []MessageMeta 509 wantMsgCount int 510 searchHit string 511 searchMiss string 512 }{ 513 { 514 name: "no meta (legacy session) indexes all", 515 messages: []client.Message{ 516 {Role: "user", Content: client.NewTextContent("legacy alpha")}, 517 {Role: "assistant", Content: client.NewTextContent("legacy bravo")}, 518 }, 519 meta: nil, 520 wantMsgCount: 2, 521 searchHit: "alpha", 522 searchMiss: "", 523 }, 524 { 525 // msg_count reflects total messages (len(sess.Messages)), not 526 // indexed rows, so the desktop sidebar's "used session" filter 527 // stays consistent with what the user sees in the conversation. 528 name: "injected messages excluded from index", 529 messages: []client.Message{ 530 {Role: "user", Content: client.NewTextContent("visible unicorn")}, 531 {Role: "assistant", Content: client.NewTextContent("injected giraffe")}, 532 {Role: "assistant", Content: client.NewTextContent("visible elephant")}, 533 }, 534 meta: []MessageMeta{ 535 {}, 536 {SystemInjected: true}, 537 {}, 538 }, 539 wantMsgCount: 3, 540 searchHit: "unicorn", 541 searchMiss: "giraffe", 542 }, 543 { 544 name: "short meta (fewer than messages) indexes unmatched positions", 545 messages: []client.Message{ 546 {Role: "user", Content: client.NewTextContent("first penguin")}, 547 {Role: "assistant", Content: client.NewTextContent("second dolphin")}, 548 {Role: "user", Content: client.NewTextContent("third falcon")}, 549 }, 550 meta: []MessageMeta{ 551 {SystemInjected: true}, 552 }, 553 wantMsgCount: 3, 554 searchHit: "dolphin", 555 searchMiss: "penguin", 556 }, 557 } 558 559 for i, tt := range tests { 560 t.Run(tt.name, func(t *testing.T) { 561 sessID := fmt.Sprintf("injected-%d", i) 562 sess := &Session{ 563 ID: sessID, Title: tt.name, CreatedAt: now, UpdatedAt: now, 564 Messages: tt.messages, 565 MessageMeta: tt.meta, 566 } 567 if err := idx.UpsertSession(sess); err != nil { 568 t.Fatalf("UpsertSession: %v", err) 569 } 570 571 summaries, err := idx.ListSessions() 572 if err != nil { 573 t.Fatalf("ListSessions: %v", err) 574 } 575 var found bool 576 for _, s := range summaries { 577 if s.ID == sessID { 578 found = true 579 if s.MsgCount != tt.wantMsgCount { 580 t.Errorf("msg_count = %d, want %d", s.MsgCount, tt.wantMsgCount) 581 } 582 } 583 } 584 if !found { 585 t.Fatalf("session %q not found in list", sessID) 586 } 587 588 if tt.searchHit != "" { 589 results, err := idx.Search(tt.searchHit, 20) 590 if err != nil { 591 t.Fatalf("Search(%q): %v", tt.searchHit, err) 592 } 593 if len(results) == 0 { 594 t.Errorf("expected hit for %q", tt.searchHit) 595 } 596 } 597 if tt.searchMiss != "" { 598 results, err := idx.Search(tt.searchMiss, 20) 599 if err != nil { 600 t.Fatalf("Search(%q): %v", tt.searchMiss, err) 601 } 602 for _, r := range results { 603 if r.SessionID == sessID { 604 t.Errorf("expected no hit for %q in session %q, but got one", tt.searchMiss, sessID) 605 } 606 } 607 } 608 }) 609 } 610 } 611 612 func TestIndex_IsEmpty(t *testing.T) { 613 dir := t.TempDir() 614 idx, err := OpenIndex(dir) 615 if err != nil { 616 t.Fatal(err) 617 } 618 defer idx.Close() 619 620 empty, err := idx.IsEmpty() 621 if err != nil { 622 t.Fatal(err) 623 } 624 if !empty { 625 t.Error("expected empty index") 626 } 627 628 now := time.Now().Truncate(time.Second) 629 if err := idx.UpsertSession(&Session{ 630 ID: "x", Title: "X", CreatedAt: now, UpdatedAt: now, 631 }); err != nil { 632 t.Fatal(err) 633 } 634 635 empty, err = idx.IsEmpty() 636 if err != nil { 637 t.Fatal(err) 638 } 639 if empty { 640 t.Error("expected non-empty index after insert") 641 } 642 } 643 644 // TestIndex_V2ToV3MigrationRebuildsFromJSON verifies that an existing v2 645 // sessions.db (no `source` column, PRAGMA user_version = 2) can be opened by 646 // the v3 schema without error: the rebuild path drops messages tables, the 647 // ALTER TABLE backfills the new column, and subsequent UpsertSession calls 648 // populate `source` correctly. 649 func TestIndex_V2ToV3MigrationRebuildsFromJSON(t *testing.T) { 650 dir := t.TempDir() 651 dbPath := filepath.Join(dir, "sessions.db") 652 { 653 raw, err := sql.Open("sqlite", dbPath) 654 if err != nil { 655 t.Fatalf("open raw sqlite: %v", err) 656 } 657 // Exact v2 CREATE TABLE — no source column. 658 if _, err := raw.Exec(`CREATE TABLE sessions (id TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '', cwd TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, msg_count INTEGER NOT NULL DEFAULT 0)`); err != nil { 659 t.Fatalf("create v2 sessions table: %v", err) 660 } 661 if _, err := raw.Exec(`PRAGMA user_version = 2`); err != nil { 662 t.Fatalf("stamp v2 user_version: %v", err) 663 } 664 now := time.Now().UTC() 665 if _, err := raw.Exec( 666 `INSERT INTO sessions (id, title, created_at, updated_at, msg_count) VALUES (?, ?, ?, ?, ?)`, 667 "s1", "old session", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), 1, 668 ); err != nil { 669 t.Fatalf("seed v2 row: %v", err) 670 } 671 if err := raw.Close(); err != nil { 672 t.Fatalf("close raw sqlite: %v", err) 673 } 674 } 675 676 // Open via the actual API. The version mismatch (2 != 3) MUST trigger 677 // the drop-and-rebuild path AND backfill the `source` column. 678 idx, err := OpenIndex(dir) 679 if err != nil { 680 t.Fatalf("OpenIndex (v2->v3): %v", err) 681 } 682 defer idx.Close() 683 684 // Confirm new column is queryable. Rebuild reads JSON files on disk 685 // (none seeded here), so an empty result set is expected — the assertion 686 // is that the query SUCCEEDS against the migrated schema. 687 rows, err := idx.ListUpdatedSince(context.Background(), time.Time{}) 688 if err != nil { 689 t.Fatalf("ListUpdatedSince after migration: %v", err) 690 } 691 _ = rows 692 693 // Confirm a fresh Upsert populates the source column. 694 if err := idx.UpsertSession(&Session{ 695 ID: "s2", Source: "slack", 696 CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), 697 }); err != nil { 698 t.Fatalf("UpsertSession with source: %v", err) 699 } 700 rows2, err := idx.ListUpdatedSince(context.Background(), time.Time{}) 701 if err != nil { 702 t.Fatalf("ListUpdatedSince after upsert: %v", err) 703 } 704 found := false 705 for _, r := range rows2 { 706 if r.ID == "s2" && r.Source == "slack" { 707 found = true 708 } 709 } 710 if !found { 711 t.Errorf("expected s2 with Source=slack after migration; got %+v", rows2) 712 } 713 } 714 715 func TestIndex_ListUpdatedSince(t *testing.T) { 716 dir := t.TempDir() 717 idx, err := OpenIndex(dir) 718 if err != nil { 719 t.Fatalf("OpenIndex: %v", err) 720 } 721 defer idx.Close() 722 723 now := time.Now().UTC().Truncate(time.Second) 724 older := now.Add(-2 * time.Hour) 725 newer := now.Add(-30 * time.Minute) 726 newest := now.Add(-1 * time.Minute) 727 728 mustUpsert(t, idx, &Session{ID: "old", CreatedAt: older, UpdatedAt: older}) 729 mustUpsert(t, idx, &Session{ID: "mid", CreatedAt: newer, UpdatedAt: newer}) 730 mustUpsert(t, idx, &Session{ID: "new", CreatedAt: newest, UpdatedAt: newest}) 731 732 cutoff := now.Add(-1 * time.Hour) 733 rows, err := idx.ListUpdatedSince(context.Background(), cutoff) 734 if err != nil { 735 t.Fatalf("ListUpdatedSince: %v", err) 736 } 737 738 gotIDs := map[string]bool{} 739 for _, r := range rows { 740 gotIDs[r.ID] = true 741 } 742 if gotIDs["old"] { 743 t.Errorf("ListUpdatedSince should exclude sessions with updated_at <= cutoff") 744 } 745 if !gotIDs["mid"] || !gotIDs["new"] { 746 t.Errorf("ListUpdatedSince should include sessions with updated_at > cutoff; got %v", gotIDs) 747 } 748 if len(rows) != 2 { 749 t.Errorf("expected 2 rows, got %d: %+v", len(rows), rows) 750 } 751 }