Files
onyx/cli/cmd/search_test.go
T

532 lines
16 KiB
Go

package cmd
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/onyx-dot-app/onyx/cli/internal/exitcodes"
"github.com/onyx-dot-app/onyx/cli/internal/iostreams"
"github.com/onyx-dot-app/onyx/cli/internal/models"
"github.com/spf13/cobra"
)
func TestSearch_NoQuery(t *testing.T) {
ios := &iostreams.IOStreams{
In: &bytes.Buffer{},
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
IsStdinTTY: true,
IsStdoutTTY: true,
}
cmd := newSearchCmd(ios)
cmd.SetArgs([]string{})
// Stub RunE so we don't need a real client, but keep the arg check.
origRunE := cmd.RunE
cmd.RunE = func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return exitcodes.New(exitcodes.BadRequest,
"no query provided\n Usage: onyx-cli search \"your query\"")
}
return origRunE(cmd, args)
}
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for missing query")
}
var exitErr *exitcodes.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("want *ExitError, got %T: %v", err, err)
}
if exitErr.Code != exitcodes.BadRequest {
t.Errorf("exit code = %d, want %d", exitErr.Code, exitcodes.BadRequest)
}
}
func TestBuildSearchRequest(t *testing.T) {
intPtr := func(v int) *int { return &v }
tests := []struct {
name string
query string
sources []string
days int
daysSet bool
agentID int
agentIDSet bool
defaultAgentID int
noQueryExpansion bool
wantSources []string
// wantDaysAgo is the expected "N days ago" cutoff; buildSearchRequest
// converts this to an ISO timestamp ~N*24h before now, asserted
// within a 10s tolerance below.
wantDaysAgo *int
wantPersonaID *int
wantSkipQueryExpansion bool
}{
{
name: "no_sources",
query: "test query",
wantSources: nil,
},
{
name: "two_sources",
query: "test query",
sources: []string{"slack", "google_drive"},
wantSources: []string{"slack", "google_drive"},
},
{
name: "empty_strings_filtered_from_sources",
query: "test query",
sources: []string{"slack", "", " ", "google_drive"},
wantSources: []string{"slack", "google_drive"},
},
{
name: "days_agentID_set",
query: "test query",
days: 30,
daysSet: true,
agentID: 3,
agentIDSet: true,
wantDaysAgo: intPtr(30),
wantPersonaID: intPtr(3),
},
{
name: "unset_flags_produce_zero_values",
query: "test query",
wantSources: nil,
wantDaysAgo: nil,
wantPersonaID: nil,
},
{
name: "agent_id_zero_explicitly_set",
query: "test query",
agentID: 0,
agentIDSet: true,
wantPersonaID: intPtr(0),
},
{
name: "no_query_expansion",
query: "exact error text",
noQueryExpansion: true,
wantSkipQueryExpansion: true,
},
{
name: "default_agent_id_fallback",
query: "test query",
defaultAgentID: 7,
wantPersonaID: intPtr(7),
},
{
name: "explicit_agent_id_overrides_default",
query: "test query",
agentID: 2,
agentIDSet: true,
defaultAgentID: 7,
wantPersonaID: intPtr(2),
},
{
name: "default_agent_id_zero_not_sent",
query: "test query",
defaultAgentID: 0,
wantPersonaID: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := buildSearchRequest(searchFlags{
query: tt.query,
sources: tt.sources,
days: tt.days,
daysSet: tt.daysSet,
agentID: tt.agentID,
agentIDSet: tt.agentIDSet,
defaultAgentID: tt.defaultAgentID,
noQueryExpansion: tt.noQueryExpansion,
})
if req.Query != tt.query {
t.Errorf("Query = %q, want %q", req.Query, tt.query)
}
// Sources
if tt.wantSources == nil {
if req.Sources != nil {
t.Errorf("Sources = %v, want nil", req.Sources)
}
} else {
if len(req.Sources) != len(tt.wantSources) {
t.Fatalf("Sources length = %d, want %d: %v", len(req.Sources), len(tt.wantSources), req.Sources)
}
for i, s := range tt.wantSources {
if req.Sources[i] != s {
t.Errorf("Sources[%d] = %q, want %q", i, req.Sources[i], s)
}
}
}
// TimeCutoff: when days is set, expect an ISO timestamp roughly
// `days` days before now.
if tt.wantDaysAgo == nil {
if req.TimeCutoff != nil {
t.Errorf("TimeCutoff = %v, want nil", *req.TimeCutoff)
}
} else {
if req.TimeCutoff == nil {
t.Fatalf("TimeCutoff = nil, want ~%d days ago", *tt.wantDaysAgo)
}
parsed, err := time.Parse(time.RFC3339, *req.TimeCutoff)
if err != nil {
t.Fatalf("TimeCutoff %q is not RFC3339: %v", *req.TimeCutoff, err)
}
expected := time.Now().UTC().Add(-time.Duration(*tt.wantDaysAgo) * 24 * time.Hour)
delta := parsed.Sub(expected)
if delta < -10*time.Second || delta > 10*time.Second {
t.Errorf("TimeCutoff = %v (off by %v), want ~%v", parsed, delta, expected)
}
}
// PersonaID
if tt.wantPersonaID == nil {
if req.PersonaID != nil {
t.Errorf("PersonaID = %d, want nil", *req.PersonaID)
}
} else {
if req.PersonaID == nil {
t.Fatalf("PersonaID = nil, want %d", *tt.wantPersonaID)
}
if *req.PersonaID != *tt.wantPersonaID {
t.Errorf("PersonaID = %d, want %d", *req.PersonaID, *tt.wantPersonaID)
}
}
// SkipQueryExpansion
if req.SkipQueryExpansion != tt.wantSkipQueryExpansion {
t.Errorf("SkipQueryExpansion = %v, want %v", req.SkipQueryExpansion, tt.wantSkipQueryExpansion)
}
})
}
}
func TestToSearchOutput(t *testing.T) {
intPtr := func(v int) *int { return &v }
strPtr := func(v string) *string { return &v }
resp := models.SearchResponse{
Results: []models.SearchResult{
{
CitationID: intPtr(1),
Title: "Onboarding guide",
Content: "Full chunk text for doc A — multiple paragraphs.",
Link: strPtr("https://docs.example.com/a"),
SourceType: "google_drive",
UpdatedAt: strPtr("2026-01-15T09:00:00Z"),
},
{
// Defensive case: the API contract guarantees every result is
// LLM-selected, but we still verify the projection handles
// nullable fields (CitationID, Link, UpdatedAt) cleanly when
// they happen to be nil.
CitationID: nil,
Title: "Stale draft",
Content: "Blurb for doc B",
Link: nil,
SourceType: "confluence",
UpdatedAt: nil,
},
},
}
out := toSearchOutput(resp)
if len(out.Results) != 2 {
t.Fatalf("Results length = %d, want 2", len(out.Results))
}
// Fully-populated result: every field should round-trip.
selected := out.Results[0]
if selected.Title != "Onboarding guide" {
t.Errorf("Results[0].Title = %q, want %q", selected.Title, "Onboarding guide")
}
if selected.Content != "Full chunk text for doc A — multiple paragraphs." {
t.Errorf("Results[0].Content = %q, want full chunk", selected.Content)
}
if selected.URL == nil || *selected.URL != "https://docs.example.com/a" {
t.Errorf("Results[0].URL = %v, want https://docs.example.com/a", selected.URL)
}
if selected.SourceType != "google_drive" {
t.Errorf("Results[0].SourceType = %q, want google_drive", selected.SourceType)
}
if selected.UpdatedAt == nil || *selected.UpdatedAt != "2026-01-15T09:00:00Z" {
t.Errorf("Results[0].UpdatedAt = %v, want 2026-01-15T09:00:00Z", selected.UpdatedAt)
}
// Nullable fields should round-trip as nil without panic.
withNils := out.Results[1]
if withNils.Title != "Stale draft" {
t.Errorf("Results[1].Title = %q, want Stale draft", withNils.Title)
}
if withNils.Content != "Blurb for doc B" {
t.Errorf("Results[1].Content = %q, want %q", withNils.Content, "Blurb for doc B")
}
if withNils.URL != nil {
t.Errorf("Results[1].URL = %v, want nil", withNils.URL)
}
if withNils.SourceType != "confluence" {
t.Errorf("Results[1].SourceType = %q, want confluence", withNils.SourceType)
}
if withNils.UpdatedAt != nil {
t.Errorf("Results[1].UpdatedAt = %v, want nil", withNils.UpdatedAt)
}
}
// makeSearchResults builds n results with roughly equal-size content chunks.
func makeSearchResults(n int, contentSize int) []searchOutputResult {
results := make([]searchOutputResult, 0, n)
for i := 0; i < n; i++ {
results = append(results, searchOutputResult{
Title: fmt.Sprintf("Doc %d", i),
SourceType: "confluence",
Content: strings.Repeat("x", contentSize),
})
}
return results
}
func TestWriteSearchJSON_UnderLimit(t *testing.T) {
var out, errOut bytes.Buffer
ios := &iostreams.IOStreams{Out: &out, ErrOut: &errOut}
output := searchOutput{Results: makeSearchResults(2, 100)}
if err := writeSearchJSON(ios, output, 50000); err != nil {
t.Fatalf("writeSearchJSON failed: %v", err)
}
var parsed searchOutput
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
t.Fatalf("stdout is not valid JSON: %v", err)
}
if parsed.Truncation != nil {
t.Fatal("under-limit output should not carry truncation metadata")
}
if len(parsed.Results) != 2 {
t.Fatalf("Results length = %d, want 2", len(parsed.Results))
}
if errOut.Len() != 0 {
t.Fatalf("expected empty stderr, got %q", errOut.String())
}
}
func TestWriteSearchJSON_OverLimitIsValidJSON(t *testing.T) {
var out, errOut bytes.Buffer
ios := &iostreams.IOStreams{Out: &out, ErrOut: &errOut}
output := searchOutput{Results: makeSearchResults(20, 500)}
fullData, err := json.MarshalIndent(output, "", " ")
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
limit := 3000
if len(fullData) <= limit {
t.Fatalf("test setup: payload (%d bytes) must exceed limit %d", len(fullData), limit)
}
if err := writeSearchJSON(ios, output, limit); err != nil {
t.Fatalf("writeSearchJSON failed: %v", err)
}
var parsed searchOutput
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
t.Fatalf("stdout is not valid JSON: %v\n%s", err, out.String())
}
if len(out.Bytes()) > limit+1 { // +1 for trailing newline
t.Fatalf("stdout is %d bytes, want <= %d", out.Len(), limit+1)
}
tr := parsed.Truncation
if tr == nil {
t.Fatal("expected truncation metadata")
}
t.Cleanup(func() { _ = os.Remove(tr.FullResponsePath) })
if !tr.Truncated {
t.Error("Truncated = false, want true")
}
if tr.TotalResults != 20 {
t.Errorf("TotalResults = %d, want 20", tr.TotalResults)
}
if tr.ShownResults != len(parsed.Results) {
t.Errorf("ShownResults = %d, want %d", tr.ShownResults, len(parsed.Results))
}
if tr.ShownResults < 1 || tr.ShownResults >= 20 {
t.Errorf("ShownResults = %d, want in [1, 19]", tr.ShownResults)
}
// Survivors must be the relevance-ordered prefix, not an arbitrary subset.
for i, r := range parsed.Results {
if want := fmt.Sprintf("Doc %d", i); r.Title != want {
t.Errorf("Results[%d].Title = %q, want %q", i, r.Title, want)
}
}
if tr.TotalBytes != len(fullData) {
t.Errorf("TotalBytes = %d, want %d", tr.TotalBytes, len(fullData))
}
if tr.ContentTruncated {
t.Error("ContentTruncated = true, want false when whole results fit")
}
if tr.Hint == "" {
t.Error("Hint should not be empty")
}
// Temp file must hold the complete response.
saved, err := os.ReadFile(tr.FullResponsePath)
if err != nil {
t.Fatalf("failed to read full response file: %v", err)
}
if !bytes.Equal(saved, fullData) {
t.Error("full response file does not match the full payload")
}
// Human note goes to stderr, not stdout.
if !strings.Contains(errOut.String(), "response truncated") {
t.Errorf("stderr should mention truncation, got %q", errOut.String())
}
}
func TestWriteSearchJSON_TempSaveFailureEmitsFullResponse(t *testing.T) {
// Dropped results must never be unrecoverable: with no temp copy, the
// full over-limit response is emitted instead of a truncated envelope.
t.Setenv("TMPDIR", "/nonexistent-onyx-cli-test")
var out, errOut bytes.Buffer
ios := &iostreams.IOStreams{Out: &out, ErrOut: &errOut}
output := searchOutput{Results: makeSearchResults(20, 500)}
if err := writeSearchJSON(ios, output, 3000); err != nil {
t.Fatalf("writeSearchJSON failed: %v", err)
}
var parsed searchOutput
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
t.Fatalf("stdout is not valid JSON: %v", err)
}
if parsed.Truncation != nil {
t.Fatal("full-response fallback must not carry truncation metadata")
}
if len(parsed.Results) != 20 {
t.Fatalf("Results length = %d, want all 20", len(parsed.Results))
}
if !strings.Contains(errOut.String(), "could not save full response") {
t.Errorf("stderr should warn about the failed save, got %q", errOut.String())
}
}
func TestBuildTruncatedSearchOutput_SingleOversizedResult(t *testing.T) {
// Multibyte runes verify the trim lands on a rune boundary.
content := strings.Repeat("héllo→wörld ", 500)
output := searchOutput{Results: []searchOutputResult{{
Title: "Big doc",
SourceType: "slack",
Content: content,
}}}
limit := 2000
data, err := buildTruncatedSearchOutput(output, limit, 99999, "/tmp/full.json")
if err != nil {
t.Fatalf("buildTruncatedSearchOutput failed: %v", err)
}
if len(data) > limit {
t.Fatalf("envelope is %d bytes, want <= %d", len(data), limit)
}
var parsed searchOutput
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("envelope is not valid JSON: %v", err)
}
if parsed.Truncation == nil || !parsed.Truncation.ContentTruncated {
t.Fatal("expected ContentTruncated = true")
}
if parsed.Truncation.ShownResults != 1 || len(parsed.Results) != 1 {
t.Fatalf("expected exactly 1 shown result, got %d", len(parsed.Results))
}
got := parsed.Results[0].Content
if !utf8.ValidString(got) {
t.Error("trimmed content is not valid UTF-8")
}
if len(got) == 0 || len(got) >= len(content) {
t.Errorf("trimmed content length = %d, want in (0, %d)", len(got), len(content))
}
if !strings.HasPrefix(content, got) {
t.Error("trimmed content is not a prefix of the original")
}
}
func TestBuildTruncatedSearchOutput_OversizedTitleFallsBackToZeroResults(t *testing.T) {
// Content trimming can't help when the overflow lives in an untrimmed
// field: even the empty-content render exceeds the limit, so the builder
// must fall back to the zero-results envelope (which fits).
output := searchOutput{Results: []searchOutputResult{{
Title: strings.Repeat("t", 5000),
SourceType: "slack",
Content: strings.Repeat("c", 5000),
}}}
limit := 1000
data, err := buildTruncatedSearchOutput(output, limit, 99999, "/tmp/full.json")
if err != nil {
t.Fatalf("buildTruncatedSearchOutput failed: %v", err)
}
if len(data) > limit {
t.Fatalf("envelope is %d bytes, want <= %d", len(data), limit)
}
var parsed searchOutput
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("envelope is not valid JSON: %v", err)
}
if len(parsed.Results) != 0 {
t.Fatalf("expected 0 results, got %d", len(parsed.Results))
}
if parsed.Truncation == nil || parsed.Truncation.ContentTruncated {
t.Fatal("zero-results fallback must not claim content was trimmed")
}
if parsed.Truncation.TotalResults != 1 || parsed.Truncation.ShownResults != 0 {
t.Fatalf(
"TotalResults/ShownResults = %d/%d, want 1/0",
parsed.Truncation.TotalResults, parsed.Truncation.ShownResults,
)
}
}
func TestBuildTruncatedSearchOutput_TinyLimitStillValidJSON(t *testing.T) {
output := searchOutput{Results: makeSearchResults(3, 200)}
// Limit smaller than the metadata itself: envelope may exceed the limit
// but must remain valid JSON.
data, err := buildTruncatedSearchOutput(output, 50, 1234, "/tmp/full.json")
if err != nil {
t.Fatalf("buildTruncatedSearchOutput failed: %v", err)
}
var parsed searchOutput
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("envelope is not valid JSON: %v", err)
}
if parsed.Truncation == nil || !parsed.Truncation.Truncated {
t.Fatal("expected truncation metadata")
}
if len(parsed.Results) != 0 {
t.Fatalf("expected 0 results when nothing fits, got %d", len(parsed.Results))
}
if parsed.Truncation.TotalResults != 3 {
t.Errorf("TotalResults = %d, want 3", parsed.Truncation.TotalResults)
}
}