package search_test import ( "context" "encoding/json" "strings" "testing" "time" "github.com/jmoiron/sqlx" "go.bigb.es/auxilia/culpa" "sourcecraft.dev/bigbes/lethe/internal/config" "sourcecraft.dev/bigbes/lethe/internal/domain/search" "sourcecraft.dev/bigbes/lethe/internal/domain/session" "sourcecraft.dev/bigbes/lethe/internal/platform/database" ) func newRepo(t *testing.T) (*search.Repository, *sqlx.DB) { t.Helper() d := &database.Database{Cfg: config.DatabaseConfig{Path: ":memory:", BusyTimeout: 5 * time.Second}} if err := d.Init(context.Background()); err != nil { t.Fatalf("database.Init: %v", err) } t.Cleanup(func() { _ = d.Destroy(context.Background()) }) repo := &search.Repository{Database: d} if err := repo.Init(context.Background()); err != nil { t.Fatalf("repo.Init: %v", err) } return repo, d.DB } func seedSession(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64) { t.Helper() seedSessionWithCwd(t, db, owner, tool, host, sid, startedAt, endedAt, nil) } func seedSessionWithCwd(t *testing.T, db *sqlx.DB, owner, tool, host, sid string, startedAt, endedAt int64, cwd *string) { t.Helper() _, err := db.Exec(`INSERT INTO sessions (owner, tool, host, session_id, started_at, ended_at, working_dir, source_file, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, owner, tool, host, sid, startedAt, endedAt, cwd, "/tmp/search.jsonl") if err != nil { t.Fatalf("seed session %s/%s/%s/%s: %v", owner, tool, host, sid, err) } } func seedTurn(t *testing.T, db *sqlx.DB, owner, tool, host, sid, tid string, seq, ts int64, role, content string, toolCalls *string) { t.Helper() _, err := db.Exec(`INSERT INTO turns (owner, tool, host, session_id, turn_id, seq, role, timestamp, content, model, tokens_in, tokens_out, cost_usd, tool_calls, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, NULL, ?, NULL)`, owner, tool, host, sid, tid, seq, role, ts, content, toolCalls) if err != nil { t.Fatalf("seed turn %s/%s: %v", sid, tid, err) } } func strptr(v string) *string { return &v } func TestSearchScopesToOwner(t *testing.T) { repo, db := newRepo(t) seedSession(t, db, "alice", "cc", "phoebe", "a", 100, 110) seedTurn(t, db, "alice", "cc", "phoebe", "a", "t1", 1, 101, "user", "needle belongs to alice", nil) seedSession(t, db, "bob", "cc", "phoebe", "b", 100, 110) seedTurn(t, db, "bob", "cc", "phoebe", "b", "t1", 1, 101, "user", "needle belongs to bob", nil) got, err := repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle", Limit: 10}) if err != nil { t.Fatalf("Search: %v", err) } if len(got.Results) != 1 || got.Results[0].Owner != "alice" || got.Results[0].SessionID != "a" { t.Fatalf("expected alice/a only; got %#v", got.Results) } } func TestSearchAppliesToolHostAndTimeFilters(t *testing.T) { repo, db := newRepo(t) for _, row := range []struct { tool, host, sid string ts int64 }{{"cc", "phoebe", "keep", 200}, {"gemini", "phoebe", "badtool", 200}, {"cc", "rhea", "badhost", 200}, {"cc", "phoebe", "early", 100}, {"cc", "phoebe", "late", 300}} { seedSession(t, db, "alice", row.tool, row.host, row.sid, row.ts-1, row.ts+1) seedTurn(t, db, "alice", row.tool, row.host, row.sid, "t1", 1, row.ts, "user", "needle", nil) } tool, host := "cc", "phoebe" since, until := int64(150), int64(250) got, err := repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle", Tool: &tool, Host: &host, Since: &since, Until: &until, Limit: 10}) if err != nil { t.Fatalf("Search: %v", err) } if len(got.Results) != 1 || got.Results[0].SessionID != "keep" { t.Fatalf("expected keep only; got %#v", got.Results) } } func TestSearchResultShapeIncludesWorkingDirRankMatchSourceAndEnvelope(t *testing.T) { repo, db := newRepo(t) cwd := "/code/project" seedSessionWithCwd(t, db, "alice", "cc", "phoebe", "s", 100, 110, &cwd) seedTurn(t, db, "alice", "cc", "phoebe", "s", "t1", 1, 101, "user", "needle", nil) got, err := repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle", Limit: 10}) if err != nil { t.Fatalf("Search: %v", err) } if got.Limit != 10 || len(got.Results) != 1 { t.Fatalf("bad envelope: %#v", got) } row := got.Results[0] if row.WorkingDir == nil || *row.WorkingDir != cwd { t.Fatalf("WorkingDir = %#v; want %q", row.WorkingDir, cwd) } if row.Rank == 0 { t.Fatalf("Rank was not populated: %#v", row) } if row.MatchSource != search.SourceTurn { t.Fatalf("MatchSource = %q; want %q", row.MatchSource, search.SourceTurn) } b, err := json.Marshal(got) if err != nil { t.Fatalf("Marshal: %v", err) } js := string(b) if !strings.Contains(js, `"results"`) || !strings.Contains(js, `"limit":10`) || strings.Contains(js, `"rows"`) || strings.Contains(js, `"source"`) { t.Fatalf("JSON envelope incompatible: %s", js) } } func TestSearchOrdersByRankBeforeTimestamp(t *testing.T) { repo, db := newRepo(t) seedSession(t, db, "alice", "cc", "phoebe", "s", 100, 500) seedTurn(t, db, "alice", "cc", "phoebe", "s", "newer-weaker", 1, 400, "user", "needle filler filler filler filler filler filler filler", nil) seedTurn(t, db, "alice", "cc", "phoebe", "s", "older-stronger", 2, 200, "user", "needle needle needle", nil) got, err := repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle", Limit: 10}) if err != nil { t.Fatalf("Search: %v", err) } if len(got.Results) < 2 { t.Fatalf("need two results; got %#v", got.Results) } if got.Results[0].TurnID != "older-stronger" { t.Fatalf("rank ordering ignored: got %#v", got.Results) } if got.Results[0].Rank > got.Results[1].Rank { t.Fatalf("rank should be ascending (lower better): %#v", got.Results) } } func TestSearchDefaultsToProseAndToolOutputIsOptIn(t *testing.T) { repo, db := newRepo(t) seedSession(t, db, "alice", "cc", "phoebe", "s", 100, 110) seedTurn(t, db, "alice", "cc", "phoebe", "s", "prose", 1, 101, "user", "needle in prose", nil) seedTurn(t, db, "alice", "cc", "phoebe", "s", "tool", 2, 102, "assistant", "plain text", strptr(`{"output":"needle in shell"}`)) proseOnly, err := repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle", Limit: 10}) if err != nil { t.Fatalf("Search proseOnly: %v", err) } if len(proseOnly.Results) != 1 || proseOnly.Results[0].TurnID != "prose" || proseOnly.Results[0].MatchSource != search.SourceTurn { t.Fatalf("expected only prose row; got %#v", proseOnly.Results) } withTools, err := repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle", IncludeToolOutputs: true, Limit: 10}) if err != nil { t.Fatalf("Search withTools: %v", err) } if len(withTools.Results) != 2 { t.Fatalf("expected prose + tool rows; got %#v", withTools.Results) } } func TestSearchDedupesTurnWithBetterRankedMatch(t *testing.T) { repo, db := newRepo(t) seedSession(t, db, "alice", "cc", "phoebe", "s", 100, 110) seedTurn(t, db, "alice", "cc", "phoebe", "s", "t1", 1, 101, "assistant", "needle filler filler filler filler filler filler filler", strptr(`{"output":"needle needle needle"}`)) got, err := repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle", IncludeToolOutputs: true, Limit: 10}) if err != nil { t.Fatalf("Search: %v", err) } if len(got.Results) != 1 || got.Results[0].TurnID != "t1" || got.Results[0].MatchSource != search.SourceToolOutput { t.Fatalf("expected one better-ranked tool row; got %#v", got.Results) } } func TestSearchCursorUsesRankTimestampTurnIDAndMatchSourceTieBreakers(t *testing.T) { repo, db := newRepo(t) seedSession(t, db, "alice", "cc", "phoebe", "s", 100, 110) seedTurn(t, db, "alice", "cc", "phoebe", "s", "a", 1, 101, "user", "needle", nil) seedTurn(t, db, "alice", "cc", "phoebe", "s", "b", 2, 101, "user", "needle", nil) seedTurn(t, db, "alice", "cc", "phoebe", "s", "c", 3, 100, "assistant", "plain", strptr(`{"output":"needle"}`)) page1, err := repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle", IncludeToolOutputs: true, Limit: 2}) if err != nil { t.Fatalf("Search page1: %v", err) } if len(page1.Results) != 2 || page1.Results[0].TurnID != "a" || page1.Results[1].TurnID != "b" || page1.NextCursor == "" { t.Fatalf("bad page1: %#v", page1) } cur, err := search.DecodeCursor(page1.NextCursor, search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle", IncludeToolOutputs: true, Limit: 99}) if err != nil { t.Fatalf("DecodeCursor: %v", err) } if cur.Rank != page1.Results[1].Rank || cur.Timestamp != page1.Results[1].Timestamp || cur.TurnID != "b" || cur.MatchSource != search.SourceTurn { t.Fatalf("cursor did not preserve planned tie-breakers: %#v vs row %#v", cur, page1.Results[1]) } page2, err := repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle", IncludeToolOutputs: true, Limit: 2, Cursor: page1.NextCursor}) if err != nil { t.Fatalf("Search page2: %v", err) } if len(page2.Results) != 1 || page2.Results[0].TurnID != "c" || page2.Results[0].MatchSource != search.SourceToolOutput || page2.NextCursor != "" { t.Fatalf("bad page2: %#v", page2) } _, err = repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "other", IncludeToolOutputs: true, Limit: 1, Cursor: page1.NextCursor}) if codeOf(err) != "INVALID" { t.Fatalf("expected INVALID for filter-bound cursor, got %q (%v)", codeOf(err), err) } } func TestDecodeCursorRejectsMalformedInput(t *testing.T) { _, err := search.DecodeCursor("not-base64", search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle"}) if codeOf(err) != "INVALID" { t.Fatalf("expected INVALID, got %q (%v)", codeOf(err), err) } } func TestSearchSnippetUsesMarkerRunes(t *testing.T) { repo, db := newRepo(t) seedSession(t, db, "alice", "cc", "phoebe", "s", 100, 110) seedTurn(t, db, "alice", "cc", "phoebe", "s", "t1", 1, 101, "user", "alpha needle omega", nil) got, err := repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: "needle", Limit: 10}) if err != nil { t.Fatalf("Search: %v", err) } if len(got.Results) != 1 || !strings.Contains(got.Results[0].Snippet, "\x02needle\x03") || strings.Contains(got.Results[0].Snippet, "") { t.Fatalf("snippet missing marker bytes or contains HTML: %#v", got.Results) } } func TestSearchInvalidAndEmptyQueriesMapToInvalid(t *testing.T) { repo, db := newRepo(t) seedSession(t, db, "alice", "cc", "phoebe", "s", 100, 110) seedTurn(t, db, "alice", "cc", "phoebe", "s", "t1", 1, 101, "user", "alpha", nil) for _, q := range []string{"", " ", `"unterminated`} { _, err := repo.Search(context.Background(), search.Filter{Owner: session.OwnerScope{User: "alice"}, Query: q, Limit: 10}) if codeOf(err) != "INVALID" { t.Fatalf("query %q: expected INVALID, got %q (%v)", q, codeOf(err), err) } } } func codeOf(err error) string { var cd culpa.CodeDetail if !culpa.FindDetail(err, &cd) { return "" } s, _ := cd.Code.(string) return s }