Increase test coverage to 45.6%

This commit is contained in:
2025-12-19 02:17:28 +00:00
parent 4add030bed
commit c259bb1d18
13 changed files with 5484 additions and 0 deletions
+646
View File
@@ -0,0 +1,646 @@
// Package search provides unified search capabilities for claude-mnemonic.
package search
import (
"context"
"database/sql"
"os"
"testing"
"github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
// Import sqlite driver
_ "github.com/mattn/go-sqlite3"
)
// Ensure context is used (for later tests)
var _ = context.Background
// hasFTS5 checks if FTS5 is available in the SQLite build.
func hasFTS5(t *testing.T) bool {
t.Helper()
tmpDir, err := os.MkdirTemp("", "fts5-check-*")
if err != nil {
return false
}
defer func() { _ = os.RemoveAll(tmpDir) }()
dbPath := tmpDir + "/check.db"
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return false
}
defer func() { _ = db.Close() }()
_, err = db.Exec("CREATE VIRTUAL TABLE IF NOT EXISTS fts5_test USING fts5(content)")
if err != nil {
return false
}
_, _ = db.Exec("DROP TABLE IF EXISTS fts5_test")
return true
}
// testStore creates a sqlite.Store with a temporary database for testing.
func testStore(t *testing.T) (*sqlite.Store, func()) {
t.Helper()
if !hasFTS5(t) {
t.Skip("FTS5 not available in this SQLite build")
}
tmpDir, err := os.MkdirTemp("", "search-integration-test-*")
require.NoError(t, err)
dbPath := tmpDir + "/test.db"
store, err := sqlite.NewStore(sqlite.StoreConfig{
Path: dbPath,
MaxConns: 1,
WALMode: true,
})
require.NoError(t, err)
cleanup := func() {
_ = store.Close()
_ = os.RemoveAll(tmpDir)
}
return store, cleanup
}
// SearchIntegrationSuite tests search with real SQLite stores.
type SearchIntegrationSuite struct {
suite.Suite
store *sqlite.Store
cleanup func()
manager *Manager
obsStore *sqlite.ObservationStore
sumStore *sqlite.SummaryStore
prmStore *sqlite.PromptStore
}
func (s *SearchIntegrationSuite) SetupTest() {
if !hasFTS5(s.T()) {
s.T().Skip("FTS5 not available in this SQLite build")
}
s.store, s.cleanup = testStore(s.T())
// Create real stores backed by SQLite
s.obsStore = sqlite.NewObservationStore(s.store)
s.sumStore = sqlite.NewSummaryStore(s.store)
s.prmStore = sqlite.NewPromptStore(s.store)
// Create search manager with real stores (no vector client for now)
s.manager = NewManager(s.obsStore, s.sumStore, s.prmStore, nil)
}
func (s *SearchIntegrationSuite) TearDownTest() {
if s.cleanup != nil {
s.cleanup()
}
}
func TestSearchIntegrationSuite(t *testing.T) {
suite.Run(t, new(SearchIntegrationSuite))
}
// seedObservations inserts test observations into the database.
func (s *SearchIntegrationSuite) seedObservations(ctx context.Context) []int64 {
var ids []int64
// Observation 1: Authentication bug fix
obs1 := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
Scope: models.ScopeProject,
Title: "Fixed Authentication Bug",
Narrative: "Resolved JWT token validation issue that caused intermittent login failures",
Concepts: []string{"authentication", "jwt", "security"},
FilesRead: []string{"auth/handler.go", "auth/jwt.go"},
}
id1, _, err := s.obsStore.StoreObservation(ctx, "sdk-sess-1", "test-project", obs1, 1, 100)
s.Require().NoError(err)
ids = append(ids, id1)
// Observation 2: Database optimization decision
obs2 := &models.ParsedObservation{
Type: models.ObsTypeDecision,
Scope: models.ScopeProject,
Title: "Database Query Optimization Decision",
Narrative: "Decided to add indexes on user_id and created_at columns for better performance",
Concepts: []string{"database", "performance", "decision"},
FilesRead: []string{"db/migrations/001.sql"},
}
id2, _, err := s.obsStore.StoreObservation(ctx, "sdk-sess-1", "test-project", obs2, 2, 150)
s.Require().NoError(err)
ids = append(ids, id2)
// Observation 3: Global best practice
obs3 := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Scope: models.ScopeGlobal,
Title: "Error Handling Best Practice",
Narrative: "Use wrapped errors with context for better debugging: errors.Wrap(err, context)",
Concepts: []string{"best-practice", "errors", "patterns"},
FilesRead: []string{"pkg/errors/errors.go"},
}
id3, _, err := s.obsStore.StoreObservation(ctx, "sdk-sess-2", "other-project", obs3, 1, 80)
s.Require().NoError(err)
ids = append(ids, id3)
// Observation 4: Code change/refactoring
obs4 := &models.ParsedObservation{
Type: models.ObsTypeChange,
Scope: models.ScopeProject,
Title: "Refactored User Service",
Narrative: "Changed user service to use repository pattern, modified interfaces for better testability",
Concepts: []string{"refactoring", "architecture"},
FilesModified: []string{"services/user.go", "services/user_test.go"},
}
id4, _, err := s.obsStore.StoreObservation(ctx, "sdk-sess-1", "test-project", obs4, 3, 200)
s.Require().NoError(err)
ids = append(ids, id4)
return ids
}
// seedSummaries inserts test session summaries into the database.
func (s *SearchIntegrationSuite) seedSummaries(ctx context.Context) []int64 {
var ids []int64
// Summary 1
sum1 := &models.ParsedSummary{
Request: "Fix authentication bug",
Investigated: "JWT token validation and session handling",
Learned: "JWT validation requires algorithm check to prevent alg:none attacks",
Completed: "Fixed JWT validation, added tests",
NextSteps: "Review other security endpoints",
}
id1, _, err := s.sumStore.StoreSummary(ctx, "sdk-sess-1", "test-project", sum1, 1, 100)
s.Require().NoError(err)
ids = append(ids, id1)
// Summary 2
sum2 := &models.ParsedSummary{
Request: "Optimize database queries",
Investigated: "Query execution plans and index usage",
Learned: "Composite indexes work better for range queries",
Completed: "Added indexes, verified performance improvement",
NextSteps: "Monitor query times in production",
}
id2, _, err := s.sumStore.StoreSummary(ctx, "sdk-sess-1", "test-project", sum2, 2, 150)
s.Require().NoError(err)
ids = append(ids, id2)
return ids
}
// TestFilterSearch_WithRealStores tests filterSearch with seeded data.
func (s *SearchIntegrationSuite) TestFilterSearch_WithRealStores() {
ctx := context.Background()
// Seed test data
obsIDs := s.seedObservations(ctx)
sumIDs := s.seedSummaries(ctx)
s.Require().Len(obsIDs, 4)
s.Require().Len(sumIDs, 2)
// Test filter search for observations only
result, err := s.manager.filterSearch(ctx, SearchParams{
Project: "test-project",
Type: "observations",
Limit: 10,
Format: "full",
})
s.Require().NoError(err)
s.NotNil(result)
// Should return project observations + global observation (4 total: 3 project + 1 global)
s.GreaterOrEqual(len(result.Results), 3)
// Verify result types
for _, r := range result.Results {
s.Equal("observation", r.Type)
}
}
// TestFilterSearch_SessionsOnly tests filterSearch for sessions.
func (s *SearchIntegrationSuite) TestFilterSearch_SessionsOnly() {
ctx := context.Background()
// Seed test data
_ = s.seedObservations(ctx)
sumIDs := s.seedSummaries(ctx)
s.Require().Len(sumIDs, 2)
// Test filter search for sessions only
result, err := s.manager.filterSearch(ctx, SearchParams{
Project: "test-project",
Type: "sessions",
Limit: 10,
Format: "full",
})
s.Require().NoError(err)
s.NotNil(result)
// Should return 2 summaries
s.Len(result.Results, 2)
// Verify result types
for _, r := range result.Results {
s.Equal("session", r.Type)
s.NotEmpty(r.Title) // Title should be populated from Request
}
}
// TestFilterSearch_AllTypes tests filterSearch for all types.
func (s *SearchIntegrationSuite) TestFilterSearch_AllTypes() {
ctx := context.Background()
// Seed test data
obsIDs := s.seedObservations(ctx)
sumIDs := s.seedSummaries(ctx)
s.Require().Len(obsIDs, 4)
s.Require().Len(sumIDs, 2)
// Test filter search for all types (Type = "")
result, err := s.manager.filterSearch(ctx, SearchParams{
Project: "test-project",
Type: "", // All types
Limit: 20,
Format: "full",
})
s.Require().NoError(err)
s.NotNil(result)
// Should return both observations and sessions
hasObservations := false
hasSessions := false
for _, r := range result.Results {
if r.Type == "observation" {
hasObservations = true
}
if r.Type == "session" {
hasSessions = true
}
}
s.True(hasObservations, "Should have observation results")
s.True(hasSessions, "Should have session results")
}
// TestUnifiedSearch_DefaultLimit tests UnifiedSearch with default limit.
func (s *SearchIntegrationSuite) TestUnifiedSearch_DefaultLimit() {
ctx := context.Background()
// Seed test data
s.seedObservations(ctx)
s.seedSummaries(ctx)
// Test with no limit specified (should default to 20)
result, err := s.manager.UnifiedSearch(ctx, SearchParams{
Project: "test-project",
})
s.Require().NoError(err)
s.NotNil(result)
s.LessOrEqual(len(result.Results), 20)
}
// TestUnifiedSearch_LimitCapping tests UnifiedSearch limit capping.
func (s *SearchIntegrationSuite) TestUnifiedSearch_LimitCapping() {
ctx := context.Background()
// Seed test data
s.seedObservations(ctx)
s.seedSummaries(ctx)
// Test with limit > 100 (should be capped to 100)
result, err := s.manager.UnifiedSearch(ctx, SearchParams{
Project: "test-project",
Limit: 500,
})
s.Require().NoError(err)
s.NotNil(result)
s.LessOrEqual(len(result.Results), 100)
}
// TestDecisions_WithRealStores tests the Decisions method falls back to filterSearch.
func (s *SearchIntegrationSuite) TestDecisions_WithRealStores() {
ctx := context.Background()
// Seed test data
s.seedObservations(ctx)
// Test Decisions search (without vector client, falls back to filterSearch)
result, err := s.manager.Decisions(ctx, SearchParams{
Project: "test-project",
Query: "database",
Limit: 10,
})
s.Require().NoError(err)
s.NotNil(result)
// Without vector client, falls back to filterSearch which returns observations
// All results should be observations (type is forced to "observations" in Decisions)
for _, r := range result.Results {
s.Equal("observation", r.Type)
}
}
// TestChanges_WithRealStores tests the Changes method falls back to filterSearch.
func (s *SearchIntegrationSuite) TestChanges_WithRealStores() {
ctx := context.Background()
// Seed test data
s.seedObservations(ctx)
// Test Changes search (without vector client, falls back to filterSearch)
result, err := s.manager.Changes(ctx, SearchParams{
Project: "test-project",
Query: "user service",
Limit: 10,
})
s.Require().NoError(err)
s.NotNil(result)
// Without vector client, falls back to filterSearch which returns observations
// All results should be observations (type is forced to "observations" in Changes)
for _, r := range result.Results {
s.Equal("observation", r.Type)
}
}
// TestHowItWorks_WithRealStores tests the HowItWorks method falls back to filterSearch.
func (s *SearchIntegrationSuite) TestHowItWorks_WithRealStores() {
ctx := context.Background()
// Seed test data
s.seedObservations(ctx)
// Test HowItWorks search (without vector client, falls back to filterSearch)
result, err := s.manager.HowItWorks(ctx, SearchParams{
Project: "test-project",
Query: "authentication",
Limit: 10,
})
s.Require().NoError(err)
s.NotNil(result)
// Without vector client, falls back to filterSearch which returns observations
// All results should be observations (type is forced to "observations" in HowItWorks)
for _, r := range result.Results {
s.Equal("observation", r.Type)
}
}
// TestObservationToResult tests observation to result conversion with full format.
func (s *SearchIntegrationSuite) TestObservationToResult_FullFormat() {
ctx := context.Background()
// Insert single observation
obs := &models.ParsedObservation{
Type: models.ObsTypeDiscovery,
Scope: models.ScopeProject,
Title: "Test Title",
Narrative: "Detailed narrative content for testing",
Concepts: []string{"testing", "content"},
}
id, _, err := s.obsStore.StoreObservation(ctx, "sdk-test", "test-project", obs, 1, 50)
s.Require().NoError(err)
// Retrieve and convert
retrieved, err := s.obsStore.GetObservationByID(ctx, id)
s.Require().NoError(err)
s.Require().NotNil(retrieved)
result := s.manager.observationToResult(retrieved, "full")
s.Equal("observation", result.Type)
s.Equal(id, result.ID)
s.Equal("Test Title", result.Title)
s.Equal("Detailed narrative content for testing", result.Content)
s.Equal("test-project", result.Project)
s.Equal("project", result.Scope)
s.NotNil(result.Metadata)
s.Equal("discovery", result.Metadata["obs_type"])
}
// TestObservationToResult_IndexFormat tests index format (no content).
func (s *SearchIntegrationSuite) TestObservationToResult_IndexFormat() {
ctx := context.Background()
obs := &models.ParsedObservation{
Type: models.ObsTypeBugfix,
Scope: models.ScopeGlobal,
Title: "Bug Fix Title",
Narrative: "This should not appear in index format",
}
id, _, err := s.obsStore.StoreObservation(ctx, "sdk-test", "test-project", obs, 1, 50)
s.Require().NoError(err)
retrieved, err := s.obsStore.GetObservationByID(ctx, id)
s.Require().NoError(err)
result := s.manager.observationToResult(retrieved, "index")
s.Equal("observation", result.Type)
s.Equal("Bug Fix Title", result.Title)
s.Empty(result.Content, "Index format should not include content")
s.Equal("global", result.Scope)
}
// TestSummaryToResult_FullFormat tests summary to result conversion.
func (s *SearchIntegrationSuite) TestSummaryToResult_FullFormat() {
ctx := context.Background()
sum := &models.ParsedSummary{
Request: "Implement new feature",
Learned: "Learned important lessons about testing",
}
id, _, err := s.sumStore.StoreSummary(ctx, "sdk-test", "test-project", sum, 1, 50)
s.Require().NoError(err)
// Retrieve via GetRecentSummaries since there's no GetByID
summaries, err := s.sumStore.GetRecentSummaries(ctx, "test-project", 10)
s.Require().NoError(err)
s.Require().NotEmpty(summaries)
var retrieved *models.SessionSummary
for _, s := range summaries {
if s.ID == id {
retrieved = s
break
}
}
s.Require().NotNil(retrieved)
result := s.manager.summaryToResult(retrieved, "full")
s.Equal("session", result.Type)
s.Equal(id, result.ID)
s.Contains(result.Title, "Implement new feature")
s.Equal("Learned important lessons about testing", result.Content)
s.Equal("test-project", result.Project)
}
// TestPromptToResult_FullFormat tests prompt to result conversion.
func (s *SearchIntegrationSuite) TestPromptToResult_FullFormat() {
// First create a session
ctx := context.Background()
sessionStore := sqlite.NewSessionStore(s.store)
_, err := sessionStore.CreateSDKSession(ctx, "sdk-prompt-test", "test-project", "initial prompt")
s.Require().NoError(err)
// Save a user prompt
promptID, err := s.prmStore.SaveUserPromptWithMatches(ctx, "sdk-prompt-test", 1, "Help me fix this authentication bug", 3)
s.Require().NoError(err)
// Retrieve prompts
prompts, err := s.prmStore.GetPromptsByIDs(ctx, []int64{promptID}, "date_desc", 10)
s.Require().NoError(err)
s.Require().NotEmpty(prompts)
result := s.manager.promptToResult(prompts[0], "full")
s.Equal("prompt", result.Type)
s.Equal(promptID, result.ID)
s.Contains(result.Title, "Help me fix")
s.Equal("Help me fix this authentication bug", result.Content)
}
// TestTruncate_TableDriven tests truncation with various inputs.
func TestTruncate_TableDriven(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
expected string
}{
{"short_string", "hello", 10, "hello"},
{"exact_length", "hello", 5, "hello"},
{"long_string", "hello world", 5, "hello..."},
{"empty_string", "", 10, ""},
{"whitespace_only", " ", 10, ""},
{"with_leading_space", " hello ", 10, "hello"},
{"very_long", "this is a very long string that should be truncated", 20, "this is a very long ..."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := truncate(tt.input, tt.maxLen)
assert.Equal(t, tt.expected, result)
})
}
}
// TestManagerWithNilStores tests that Manager handles nil stores gracefully.
func TestManagerWithNilStores(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
assert.NotNil(t, m)
assert.Nil(t, m.observationStore)
assert.Nil(t, m.summaryStore)
assert.Nil(t, m.promptStore)
assert.Nil(t, m.vectorClient)
}
// TestSearchResultMetadataFields tests all metadata fields with real data.
func (s *SearchIntegrationSuite) TestSearchResultMetadataFields() {
ctx := context.Background()
obs := &models.ParsedObservation{
Type: models.ObsTypeDecision,
Scope: models.ScopeGlobal,
Title: "Architecture Decision",
Concepts: []string{"auth", "security"},
FilesRead: []string{"handler.go", "auth.go"},
}
id, _, err := s.obsStore.StoreObservation(ctx, "sdk-meta-test", "test-project", obs, 1, 50)
s.Require().NoError(err)
retrieved, err := s.obsStore.GetObservationByID(ctx, id)
s.Require().NoError(err)
result := s.manager.observationToResult(retrieved, "full")
// Check metadata fields
s.NotNil(result.Metadata)
s.Equal("decision", result.Metadata["obs_type"])
s.Equal("global", result.Metadata["scope"])
s.Equal("global", result.Scope)
}
// TestObservationToResult_AllFormats tests different format options.
func TestObservationToResult_AllFormats(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
obs := &models.Observation{
ID: 1,
Project: "test",
Type: models.ObsTypeBugfix,
Scope: models.ScopeProject,
Title: sql.NullString{String: "Bug Fix Title", Valid: true},
Narrative: sql.NullString{String: "Detailed bug fix narrative", Valid: true},
CreatedAtEpoch: 1704067200000,
}
tests := []struct {
name string
format string
expectContent bool
}{
{"full_format", "full", true},
{"index_format", "index", false},
{"empty_format", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := m.observationToResult(obs, tt.format)
assert.Equal(t, "observation", result.Type)
assert.Equal(t, int64(1), result.ID)
if tt.expectContent {
assert.NotEmpty(t, result.Content)
}
})
}
}
// TestSummaryToResult_AllFormats tests different format options for summaries.
func TestSummaryToResult_AllFormats(t *testing.T) {
m := NewManager(nil, nil, nil, nil)
summary := &models.SessionSummary{
ID: 1,
Project: "test",
Request: sql.NullString{String: "Test request", Valid: true},
Learned: sql.NullString{String: "Test learned", Valid: true},
Completed: sql.NullString{String: "Test completed", Valid: true},
NextSteps: sql.NullString{String: "Test next steps", Valid: true},
CreatedAtEpoch: 1704067200000,
}
tests := []struct {
name string
format string
expectContent bool
}{
{"full_format", "full", true},
{"index_format", "index", false},
{"empty_format", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := m.summaryToResult(summary, tt.format)
assert.Equal(t, "session", result.Type)
assert.Equal(t, int64(1), result.ID)
if tt.expectContent {
assert.NotEmpty(t, result.Content)
}
})
}
}
+480
View File
@@ -4,6 +4,7 @@ package search
import (
"database/sql"
"testing"
"time"
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
"github.com/stretchr/testify/assert"
@@ -594,3 +595,482 @@ func TestSearchTypeMapping(t *testing.T) {
})
}
}
// 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
initialLimit int
initialOrder string
expectedLimit int
expectedOrder string
}{
{"zero_limit", 0, "", 20, "date_desc"},
{"negative_limit", -5, "", 20, "date_desc"},
{"over_100_limit", 150, "", 100, "date_desc"},
{"valid_limit_50", 50, "relevance", 50, "relevance"},
{"custom_order", 30, "date_asc", 30, "date_asc"},
}
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
maxLen int
expected string
}{
// Unicode strings - uses byte length so ensure maxLen accommodates full string
{"unicode_string_no_truncate", "日本語テスト", 20, "日本語テスト"},
{"mixed_unicode_no_truncate", "Hello世界", 15, "Hello世界"},
// ASCII truncation
{"ascii_truncate", "Hello World", 5, "Hello..."},
{"only_whitespace", " ", 10, ""},
{"tabs_and_newlines", "\t\n \t", 10, ""},
{"newlines_with_content", "\n\nhello\n\n", 10, "hello"},
{"zero_max_len", "hello", 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)
})
}
}
// 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)
})
}
}