Files
claude-mnemonic/internal/search/manager_test.go
T
lukaszraczylo a81482d06a fix: address 15 additional hang vectors found during deep audit (#45)
MCP server (5 fixes):
- Move semaphore acquisition inside goroutine so main loop stays
  responsive when all slots are taken
- Add 10s write timeout to sendResponse to prevent pipe deadlock
  when Claude Code pauses reading stdout
- Send fallback JSON-RPC error when json.Marshal fails instead of
  silently swallowing the error and leaving caller waiting forever
- Silence unknown notification methods (req.ID == nil) instead of
  sending unsolicited error responses that may desync the host
- Return MCP isError content for tool failures instead of top-level
  JSON-RPC error, matching the MCP specification

Vector/embedding (3 fixes):
- Move EmbedBatchWithContext call before writeMu.Lock in AddDocuments
  so ONNX inference runs outside the write lock
- Replace singleflight.Do with DoChan + ctx select in both
  getOrComputeEmbedding and UnifiedSearch so callers can bail out
  independently when their context expires
- Add activeQueries atomic counter; skip cache warming when user
  queries are in-flight; reduce warming timeout from 5s to 2s

Hooks (4 fixes):
- Cap EnsureWorkerRunning to 15s hard deadline with context; reduce
  StartupTimeout from 30s to 10s; reduce port-in-use retries
- Fix nil dereference panic in user-prompt hook when initResult is
  nil (non-JSON worker response); use comma-ok assertions
- Use package-level hookClient/healthClient with DisableKeepAlives
  to prevent FD leaks in short-lived hook processes
- Set SysProcAttr{Setpgid: true} to detach worker from hook process
  group, preventing kill-cascade from Claude Code

Worker/DB (3 fixes):
- Replace os.Exit(0) in MCP config watcher with context cancellation
  for clean protocol shutdown
- Add 60s context.WithTimeout around ProcessObservation calls in
  processAllSessions to prevent hung CLI subprocesses from blocking
  the queue processor forever
- Set explicit PRAGMA wal_autocheckpoint=1000 and add PASSIVE WAL
  checkpoint to Optimize() to prevent checkpoint stalls

Adds 20+ regression tests across all fix areas.
2026-05-26 14:29:34 +01:00

1162 lines
32 KiB
Go

// Package search provides unified search capabilities for claude-mnemonic.
package search
import (
"database/sql"
"testing"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
)
// ManagerSuite is a test suite for search Manager operations.
type ManagerSuite struct {
suite.Suite
}
func TestManagerSuite(t *testing.T) {
suite.Run(t, new(ManagerSuite))
}
// TestNewManager tests manager creation.
func (s *ManagerSuite) TestNewManager() {
// Test with nil stores (valid use case for testing)
m := NewManager(nil, nil, nil, nil)
s.NotNil(m)
s.Nil(m.observationStore)
s.Nil(m.summaryStore)
s.Nil(m.promptStore)
s.Nil(m.vectorClient)
}
// TestSearchParams tests SearchParams defaults.
func (s *ManagerSuite) TestSearchParams() {
params := SearchParams{
Query: "test query",
Project: "my-project",
Limit: 10,
}
s.Equal("test query", params.Query)
s.Equal("my-project", params.Project)
s.Equal(10, params.Limit)
s.Equal("", params.Type)
s.Equal("", params.OrderBy)
}
// TestSearchResult tests SearchResult struct.
func (s *ManagerSuite) TestSearchResult() {
result := SearchResult{
Type: "observation",
ID: 123,
Title: "Test Title",
Content: "Test content",
Project: "my-project",
Scope: "project",
CreatedAt: 1704067200000,
Score: 0.95,
Metadata: map[string]interface{}{
"obs_type": "discovery",
},
}
s.Equal("observation", result.Type)
s.Equal(int64(123), result.ID)
s.Equal("Test Title", result.Title)
s.Equal("Test content", result.Content)
s.Equal("my-project", result.Project)
s.Equal("project", result.Scope)
s.Equal(int64(1704067200000), result.CreatedAt)
s.Equal(0.95, result.Score)
s.Equal("discovery", result.Metadata["obs_type"])
}
// TestUnifiedSearchResult tests UnifiedSearchResult struct.
func (s *ManagerSuite) TestUnifiedSearchResult() {
result := UnifiedSearchResult{
Results: []SearchResult{
{Type: "observation", ID: 1},
{Type: "session", ID: 2},
},
TotalCount: 2,
Query: "test",
}
s.Len(result.Results, 2)
s.Equal(2, result.TotalCount)
s.Equal("test", result.Query)
}
// TestTruncate tests the truncate helper function.
func TestTruncate(t *testing.T) {
tests := []struct {
name string
input string
expected string
maxLen int
}{
{
name: "short string no truncation",
input: "hello",
maxLen: 10,
expected: "hello",
},
{
name: "exact length no truncation",
input: "hello",
maxLen: 5,
expected: "hello",
},
{
name: "long string truncated",
input: "hello world this is a long string",
maxLen: 10,
expected: "hello worl...",
},
{
name: "empty string",
input: "",
maxLen: 10,
expected: "",
},
{
name: "whitespace trimmed",
input: " hello ",
maxLen: 10,
expected: "hello",
},
{
name: "whitespace trimmed then truncated",
input: " hello world this is long ",
maxLen: 10,
expected: "hello worl...",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncate(tt.input, tt.maxLen)
assert.Equal(t, tt.expected, result)
})
}
}
// TestObservationToResult tests observation to result conversion.
func TestObservationToResult(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
tests := []struct {
obs *models.Observation
name string
format string
expected SearchResult
}{
{
name: "full format with all fields",
obs: &models.Observation{
ID: 123,
Project: "my-project",
Type: models.ObsTypeDiscovery,
Scope: models.ScopeProject,
Title: sql.NullString{String: "Test Title", Valid: true},
Narrative: sql.NullString{String: "Full narrative content", Valid: true},
CreatedAtEpoch: 1704067200000,
},
format: "full",
expected: SearchResult{
Type: "observation",
ID: 123,
Title: "Test Title",
Content: "Full narrative content",
Project: "my-project",
Scope: "project",
CreatedAt: 1704067200000,
},
},
{
name: "index format no content",
obs: &models.Observation{
ID: 456,
Project: "other-project",
Type: models.ObsTypeBugfix,
Scope: models.ScopeGlobal,
Title: sql.NullString{String: "Bug Fix", Valid: true},
Narrative: sql.NullString{String: "Narrative here", Valid: true},
CreatedAtEpoch: 1704067200000,
},
format: "index",
expected: SearchResult{
Type: "observation",
ID: 456,
Title: "Bug Fix",
Content: "", // Not included in index format
Project: "other-project",
Scope: "global",
CreatedAt: 1704067200000,
},
},
{
name: "null title",
obs: &models.Observation{
ID: 789,
Project: "project",
Type: models.ObsTypeFeature,
Scope: models.ScopeProject,
Title: sql.NullString{Valid: false},
Narrative: sql.NullString{Valid: false},
CreatedAtEpoch: 1704067200000,
},
format: "full",
expected: SearchResult{
Type: "observation",
ID: 789,
Title: "",
Content: "",
Project: "project",
Scope: "project",
CreatedAt: 1704067200000,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := m.observationToResult(tt.obs, tt.format)
assert.Equal(t, tt.expected.Type, result.Type)
assert.Equal(t, tt.expected.ID, result.ID)
assert.Equal(t, tt.expected.Title, result.Title)
assert.Equal(t, tt.expected.Content, result.Content)
assert.Equal(t, tt.expected.Project, result.Project)
assert.Equal(t, tt.expected.Scope, result.Scope)
assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt)
})
}
}
// TestSummaryToResult tests summary to result conversion.
func TestSummaryToResult(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
tests := []struct {
summary *models.SessionSummary
name string
format string
expected SearchResult
}{
{
name: "full format with all fields",
summary: &models.SessionSummary{
ID: 123,
Project: "my-project",
Request: sql.NullString{String: "Test request", Valid: true},
Learned: sql.NullString{String: "Learned this content", Valid: true},
CreatedAtEpoch: 1704067200000,
},
format: "full",
expected: SearchResult{
Type: "session",
ID: 123,
Title: "Test request",
Content: "Learned this content",
Project: "my-project",
CreatedAt: 1704067200000,
},
},
{
name: "index format no content",
summary: &models.SessionSummary{
ID: 456,
Project: "other-project",
Request: sql.NullString{String: "Another request", Valid: true},
Learned: sql.NullString{String: "Some learning", Valid: true},
CreatedAtEpoch: 1704067200000,
},
format: "index",
expected: SearchResult{
Type: "session",
ID: 456,
Title: "Another request",
Content: "", // Not included in index format
Project: "other-project",
CreatedAt: 1704067200000,
},
},
{
name: "long title truncated",
summary: &models.SessionSummary{
ID: 789,
Project: "project",
Request: sql.NullString{String: "This is a very long request that should be truncated because it exceeds the maximum allowed length for titles which is 100 characters", Valid: true},
Learned: sql.NullString{Valid: false},
CreatedAtEpoch: 1704067200000,
},
format: "full",
expected: SearchResult{
Type: "session",
ID: 789,
Title: "This is a very long request that should be truncated because it exceeds the maximum allowed length f...",
Content: "",
Project: "project",
CreatedAt: 1704067200000,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := m.summaryToResult(tt.summary, tt.format)
assert.Equal(t, tt.expected.Type, result.Type)
assert.Equal(t, tt.expected.ID, result.ID)
assert.Equal(t, tt.expected.Title, result.Title)
assert.Equal(t, tt.expected.Content, result.Content)
assert.Equal(t, tt.expected.Project, result.Project)
assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt)
})
}
}
// TestPromptToResult tests prompt to result conversion.
func TestPromptToResult(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
tests := []struct {
prompt *models.UserPromptWithSession
name string
format string
expected SearchResult
}{
{
name: "full format with content",
prompt: &models.UserPromptWithSession{
UserPrompt: models.UserPrompt{
ID: 123,
PromptText: "What is the meaning of life?",
CreatedAtEpoch: 1704067200000,
},
Project: "my-project",
},
format: "full",
expected: SearchResult{
Type: "prompt",
ID: 123,
Title: "What is the meaning of life?",
Content: "What is the meaning of life?",
Project: "my-project",
CreatedAt: 1704067200000,
},
},
{
name: "index format no content",
prompt: &models.UserPromptWithSession{
UserPrompt: models.UserPrompt{
ID: 456,
PromptText: "Short prompt",
CreatedAtEpoch: 1704067200000,
},
Project: "other-project",
},
format: "index",
expected: SearchResult{
Type: "prompt",
ID: 456,
Title: "Short prompt",
Content: "",
Project: "other-project",
CreatedAt: 1704067200000,
},
},
{
name: "long prompt truncated title",
prompt: &models.UserPromptWithSession{
UserPrompt: models.UserPrompt{
ID: 789,
PromptText: "This is a very long prompt that should be truncated because it exceeds the maximum allowed length for titles which is 100 characters and it keeps going",
CreatedAtEpoch: 1704067200000,
},
Project: "project",
},
format: "full",
expected: SearchResult{
Type: "prompt",
ID: 789,
Title: "This is a very long prompt that should be truncated because it exceeds the maximum allowed length fo...",
Content: "This is a very long prompt that should be truncated because it exceeds the maximum allowed length for titles which is 100 characters and it keeps going",
Project: "project",
CreatedAt: 1704067200000,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := m.promptToResult(tt.prompt, tt.format)
assert.Equal(t, tt.expected.Type, result.Type)
assert.Equal(t, tt.expected.ID, result.ID)
assert.Equal(t, tt.expected.Title, result.Title)
assert.Equal(t, tt.expected.Content, result.Content)
assert.Equal(t, tt.expected.Project, result.Project)
assert.Equal(t, tt.expected.CreatedAt, result.CreatedAt)
})
}
}
// TestSearchParamsValidation tests parameter validation in UnifiedSearch.
func TestSearchParamsValidation(t *testing.T) {
tests := []struct {
name string
expectedOrder string
params SearchParams
expectedLimit int
}{
{
name: "default limit applied",
params: SearchParams{
Query: "test",
Project: "project",
Limit: 0,
},
expectedLimit: 20,
expectedOrder: "date_desc",
},
{
name: "negative limit corrected",
params: SearchParams{
Query: "test",
Project: "project",
Limit: -5,
},
expectedLimit: 20,
expectedOrder: "date_desc",
},
{
name: "limit over 100 capped",
params: SearchParams{
Query: "test",
Project: "project",
Limit: 200,
},
expectedLimit: 100,
expectedOrder: "date_desc",
},
{
name: "custom limit preserved",
params: SearchParams{
Query: "test",
Project: "project",
Limit: 50,
OrderBy: "relevance",
},
expectedLimit: 50,
expectedOrder: "relevance",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Since we can't easily call UnifiedSearch without stores,
// we verify the expected values through logic
params := tt.params
// Simulate the validation logic from UnifiedSearch
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
if params.OrderBy == "" {
params.OrderBy = "date_desc"
}
assert.Equal(t, tt.expectedLimit, params.Limit)
assert.Equal(t, tt.expectedOrder, params.OrderBy)
})
}
}
// TestDecisionsQueryBoost tests Decisions search query boosting.
func TestDecisionsQueryBoost(t *testing.T) {
tests := []struct {
name string
inputQuery string
expectedQuery string
}{
{
name: "empty query not boosted",
inputQuery: "",
expectedQuery: "",
},
{
name: "query boosted with keywords",
inputQuery: "authentication",
expectedQuery: "authentication decision chose architecture",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := SearchParams{Query: tt.inputQuery}
// Simulate Decisions boost logic
if params.Query != "" {
params.Query = params.Query + " decision chose architecture"
}
assert.Equal(t, tt.expectedQuery, params.Query)
})
}
}
// TestChangesQueryBoost tests Changes search query boosting.
func TestChangesQueryBoost(t *testing.T) {
tests := []struct {
name string
inputQuery string
expectedQuery string
}{
{
name: "empty query not boosted",
inputQuery: "",
expectedQuery: "",
},
{
name: "query boosted with keywords",
inputQuery: "handler",
expectedQuery: "handler changed modified refactored",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := SearchParams{Query: tt.inputQuery}
// Simulate Changes boost logic
if params.Query != "" {
params.Query = params.Query + " changed modified refactored"
}
assert.Equal(t, tt.expectedQuery, params.Query)
})
}
}
// TestHowItWorksQueryBoost tests HowItWorks search query boosting.
func TestHowItWorksQueryBoost(t *testing.T) {
tests := []struct {
name string
inputQuery string
expectedQuery string
}{
{
name: "empty query not boosted",
inputQuery: "",
expectedQuery: "",
},
{
name: "query boosted with keywords",
inputQuery: "database",
expectedQuery: "database architecture design pattern implements",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := SearchParams{Query: tt.inputQuery}
// Simulate HowItWorks boost logic
if params.Query != "" {
params.Query = params.Query + " architecture design pattern implements"
}
assert.Equal(t, tt.expectedQuery, params.Query)
})
}
}
// TestSearchTypeMapping tests type string to doc type mapping.
func TestSearchTypeMapping(t *testing.T) {
tests := []struct {
typeStr string
expected string
}{
{"observations", "observation"},
{"sessions", "session_summary"},
{"prompts", "user_prompt"},
{"", ""}, // Empty type for all
}
for _, tt := range tests {
t.Run("type_"+tt.typeStr, func(t *testing.T) {
// This tests the type mapping logic
// Just verify the valid type strings
validTypes := map[string]bool{
"observations": true,
"sessions": true,
"prompts": true,
"": true,
}
assert.True(t, validTypes[tt.typeStr])
})
}
}
// TestFilterSearchWithObservations tests filter search when observations exist.
func TestFilterSearchWithObservations(t *testing.T) {
// Create mock observation
obs := &models.Observation{
ID: 1,
Project: "test-project",
Type: models.ObsTypeDiscovery,
Scope: models.ScopeProject,
Title: sql.NullString{String: "Test Title", Valid: true},
Narrative: sql.NullString{String: "Test narrative content", Valid: true},
CreatedAtEpoch: 1704067200000,
}
m := NewManager(nil, nil, nil, nil)
result := m.observationToResult(obs, "full")
assert.Equal(t, "observation", result.Type)
assert.Equal(t, int64(1), result.ID)
assert.Equal(t, "Test Title", result.Title)
assert.Equal(t, "Test narrative content", result.Content)
assert.Equal(t, "test-project", result.Project)
assert.Equal(t, "project", result.Scope)
}
// TestManagerStoreReferences tests that Manager stores references correctly.
func TestManagerStoreReferences(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
assert.Nil(t, m.observationStore)
assert.Nil(t, m.summaryStore)
assert.Nil(t, m.promptStore)
assert.Nil(t, m.vectorClient)
}
// TestObservationToResultWithMetadata tests metadata inclusion in results.
func TestObservationToResultWithMetadata(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
tests := []struct {
name string
obsType models.ObservationType
scope models.ObservationScope
}{
{"bugfix_project", models.ObsTypeBugfix, models.ScopeProject},
{"feature_global", models.ObsTypeFeature, models.ScopeGlobal},
{"discovery_project", models.ObsTypeDiscovery, models.ScopeProject},
{"decision_global", models.ObsTypeDecision, models.ScopeGlobal},
{"refactor_project", models.ObsTypeRefactor, models.ScopeProject},
{"change_global", models.ObsTypeChange, models.ScopeGlobal},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
obs := &models.Observation{
ID: 1,
Project: "test-project",
Type: tt.obsType,
Scope: tt.scope,
Title: sql.NullString{String: "Title", Valid: true},
CreatedAtEpoch: 1704067200000,
}
result := m.observationToResult(obs, "full")
assert.Equal(t, string(tt.obsType), result.Metadata["obs_type"])
assert.Equal(t, string(tt.scope), result.Metadata["scope"])
})
}
}
// TestSummaryToResultTruncation tests title truncation in summary results.
func TestSummaryToResultTruncation(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
tests := []struct {
name string
request string
expectedLen int
shouldTrunc bool
}{
{"short_title", "Short request", 13, false},
{"exact_100", string(make([]byte, 100)), 103, true}, // 100 + "..."
{"over_100", string(make([]byte, 150)), 103, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
summary := &models.SessionSummary{
ID: 1,
Project: "test-project",
Request: sql.NullString{String: tt.request, Valid: true},
CreatedAtEpoch: 1704067200000,
}
result := m.summaryToResult(summary, "full")
if tt.shouldTrunc {
assert.LessOrEqual(t, len(result.Title), tt.expectedLen)
assert.True(t, len(result.Title) <= 103) // max 100 + "..."
} else {
assert.Equal(t, tt.request, result.Title)
}
})
}
}
// TestPromptToResultFormats tests prompt to result conversion with different formats.
func TestPromptToResultFormats(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
prompt := &models.UserPromptWithSession{
UserPrompt: models.UserPrompt{
ID: 123,
PromptText: "What is the meaning of life?",
CreatedAtEpoch: 1704067200000,
},
Project: "my-project",
}
// Full format - includes content
fullResult := m.promptToResult(prompt, "full")
assert.Equal(t, "What is the meaning of life?", fullResult.Content)
// Index format - no content
indexResult := m.promptToResult(prompt, "index")
assert.Equal(t, "", indexResult.Content)
// Both should have same title
assert.Equal(t, fullResult.Title, indexResult.Title)
}
// TestSearchParamsDefaults tests that search params have proper defaults.
func TestSearchParamsDefaults(t *testing.T) {
tests := []struct {
name string
initialOrder string
expectedOrder string
initialLimit int
expectedLimit int
}{
{name: "zero_limit", initialOrder: "", expectedOrder: "date_desc", initialLimit: 0, expectedLimit: 20},
{name: "negative_limit", initialOrder: "", expectedOrder: "date_desc", initialLimit: -5, expectedLimit: 20},
{name: "over_100_limit", initialOrder: "", expectedOrder: "date_desc", initialLimit: 150, expectedLimit: 100},
{name: "valid_limit_50", initialOrder: "relevance", expectedOrder: "relevance", initialLimit: 50, expectedLimit: 50},
{name: "custom_order", initialOrder: "date_asc", expectedOrder: "date_asc", initialLimit: 30, expectedLimit: 30},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := SearchParams{
Query: "test",
Project: "project",
Limit: tt.initialLimit,
OrderBy: tt.initialOrder,
}
// Simulate the normalization that happens in UnifiedSearch
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
if params.OrderBy == "" {
params.OrderBy = "date_desc"
}
assert.Equal(t, tt.expectedLimit, params.Limit)
assert.Equal(t, tt.expectedOrder, params.OrderBy)
})
}
}
// TestTruncateEdgeCases tests edge cases for truncate function.
func TestTruncateEdgeCases(t *testing.T) {
tests := []struct {
name string
input string
expected string
maxLen int
}{
// Unicode strings - uses byte length so ensure maxLen accommodates full string
{name: "unicode_string_no_truncate", input: "日本語テスト", expected: "日本語テスト", maxLen: 20},
{name: "mixed_unicode_no_truncate", input: "Hello世界", expected: "Hello世界", maxLen: 15},
// ASCII truncation
{name: "ascii_truncate", input: "Hello World", expected: "Hello...", maxLen: 5},
{name: "only_whitespace", input: " ", expected: "", maxLen: 10},
{name: "tabs_and_newlines", input: "\t\n \t", expected: "", maxLen: 10},
{name: "newlines_with_content", input: "\n\nhello\n\n", expected: "hello", maxLen: 10},
{name: "zero_max_len", input: "hello", expected: "...", maxLen: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncate(tt.input, tt.maxLen)
assert.Equal(t, tt.expected, result)
})
}
}
// TestUnifiedSearchResultEmpty tests empty UnifiedSearchResult.
func TestUnifiedSearchResultEmpty(t *testing.T) {
result := UnifiedSearchResult{
Results: []SearchResult{},
TotalCount: 0,
Query: "",
}
assert.Empty(t, result.Results)
assert.Equal(t, 0, result.TotalCount)
assert.Equal(t, "", result.Query)
}
// TestSearchResultMetadata tests SearchResult metadata handling.
func TestSearchResultMetadata(t *testing.T) {
result := SearchResult{
Type: "observation",
ID: 1,
Metadata: map[string]interface{}{
"obs_type": "discovery",
"scope": "project",
"count": 42,
"enabled": true,
},
}
assert.Equal(t, "discovery", result.Metadata["obs_type"])
assert.Equal(t, "project", result.Metadata["scope"])
assert.Equal(t, 42, result.Metadata["count"])
assert.Equal(t, true, result.Metadata["enabled"])
}
// TestSearchResultTypes tests all search result types.
func TestSearchResultTypes(t *testing.T) {
types := []string{"observation", "session", "prompt"}
for _, typ := range types {
t.Run(typ, func(t *testing.T) {
result := SearchResult{
Type: typ,
ID: 1,
Project: "test",
CreatedAt: time.Now().UnixMilli(),
}
assert.Equal(t, typ, result.Type)
})
}
}
// TestSearchParamsAllFields tests SearchParams with all fields populated.
func TestSearchParamsAllFields(t *testing.T) {
params := SearchParams{
Query: "authentication bug",
Type: "observations",
Project: "my-project",
ObsType: "bugfix",
Concepts: "security,auth",
Files: "handler.go,auth.go",
DateStart: 1700000000000,
DateEnd: 1700100000000,
OrderBy: "relevance",
Limit: 25,
Offset: 10,
Format: "full",
Scope: "project",
IncludeGlobal: true,
}
assert.Equal(t, "authentication bug", params.Query)
assert.Equal(t, "observations", params.Type)
assert.Equal(t, "my-project", params.Project)
assert.Equal(t, "bugfix", params.ObsType)
assert.Equal(t, "security,auth", params.Concepts)
assert.Equal(t, "handler.go,auth.go", params.Files)
assert.Equal(t, int64(1700000000000), params.DateStart)
assert.Equal(t, int64(1700100000000), params.DateEnd)
assert.Equal(t, "relevance", params.OrderBy)
assert.Equal(t, 25, params.Limit)
assert.Equal(t, 10, params.Offset)
assert.Equal(t, "full", params.Format)
assert.Equal(t, "project", params.Scope)
assert.True(t, params.IncludeGlobal)
}
// TestObservationToResultWithNullFields tests handling of null fields.
func TestObservationToResultWithNullFields(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
obs := &models.Observation{
ID: 1,
Project: "test-project",
Type: models.ObsTypeDiscovery,
Scope: models.ScopeProject,
Title: sql.NullString{Valid: false},
Narrative: sql.NullString{Valid: false},
CreatedAtEpoch: 1704067200000,
}
result := m.observationToResult(obs, "full")
assert.Equal(t, "", result.Title)
assert.Equal(t, "", result.Content)
}
// TestSummaryToResultWithNullFields tests handling of null fields in summary.
func TestSummaryToResultWithNullFields(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
summary := &models.SessionSummary{
ID: 1,
Project: "test-project",
Request: sql.NullString{Valid: false},
Learned: sql.NullString{Valid: false},
CreatedAtEpoch: 1704067200000,
}
result := m.summaryToResult(summary, "full")
assert.Equal(t, "", result.Title)
assert.Equal(t, "", result.Content)
}
// TestSearchParams_LimitValues tests limit parameter handling values.
func TestSearchParams_LimitValues(t *testing.T) {
tests := []struct {
name string
inputLimit int
expectedValid bool
}{
{"zero_limit", 0, true},
{"negative_limit", -5, true},
{"normal_limit", 20, true},
{"max_limit", 100, true},
{"over_limit", 200, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := SearchParams{
Query: "test",
Project: "test",
Limit: tt.inputLimit,
}
assert.NotNil(t, params)
assert.Equal(t, tt.inputLimit, params.Limit)
})
}
}
// TestSearchParams_OrderByValues tests order by parameter values.
func TestSearchParams_OrderByValues(t *testing.T) {
validOrders := []string{"relevance", "date_desc", "date_asc", ""}
for _, order := range validOrders {
t.Run("order_"+order, func(t *testing.T) {
params := SearchParams{
Query: "test",
Project: "test",
OrderBy: order,
}
assert.Equal(t, order, params.OrderBy)
})
}
}
// TestSearchParams_TypeValues tests type parameter values.
func TestSearchParams_TypeValues(t *testing.T) {
validTypes := []string{"observations", "sessions", "prompts", ""}
for _, typ := range validTypes {
t.Run("type_"+typ, func(t *testing.T) {
params := SearchParams{
Query: "test",
Project: "test",
Type: typ,
}
assert.Equal(t, typ, params.Type)
})
}
}
// TestSearchParams_ScopeValues tests scope parameter values.
func TestSearchParams_ScopeValues(t *testing.T) {
validScopes := []string{"project", "global", ""}
for _, scope := range validScopes {
t.Run("scope_"+scope, func(t *testing.T) {
params := SearchParams{
Query: "test",
Project: "test",
Scope: scope,
}
assert.Equal(t, scope, params.Scope)
})
}
}
// TestSearchParams_FormatValues tests format parameter values.
func TestSearchParams_FormatValues(t *testing.T) {
validFormats := []string{"index", "full", ""}
for _, format := range validFormats {
t.Run("format_"+format, func(t *testing.T) {
params := SearchParams{
Query: "test",
Project: "test",
Format: format,
}
assert.Equal(t, format, params.Format)
})
}
}
// =============================================================================
// REGRESSION TESTS FOR Fix #3: Cache warming skipped during active queries
// =============================================================================
// TestCacheWarming_SkippedDuringActiveQueries verifies that warmFrequentQueries
// returns immediately without doing work when activeQueries > 0, and actually
// warms when activeQueries == 0.
func TestCacheWarming_SkippedDuringActiveQueries(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
defer m.Close()
// Seed a frequent query so warming has something to consider
params := SearchParams{
Query: "test warming query",
Project: "test-project",
Limit: 10,
}
m.trackQueryFrequency(params)
// Track it multiple times to boost its score
for range 5 {
m.trackQueryFrequency(params)
}
// Case 1: activeQueries > 0 — warming should be skipped (fast return)
m.activeQueries.Store(1)
start := time.Now()
m.warmFrequentQueries()
elapsed := time.Since(start)
// Warming should return almost immediately when skipped
assert.Less(t, elapsed, 50*time.Millisecond,
"warmFrequentQueries should return quickly when user queries are in flight")
// Case 2: activeQueries == 0 — warming should proceed past the guard.
// With nil stores, executeSearch would panic, so we verify the guard was
// bypassed by checking that the frequency map's lastCached is NOT updated
// (warming fails silently on nil stores). The key assertion is that Case 1
// returns immediately (the guard works) while Case 2 enters the body.
m.activeQueries.Store(0)
// Clear frequency data to prevent executeSearch from being called
m.queryFrequencyMu.Lock()
m.queryFrequency = make(map[string]*queryFrequencyInfo)
m.queryFrequencyMu.Unlock()
start2 := time.Now()
m.warmFrequentQueries()
elapsed2 := time.Since(start2)
// With empty frequency map, warming enters the function body (past guard)
// but finds no candidates — fast return without calling executeSearch.
assert.Less(t, elapsed2, 200*time.Millisecond,
"warmFrequentQueries should complete quickly with no candidates")
}
// TestActiveQueries_IncrementDecrement verifies that the activeQueries counter
// is correctly incremented and decremented around search operations.
func TestActiveQueries_IncrementDecrement(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
defer m.Close()
// Before any search, counter should be 0
assert.Equal(t, int32(0), m.activeQueries.Load())
// Directly test the atomic increment/decrement pattern used in UnifiedSearch
// (can't call UnifiedSearch with nil stores without panicking on filterSearch)
m.activeQueries.Add(1)
assert.Equal(t, int32(1), m.activeQueries.Load(), "should be 1 after increment")
m.activeQueries.Add(1)
assert.Equal(t, int32(2), m.activeQueries.Load(), "should be 2 after second increment")
m.activeQueries.Add(-1)
assert.Equal(t, int32(1), m.activeQueries.Load(), "should be 1 after decrement")
m.activeQueries.Add(-1)
assert.Equal(t, int32(0), m.activeQueries.Load(), "should be 0 after final decrement")
// Verify the field is accessible from warmFrequentQueries guard
m.activeQueries.Store(3)
assert.Equal(t, int32(3), m.activeQueries.Load())
m.activeQueries.Store(0)
}
// TestUnifiedSearchResult_MultipleResults tests result with multiple items.
func TestUnifiedSearchResult_MultipleResults(t *testing.T) {
results := []SearchResult{
{Type: "observation", ID: 1, Title: "First", Project: "test"},
{Type: "session", ID: 2, Title: "Second", Project: "test"},
{Type: "prompt", ID: 3, Title: "Third", Project: "test"},
}
result := UnifiedSearchResult{
Results: results,
TotalCount: 3,
Query: "test query",
}
assert.Len(t, result.Results, 3)
assert.Equal(t, 3, result.TotalCount)
assert.Equal(t, "observation", result.Results[0].Type)
assert.Equal(t, "session", result.Results[1].Type)
assert.Equal(t, "prompt", result.Results[2].Type)
}
// TestSearchResult_Metadata tests metadata handling in SearchResult.
func TestSearchResult_Metadata(t *testing.T) {
metadata := map[string]interface{}{
"obs_type": "discovery",
"concepts": []string{"auth", "security"},
"files_count": 5,
"is_important": true,
}
result := SearchResult{
Type: "observation",
ID: 1,
Metadata: metadata,
}
assert.Equal(t, "discovery", result.Metadata["obs_type"])
assert.Equal(t, 5, result.Metadata["files_count"])
assert.Equal(t, true, result.Metadata["is_important"])
}
// TestSearchResult_Scores tests score handling in SearchResult.
func TestSearchResult_Scores(t *testing.T) {
tests := []struct {
name string
score float64
}{
{"perfect_score", 1.0},
{"high_score", 0.95},
{"medium_score", 0.5},
{"low_score", 0.1},
{"zero_score", 0.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SearchResult{
Type: "observation",
ID: 1,
Score: tt.score,
}
assert.Equal(t, tt.score, result.Score)
})
}
}