Module path now matches the GitHub mirror location. All internal imports updated across 35+ files.
248 lines
6.3 KiB
Go
248 lines
6.3 KiB
Go
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))
|
|
}
|
|
}
|