package search import ( "context" "errors" "testing" "time" "github.com/metamorphosis-dev/kafka/internal/contracts" "github.com/metamorphosis-dev/kafka/internal/engines" ) // mockEngine is a test engine that returns a predefined response or error. type mockEngine struct { name string resp contracts.SearchResponse err error delay time.Duration } func (m *mockEngine) Name() string { return m.name } func (m *mockEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) { if m.delay > 0 { select { case <-ctx.Done(): return contracts.SearchResponse{}, ctx.Err() case <-time.After(m.delay): } } if m.err != nil { return contracts.SearchResponse{}, m.err } return m.resp, nil } func TestSearch_ConcurrentEngines(t *testing.T) { // Two engines that each take 100ms. If sequential, total would be ~200ms. // Concurrent should be ~100ms. registry := map[string]engines.Engine{ "fast-a": &mockEngine{ name: "fast-a", resp: contracts.SearchResponse{ Query: "test", NumberOfResults: 1, Results: []contracts.MainResult{ {Title: "Result A", Engine: "fast-a"}, }, Answers: []map[string]any{}, Corrections: []string{}, Infoboxes: []map[string]any{}, Suggestions: []string{}, UnresponsiveEngines: [][2]string{}, }, delay: 100 * time.Millisecond, }, "fast-b": &mockEngine{ name: "fast-b", resp: contracts.SearchResponse{ Query: "test", NumberOfResults: 1, Results: []contracts.MainResult{ {Title: "Result B", Engine: "fast-b"}, }, Answers: []map[string]any{}, Corrections: []string{}, Infoboxes: []map[string]any{}, Suggestions: []string{}, UnresponsiveEngines: [][2]string{}, }, delay: 100 * time.Millisecond, }, } svc := &Service{ upstreamClient: nil, planner: engines.NewPlanner([]string{"fast-a", "fast-b"}), localEngines: registry, } req := SearchRequest{ Format: FormatJSON, Query: "test", Engines: []string{"fast-a", "fast-b"}, } start := time.Now() resp, err := svc.Search(context.Background(), req) elapsed := time.Since(start) if err != nil { t.Fatalf("unexpected error: %v", err) } if len(resp.Results) != 2 { t.Errorf("expected 2 results, got %d", len(resp.Results)) } // Should complete in well under 200ms (sequential time). if elapsed > 180*time.Millisecond { t.Errorf("engines not running concurrently: took %v", elapsed) } } func TestSearch_GracefulDegradation(t *testing.T) { // One engine succeeds, one fails. Both should be represented: // the failing engine in unresponsive_engines, the succeeding one in results. registry := map[string]engines.Engine{ "good": &mockEngine{ name: "good", resp: contracts.SearchResponse{ Query: "test", NumberOfResults: 1, Results: []contracts.MainResult{ {Title: "Good Result", Engine: "good"}, }, Answers: []map[string]any{}, Corrections: []string{}, Infoboxes: []map[string]any{}, Suggestions: []string{}, UnresponsiveEngines: [][2]string{}, }, }, "bad": &mockEngine{ name: "bad", err: errors.New("connection refused"), }, } svc := &Service{ upstreamClient: nil, planner: engines.NewPlanner([]string{"good", "bad"}), localEngines: registry, } req := SearchRequest{ Format: FormatJSON, Query: "test", Engines: []string{"good", "bad"}, } resp, err := svc.Search(context.Background(), req) if err != nil { t.Fatalf("search should not fail on individual engine error: %v", err) } // Should still have the good result. if len(resp.Results) != 1 { t.Errorf("expected 1 result from good engine, got %d", len(resp.Results)) } // The bad engine should appear in unresponsive_engines. foundBad := false for _, ue := range resp.UnresponsiveEngines { if ue[0] == "bad" { foundBad = true } } if !foundBad { t.Errorf("expected 'bad' in unresponsive_engines, got %v", resp.UnresponsiveEngines) } } func TestSearch_AllEnginesFail(t *testing.T) { registry := map[string]engines.Engine{ "failing": &mockEngine{ name: "failing", err: errors.New("timeout"), }, } svc := &Service{ upstreamClient: nil, planner: engines.NewPlanner([]string{"failing"}), localEngines: registry, } req := SearchRequest{ Format: FormatJSON, Query: "test", Engines: []string{"failing"}, } resp, err := svc.Search(context.Background(), req) if err != nil { t.Fatalf("should not return error even when all engines fail: %v", err) } if len(resp.Results) != 0 { t.Errorf("expected 0 results, got %d", len(resp.Results)) } if len(resp.UnresponsiveEngines) != 1 { t.Errorf("expected 1 unresponsive engine, got %d", len(resp.UnresponsiveEngines)) } } func TestSearch_ContextCancellation(t *testing.T) { slowEngine := &mockEngine{ name: "slow", delay: 5 * time.Second, resp: contracts.SearchResponse{ Query: "test", Results: []contracts.MainResult{}, Answers: []map[string]any{}, Corrections: []string{}, Infoboxes: []map[string]any{}, Suggestions: []string{}, UnresponsiveEngines: [][2]string{}, }, } svc := &Service{ upstreamClient: nil, planner: engines.NewPlanner([]string{"slow"}), localEngines: map[string]engines.Engine{ "slow": slowEngine, }, } ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() req := SearchRequest{ Format: FormatJSON, Query: "test", Engines: []string{"slow"}, } start := time.Now() resp, err := svc.Search(ctx, req) elapsed := time.Since(start) // Should not error — graceful degradation handles context cancellation. if err != nil { t.Fatalf("should not error on context cancel: %v", err) } if len(resp.Results) != 0 { t.Errorf("expected 0 results from cancelled engine, got %d", len(resp.Results)) } // Should complete quickly, not wait for the 5s delay. if elapsed > 200*time.Millisecond { t.Errorf("context cancellation not respected: took %v", elapsed) } // Engine should be marked unresponsive. if len(resp.UnresponsiveEngines) != 1 { t.Errorf("expected 1 unresponsive engine, got %d", len(resp.UnresponsiveEngines)) } }