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, "<mark>") {
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
}