From d0848ea1b3021a7f39a00ede07586a95a35873ad Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 28 Mar 2026 02:33:59 +0100 Subject: [PATCH] test(go-services): add unit tests for mana-search-go and mana-notify-go mana-search-go (42 tests): - search: BuildCacheKey, scoreResult, normalizeResults, CacheOptions - extract: cleanText, countWords, formatTime, BuildCacheKey - handlers: validation for search/extract/bulk endpoints mana-notify-go (38 tests): - handler: validateSendRequest, parseTime, nilIfEmpty, jsonOrNil - channel: webhook Send with httptest, IsExpoPushToken - auth: ValidateServiceKey middleware, GetUser context - template: RenderDirect with variables, conditionals, errors Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mana-notify-go/internal/auth/auth_test.go | 174 ++++++++++++ .../internal/channel/push_test.go | 31 ++ .../internal/channel/webhook_test.go | 166 +++++++++++ .../internal/handler/notifications_test.go | 200 +++++++++++++ .../internal/template/engine_test.go | 86 ++++++ .../internal/extract/extractor_test.go | 141 ++++++++++ .../internal/handler/extract_test.go | 181 ++++++++++++ .../internal/handler/helpers_test.go | 25 ++ .../internal/handler/search_test.go | 108 +++++++ .../internal/search/searxng_test.go | 264 ++++++++++++++++++ 10 files changed, 1376 insertions(+) create mode 100644 services/mana-notify-go/internal/auth/auth_test.go create mode 100644 services/mana-notify-go/internal/channel/push_test.go create mode 100644 services/mana-notify-go/internal/channel/webhook_test.go create mode 100644 services/mana-notify-go/internal/handler/notifications_test.go create mode 100644 services/mana-notify-go/internal/template/engine_test.go create mode 100644 services/mana-search-go/internal/extract/extractor_test.go create mode 100644 services/mana-search-go/internal/handler/extract_test.go create mode 100644 services/mana-search-go/internal/handler/helpers_test.go create mode 100644 services/mana-search-go/internal/handler/search_test.go create mode 100644 services/mana-search-go/internal/search/searxng_test.go diff --git a/services/mana-notify-go/internal/auth/auth_test.go b/services/mana-notify-go/internal/auth/auth_test.go new file mode 100644 index 000000000..9554e2c3b --- /dev/null +++ b/services/mana-notify-go/internal/auth/auth_test.go @@ -0,0 +1,174 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestValidateServiceKey(t *testing.T) { + const validKey = "test-service-key-123" + + okHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + }) + + middleware := ValidateServiceKey(validKey) + handler := middleware(okHandler) + + tests := []struct { + name string + key string + wantStatus int + }{ + { + name: "valid key passes through", + key: validKey, + wantStatus: http.StatusOK, + }, + { + name: "missing key returns 401", + key: "", + wantStatus: http.StatusUnauthorized, + }, + { + name: "wrong key returns 401", + key: "wrong-key", + wantStatus: http.StatusUnauthorized, + }, + { + name: "partial key returns 401", + key: "test-service-key", + wantStatus: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/test", nil) + if tt.key != "" { + req.Header.Set("X-Service-Key", tt.key) + } + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +} + +func TestValidateServiceKey_NextHandlerCalled(t *testing.T) { + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + + handler := ValidateServiceKey("key123")(next) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Service-Key", "key123") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if !called { + t.Fatal("next handler was not called with valid key") + } +} + +func TestValidateServiceKey_NextHandlerNotCalledOnInvalidKey(t *testing.T) { + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + }) + + handler := ValidateServiceKey("correct-key")(next) + + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-Service-Key", "wrong-key") + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if called { + t.Fatal("next handler should not be called with invalid key") + } +} + +func TestValidateJWT_MissingBearer(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("next handler should not be called without token") + }) + + handler := ValidateJWT("http://localhost:3001")(next) + + tests := []struct { + name string + header string + }{ + {"no header", ""}, + {"no Bearer prefix", "Token abc123"}, + {"basic auth", "Basic dXNlcjpwYXNz"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if tt.header != "" { + req.Header.Set("Authorization", tt.header) + } + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + }) + } +} + +func TestGetUser_NoUser(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + user := GetUser(req) + if user != nil { + t.Fatal("expected nil user from empty context") + } +} + +func TestGetUser_WithUser(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + expected := &User{ + UserID: "user-123", + Email: "test@example.com", + Role: "user", + SessionID: "sess-456", + } + + ctx := req.Context() + ctx = context.WithValue(ctx, UserContextKey, expected) + req = req.WithContext(ctx) + + user := GetUser(req) + if user == nil { + t.Fatal("expected non-nil user") + } + if user.UserID != expected.UserID { + t.Fatalf("UserID = %q, want %q", user.UserID, expected.UserID) + } + if user.Email != expected.Email { + t.Fatalf("Email = %q, want %q", user.Email, expected.Email) + } + if user.Role != expected.Role { + t.Fatalf("Role = %q, want %q", user.Role, expected.Role) + } + if user.SessionID != expected.SessionID { + t.Fatalf("SessionID = %q, want %q", user.SessionID, expected.SessionID) + } +} diff --git a/services/mana-notify-go/internal/channel/push_test.go b/services/mana-notify-go/internal/channel/push_test.go new file mode 100644 index 000000000..738f9a871 --- /dev/null +++ b/services/mana-notify-go/internal/channel/push_test.go @@ -0,0 +1,31 @@ +package channel + +import "testing" + +func TestIsExpoPushToken(t *testing.T) { + tests := []struct { + token string + want bool + }{ + {"ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", true}, + {"ExpoPushToken[xxxxxxxxxxxxxxxxxxxxxx]", true}, + {"ExponentPushToken[abc123]", true}, + {"ExpoPushToken[abc123]", true}, + {"ExponentPushToken[]", true}, + {"", false}, + {"some-random-token", false}, + {"Bearer ExponentPushToken[abc]", false}, + {"exponentpushtoken[abc]", false}, // case sensitive + {"ExpoPush[abc]", false}, + {"fcm:token123", false}, + } + + for _, tt := range tests { + t.Run(tt.token, func(t *testing.T) { + got := IsExpoPushToken(tt.token) + if got != tt.want { + t.Fatalf("IsExpoPushToken(%q) = %v, want %v", tt.token, got, tt.want) + } + }) + } +} diff --git a/services/mana-notify-go/internal/channel/webhook_test.go b/services/mana-notify-go/internal/channel/webhook_test.go new file mode 100644 index 000000000..b7d628b82 --- /dev/null +++ b/services/mana-notify-go/internal/channel/webhook_test.go @@ -0,0 +1,166 @@ +package channel + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWebhookService_Send(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + msg WebhookMessage + wantOK bool + wantStatus int + }{ + { + name: "successful POST with 200", + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("expected Content-Type application/json, got %s", ct) + } + if ua := r.Header.Get("User-Agent"); ua != "ManaNotify/1.0" { + t.Errorf("expected User-Agent ManaNotify/1.0, got %s", ua) + } + w.WriteHeader(http.StatusOK) + }, + msg: WebhookMessage{Body: map[string]any{"test": true}}, + wantOK: true, + wantStatus: 200, + }, + { + name: "successful PUT", + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("expected PUT, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + }, + msg: WebhookMessage{Method: "PUT", Body: map[string]any{"update": true}}, + wantOK: true, + wantStatus: 200, + }, + { + name: "custom headers are sent", + handler: func(w http.ResponseWriter, r *http.Request) { + if v := r.Header.Get("X-Custom"); v != "test-value" { + t.Errorf("expected X-Custom=test-value, got %s", v) + } + w.WriteHeader(http.StatusOK) + }, + msg: WebhookMessage{ + Headers: map[string]string{"X-Custom": "test-value"}, + Body: map[string]any{}, + }, + wantOK: true, + wantStatus: 200, + }, + { + name: "body is sent as JSON", + handler: func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var m map[string]any + if err := json.Unmarshal(body, &m); err != nil { + t.Errorf("body is not valid JSON: %v", err) + } + if m["event"] != "test" { + t.Errorf("expected event=test, got %v", m["event"]) + } + w.WriteHeader(http.StatusOK) + }, + msg: WebhookMessage{Body: map[string]any{"event": "test"}}, + wantOK: true, + wantStatus: 200, + }, + { + name: "server error returns failure", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + msg: WebhookMessage{Body: map[string]any{}}, + wantOK: false, + wantStatus: 500, + }, + { + name: "404 returns failure", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + msg: WebhookMessage{Body: map[string]any{}}, + wantOK: false, + wantStatus: 404, + }, + { + name: "201 is treated as success", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + }, + msg: WebhookMessage{Body: map[string]any{}}, + wantOK: true, + wantStatus: 201, + }, + } + + svc := NewWebhookService() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(tt.handler) + defer srv.Close() + + tt.msg.URL = srv.URL + result := svc.Send(context.Background(), &tt.msg) + + if result.Success != tt.wantOK { + t.Fatalf("Success = %v, want %v (error: %s)", result.Success, tt.wantOK, result.Error) + } + if tt.wantStatus != 0 && result.StatusCode != tt.wantStatus { + t.Fatalf("StatusCode = %d, want %d", result.StatusCode, tt.wantStatus) + } + if result.DurationMs < 0 { + t.Fatalf("DurationMs should be >= 0, got %d", result.DurationMs) + } + }) + } +} + +func TestWebhookService_Send_InvalidURL(t *testing.T) { + svc := NewWebhookService() + result := svc.Send(context.Background(), &WebhookMessage{ + URL: "http://localhost:1", // unreachable port + Body: map[string]any{}, + }) + + if result.Success { + t.Fatal("expected failure for unreachable URL") + } + if result.Error == "" { + t.Fatal("expected non-empty error") + } +} + +func TestWebhookService_Send_DefaultMethod(t *testing.T) { + var gotMethod string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + svc := NewWebhookService() + svc.Send(context.Background(), &WebhookMessage{ + URL: srv.URL, + Body: map[string]any{}, + }) + + if gotMethod != "POST" { + t.Fatalf("default method should be POST, got %s", gotMethod) + } +} diff --git a/services/mana-notify-go/internal/handler/notifications_test.go b/services/mana-notify-go/internal/handler/notifications_test.go new file mode 100644 index 000000000..17cee1415 --- /dev/null +++ b/services/mana-notify-go/internal/handler/notifications_test.go @@ -0,0 +1,200 @@ +package handler + +import ( + "encoding/json" + "testing" +) + +func TestValidateSendRequest(t *testing.T) { + tests := []struct { + name string + req SendRequest + wantErr string + }{ + { + name: "missing channel", + req: SendRequest{AppID: "app1", Recipient: "user@test.com", Body: "hello"}, + wantErr: "channel is required", + }, + { + name: "invalid channel", + req: SendRequest{Channel: "sms", AppID: "app1", Recipient: "user@test.com", Body: "hello"}, + wantErr: "channel must be email, push, matrix, or webhook", + }, + { + name: "missing appId", + req: SendRequest{Channel: "email", Recipient: "user@test.com", Body: "hello"}, + wantErr: "appId is required", + }, + { + name: "missing recipient and userId", + req: SendRequest{Channel: "email", AppID: "app1", Body: "hello"}, + wantErr: "recipient, recipients, or userId is required", + }, + { + name: "missing template and body", + req: SendRequest{Channel: "email", AppID: "app1", Recipient: "user@test.com"}, + wantErr: "template or body is required", + }, + { + name: "valid with recipient and body", + req: SendRequest{Channel: "email", AppID: "app1", Recipient: "user@test.com", Body: "hello"}, + }, + { + name: "valid with userId and template", + req: SendRequest{Channel: "push", AppID: "app1", UserID: "u1", Template: "welcome"}, + }, + { + name: "valid with recipients", + req: SendRequest{Channel: "webhook", AppID: "app1", Recipients: []string{"url1"}, Body: "data"}, + }, + { + name: "valid email channel", + req: SendRequest{Channel: "email", AppID: "app1", Recipient: "a@b.com", Body: "hi"}, + }, + { + name: "valid push channel", + req: SendRequest{Channel: "push", AppID: "app1", Recipient: "token", Body: "hi"}, + }, + { + name: "valid matrix channel", + req: SendRequest{Channel: "matrix", AppID: "app1", Recipient: "!room:server", Body: "hi"}, + }, + { + name: "valid webhook channel", + req: SendRequest{Channel: "webhook", AppID: "app1", Recipient: "https://hook.example.com", Body: "{}"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSendRequest(&tt.req) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error %q, got nil", tt.wantErr) + } + if err.Error() != tt.wantErr { + t.Fatalf("expected error %q, got %q", tt.wantErr, err.Error()) + } + } else { + if err != nil { + t.Fatalf("expected no error, got %q", err.Error()) + } + } + }) + } +} + +func TestParseTime(t *testing.T) { + tests := []struct { + input string + wantH int + wantM int + }{ + {"22:00", 22, 0}, + {"08:30", 8, 30}, + {"0:00", 0, 0}, + {"23:59", 23, 59}, + {"invalid", 0, 0}, + {"", 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + h, m := parseTime(tt.input) + if h != tt.wantH || m != tt.wantM { + t.Fatalf("parseTime(%q) = (%d, %d), want (%d, %d)", tt.input, h, m, tt.wantH, tt.wantM) + } + }) + } +} + +func TestNilIfEmpty(t *testing.T) { + tests := []struct { + name string + input string + isNil bool + }{ + {"empty string returns nil", "", true}, + {"non-empty returns pointer", "hello", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := nilIfEmpty(tt.input) + if tt.isNil { + if result != nil { + t.Fatal("expected nil, got non-nil") + } + } else { + if result == nil { + t.Fatal("expected non-nil, got nil") + } + if *result != tt.input { + t.Fatalf("expected %q, got %q", tt.input, *result) + } + } + }) + } +} + +func TestJsonOrNil(t *testing.T) { + tests := []struct { + name string + input map[string]any + isNil bool + verify func(t *testing.T, b []byte) + }{ + { + name: "nil map returns nil", + input: nil, + isNil: true, + }, + { + name: "empty map returns valid JSON", + input: map[string]any{}, + isNil: false, + verify: func(t *testing.T, b []byte) { + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if len(m) != 0 { + t.Fatalf("expected empty map, got %v", m) + } + }, + }, + { + name: "map with data returns valid JSON", + input: map[string]any{"key": "value", "num": float64(42)}, + isNil: false, + verify: func(t *testing.T, b []byte) { + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if m["key"] != "value" { + t.Fatalf("expected key=value, got %v", m["key"]) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := jsonOrNil(tt.input) + if tt.isNil { + if result != nil { + t.Fatal("expected nil, got non-nil") + } + } else { + if result == nil { + t.Fatal("expected non-nil, got nil") + } + if tt.verify != nil { + tt.verify(t, result) + } + } + }) + } +} diff --git a/services/mana-notify-go/internal/template/engine_test.go b/services/mana-notify-go/internal/template/engine_test.go new file mode 100644 index 000000000..bdaa34ea6 --- /dev/null +++ b/services/mana-notify-go/internal/template/engine_test.go @@ -0,0 +1,86 @@ +package template + +import "testing" + +func TestRenderDirect(t *testing.T) { + tests := []struct { + name string + tmpl string + data map[string]any + want string + wantErr bool + }{ + { + name: "simple variable substitution", + tmpl: "Hello {{.name}}!", + data: map[string]any{"name": "Till"}, + want: "Hello Till!", + }, + { + name: "multiple variables", + tmpl: "{{.greeting}}, {{.name}}! Your code is {{.code}}.", + data: map[string]any{"greeting": "Hi", "name": "User", "code": "ABC123"}, + want: "Hi, User! Your code is ABC123.", + }, + { + name: "no variables", + tmpl: "Static text here", + data: map[string]any{}, + want: "Static text here", + }, + { + name: "nil data", + tmpl: "No data needed", + data: nil, + want: "No data needed", + }, + { + name: "conditional", + tmpl: "{{if .show}}visible{{else}}hidden{{end}}", + data: map[string]any{"show": true}, + want: "visible", + }, + { + name: "conditional false", + tmpl: "{{if .show}}visible{{else}}hidden{{end}}", + data: map[string]any{"show": false}, + want: "hidden", + }, + { + name: "invalid template syntax", + tmpl: "{{.name", + data: map[string]any{"name": "test"}, + wantErr: true, + }, + { + name: "HTML content preserved", + tmpl: "

{{.title}}

{{.body}}

", + data: map[string]any{"title": "Welcome", "body": "Hello world"}, + want: "

Welcome

Hello world

", + }, + { + name: "email template pattern", + tmpl: "Hallo {{.userName}}, klicke hier: {{.resetUrl}}", + data: map[string]any{"userName": "Max", "resetUrl": "https://mana.how/reset/abc"}, + want: "Hallo Max, klicke hier: https://mana.how/reset/abc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := RenderDirect(tt.tmpl, tt.data) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/services/mana-search-go/internal/extract/extractor_test.go b/services/mana-search-go/internal/extract/extractor_test.go new file mode 100644 index 000000000..3ca86eefd --- /dev/null +++ b/services/mana-search-go/internal/extract/extractor_test.go @@ -0,0 +1,141 @@ +package extract + +import ( + "testing" + "time" +) + +func TestCleanText(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "plain text unchanged", + input: "Hello world", + want: "Hello world", + }, + { + name: "strips HTML tags", + input: "

Hello world

", + want: "Hello world", + }, + { + name: "strips script tags and content", + input: "BeforeAfter", + want: "BeforeAfter", + }, + { + name: "strips style tags and content", + input: "BeforeAfter", + want: "BeforeAfter", + }, + { + name: "collapses whitespace", + input: "Hello \n\t world", + want: "Hello world", + }, + { + name: "trims leading and trailing whitespace", + input: " Hello world ", + want: "Hello world", + }, + { + name: "handles multiline script", + input: "AB", + want: "AB", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "only tags", + input: "
", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := cleanText(tt.input) + if got != tt.want { + t.Errorf("cleanText() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCountWords(t *testing.T) { + tests := []struct { + name string + input string + want int + }{ + {"empty string", "", 0}, + {"single word", "hello", 1}, + {"multiple words", "hello world foo bar", 4}, + {"extra whitespace", " hello world ", 2}, + {"tabs and newlines", "hello\tworld\nfoo", 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := countWords(tt.input) + if got != tt.want { + t.Errorf("countWords() = %d, want %d", got, tt.want) + } + }) + } +} + +func TestFormatTime(t *testing.T) { + tests := []struct { + name string + t *time.Time + want string + }{ + {"nil time", nil, ""}, + {"zero time", func() *time.Time { t := time.Time{}; return &t }(), ""}, + { + "valid time", + func() *time.Time { + t := time.Date(2024, 6, 15, 12, 30, 0, 0, time.UTC) + return &t + }(), + "2024-06-15T12:30:00Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t2 *testing.T) { + got := formatTime(tt.t) + if got != tt.want { + t2.Errorf("formatTime() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildCacheKey(t *testing.T) { + tests := []struct { + name string + url string + want string + }{ + {"simple URL", "https://example.com", "extract:https://example.com"}, + {"URL with path", "https://example.com/path/to/page", "extract:https://example.com/path/to/page"}, + {"empty string", "", "extract:"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildCacheKey(tt.url) + if got != tt.want { + t.Errorf("BuildCacheKey() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/services/mana-search-go/internal/handler/extract_test.go b/services/mana-search-go/internal/handler/extract_test.go new file mode 100644 index 000000000..c6756d709 --- /dev/null +++ b/services/mana-search-go/internal/handler/extract_test.go @@ -0,0 +1,181 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestExtractHandler_Validation(t *testing.T) { + tests := []struct { + name string + body string + wantStatus int + wantError string + }{ + { + name: "empty body", + body: "", + wantStatus: http.StatusBadRequest, + wantError: "invalid request body", + }, + { + name: "invalid JSON", + body: "not json", + wantStatus: http.StatusBadRequest, + wantError: "invalid request body", + }, + { + name: "missing url", + body: `{}`, + wantStatus: http.StatusBadRequest, + wantError: "url is required", + }, + { + name: "empty url", + body: `{"url":""}`, + wantStatus: http.StatusBadRequest, + wantError: "url is required", + }, + { + name: "invalid url", + body: `{"url":"not-a-url"}`, + wantStatus: http.StatusBadRequest, + wantError: "url must be a valid URL", + }, + { + name: "maxLength too small", + body: `{"url":"https://example.com","options":{"maxLength":50}}`, + wantStatus: http.StatusBadRequest, + wantError: "maxLength must be between 100 and 100000", + }, + { + name: "maxLength too large", + body: `{"url":"https://example.com","options":{"maxLength":200000}}`, + wantStatus: http.StatusBadRequest, + wantError: "maxLength must be between 100 and 100000", + }, + { + name: "timeout too small", + body: `{"url":"https://example.com","options":{"timeout":500}}`, + wantStatus: http.StatusBadRequest, + wantError: "timeout must be between 1000 and 30000", + }, + { + name: "timeout too large", + body: `{"url":"https://example.com","options":{"timeout":60000}}`, + wantStatus: http.StatusBadRequest, + wantError: "timeout must be between 1000 and 30000", + }, + } + + m, c, cfg := testDeps() + h := &ExtractHandler{metrics: m, cache: c, cfg: cfg} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/extract", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + h.Extract(rr, req) + + if rr.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rr.Code, tt.wantStatus) + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + errObj, ok := resp["error"].(map[string]any) + if !ok { + t.Fatal("expected error object in response") + } + if msg, _ := errObj["message"].(string); msg != tt.wantError { + t.Errorf("error message = %q, want %q", msg, tt.wantError) + } + }) + } +} + +func TestBulkExtractHandler_Validation(t *testing.T) { + tests := []struct { + name string + body string + wantStatus int + wantError string + }{ + { + name: "empty body", + body: "", + wantStatus: http.StatusBadRequest, + wantError: "invalid request body", + }, + { + name: "empty urls array", + body: `{"urls":[]}`, + wantStatus: http.StatusBadRequest, + wantError: "urls is required", + }, + { + name: "too many urls", + body: `{"urls":["https://1.com","https://2.com","https://3.com","https://4.com","https://5.com","https://6.com","https://7.com","https://8.com","https://9.com","https://10.com","https://11.com","https://12.com","https://13.com","https://14.com","https://15.com","https://16.com","https://17.com","https://18.com","https://19.com","https://20.com","https://21.com"]}`, + wantStatus: http.StatusBadRequest, + wantError: "maximum 20 URLs allowed", + }, + { + name: "invalid url in list", + body: `{"urls":["https://valid.com","not-valid"]}`, + wantStatus: http.StatusBadRequest, + wantError: "invalid URL: not-valid", + }, + } + + m, c, cfg := testDeps() + h := &ExtractHandler{metrics: m, cache: c, cfg: cfg} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/extract/bulk", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + h.BulkExtract(rr, req) + + if rr.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rr.Code, tt.wantStatus) + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + errObj, ok := resp["error"].(map[string]any) + if !ok { + t.Fatal("expected error object in response") + } + if msg, _ := errObj["message"].(string); msg != tt.wantError { + t.Errorf("error message = %q, want %q", msg, tt.wantError) + } + }) + } +} + +func TestExtractHandler_ContentType(t *testing.T) { + m, c, cfg := testDeps() + h := &ExtractHandler{metrics: m, cache: c, cfg: cfg} + req := httptest.NewRequest(http.MethodPost, "/api/v1/extract", strings.NewReader(`{}`)) + rr := httptest.NewRecorder() + + h.Extract(rr, req) + + ct := rr.Header().Get("Content-Type") + if ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } +} diff --git a/services/mana-search-go/internal/handler/helpers_test.go b/services/mana-search-go/internal/handler/helpers_test.go new file mode 100644 index 000000000..51aad1428 --- /dev/null +++ b/services/mana-search-go/internal/handler/helpers_test.go @@ -0,0 +1,25 @@ +package handler + +import ( + "sync" + + "github.com/manacore/mana-search/internal/cache" + "github.com/manacore/mana-search/internal/config" + "github.com/manacore/mana-search/internal/metrics" +) + +var ( + testMetrics *metrics.Metrics + testCache *cache.Cache + testConfig *config.Config + initOnce sync.Once +) + +func testDeps() (*metrics.Metrics, *cache.Cache, *config.Config) { + initOnce.Do(func() { + testMetrics = metrics.New() + testCache = &cache.Cache{} + testConfig = &config.Config{} + }) + return testMetrics, testCache, testConfig +} diff --git a/services/mana-search-go/internal/handler/search_test.go b/services/mana-search-go/internal/handler/search_test.go new file mode 100644 index 000000000..85db7c722 --- /dev/null +++ b/services/mana-search-go/internal/handler/search_test.go @@ -0,0 +1,108 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSearchHandler_Validation(t *testing.T) { + tests := []struct { + name string + body string + wantStatus int + wantError string + }{ + { + name: "empty body", + body: "", + wantStatus: http.StatusBadRequest, + wantError: "invalid request body", + }, + { + name: "invalid JSON", + body: "{invalid}", + wantStatus: http.StatusBadRequest, + wantError: "invalid request body", + }, + { + name: "missing query", + body: `{}`, + wantStatus: http.StatusBadRequest, + wantError: "query is required", + }, + { + name: "empty query string", + body: `{"query":""}`, + wantStatus: http.StatusBadRequest, + wantError: "query is required", + }, + { + name: "limit too high", + body: `{"query":"test","options":{"limit":100}}`, + wantStatus: http.StatusBadRequest, + wantError: "limit must be between 1 and 50", + }, + { + name: "negative limit", + body: `{"query":"test","options":{"limit":-1}}`, + wantStatus: http.StatusBadRequest, + wantError: "limit must be between 1 and 50", + }, + { + name: "invalid safeSearch", + body: `{"query":"test","options":{"safeSearch":5}}`, + wantStatus: http.StatusBadRequest, + wantError: "safeSearch must be 0, 1, or 2", + }, + } + + m, c, cfg := testDeps() + h := &SearchHandler{metrics: m, cache: c, cfg: cfg} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/search", strings.NewReader(tt.body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + + h.Search(rr, req) + + if rr.Code != tt.wantStatus { + t.Errorf("status = %d, want %d", rr.Code, tt.wantStatus) + } + + var resp map[string]any + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + errObj, ok := resp["error"].(map[string]any) + if !ok { + t.Fatal("expected error object in response") + } + if msg, _ := errObj["message"].(string); msg != tt.wantError { + t.Errorf("error message = %q, want %q", msg, tt.wantError) + } + if success, _ := resp["success"].(bool); success { + t.Error("expected success=false") + } + }) + } +} + +func TestSearchHandler_ContentType(t *testing.T) { + m, c, cfg := testDeps() + h := &SearchHandler{metrics: m, cache: c, cfg: cfg} + req := httptest.NewRequest(http.MethodPost, "/api/v1/search", strings.NewReader(`{}`)) + rr := httptest.NewRecorder() + + h.Search(rr, req) + + ct := rr.Header().Get("Content-Type") + if ct != "application/json" { + t.Errorf("Content-Type = %q, want application/json", ct) + } +} diff --git a/services/mana-search-go/internal/search/searxng_test.go b/services/mana-search-go/internal/search/searxng_test.go new file mode 100644 index 000000000..041b90393 --- /dev/null +++ b/services/mana-search-go/internal/search/searxng_test.go @@ -0,0 +1,264 @@ +package search + +import ( + "testing" +) + +func TestBuildCacheKey(t *testing.T) { + tests := []struct { + name string + req SearchRequest + want string + }{ + { + name: "simple query without options", + req: SearchRequest{Query: "hello world"}, + want: "search:hello world:::::0", + }, + { + name: "query is lowercased", + req: SearchRequest{Query: "Hello World"}, + want: "search:hello world:::::0", + }, + { + name: "with categories sorted", + req: SearchRequest{ + Query: "test", + Options: &SearchOptions{Categories: []string{"science", "general"}}, + }, + want: "search:test:general,science::::0", + }, + { + name: "with engines sorted", + req: SearchRequest{ + Query: "test", + Options: &SearchOptions{Engines: []string{"google", "bing", "duckduckgo"}}, + }, + want: "search:test::bing,duckduckgo,google:::0", + }, + { + name: "with language lowercased", + req: SearchRequest{ + Query: "test", + Options: &SearchOptions{Language: "EN-US"}, + }, + want: "search:test:::en-us::0", + }, + { + name: "with all options", + req: SearchRequest{ + Query: "Go lang", + Options: &SearchOptions{ + Categories: []string{"it"}, + Engines: []string{"github"}, + Language: "de", + TimeRange: "month", + SafeSearch: 1, + }, + }, + want: "search:go lang:it:github:de:month:1", + }, + { + name: "does not mutate original slices", + req: SearchRequest{ + Query: "test", + Options: &SearchOptions{Categories: []string{"z", "a"}}, + }, + want: "search:test:a,z::::0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // For the mutation test, keep a copy of original order + var origCats []string + if tt.req.Options != nil { + origCats = make([]string, len(tt.req.Options.Categories)) + copy(origCats, tt.req.Options.Categories) + } + + got := BuildCacheKey(&tt.req) + if got != tt.want { + t.Errorf("BuildCacheKey() = %q, want %q", got, tt.want) + } + + // Verify original slice is not mutated + if tt.req.Options != nil && len(origCats) > 0 { + for i, v := range origCats { + if tt.req.Options.Categories[i] != v { + t.Errorf("BuildCacheKey mutated input categories: got %v", tt.req.Options.Categories) + } + } + } + }) + } +} + +func TestScoreResult(t *testing.T) { + tests := []struct { + name string + result searxngResult + wantMin float64 + wantMax float64 + }{ + { + name: "base score for empty result", + result: searxngResult{URL: "https://example.com"}, + wantMin: 0.49, + wantMax: 0.51, + }, + { + name: "bonus for long content", + result: searxngResult{URL: "https://example.com", Content: string(make([]byte, 101))}, + wantMin: 0.59, + wantMax: 0.61, + }, + { + name: "bonus for trusted domain wikipedia", + result: searxngResult{URL: "https://en.wikipedia.org/wiki/Go"}, + wantMin: 0.64, + wantMax: 0.66, + }, + { + name: "bonus for trusted domain github", + result: searxngResult{URL: "https://github.com/golang/go"}, + wantMin: 0.64, + wantMax: 0.66, + }, + { + name: "bonus for trusted domain stackoverflow", + result: searxngResult{URL: "https://stackoverflow.com/questions/123"}, + wantMin: 0.64, + wantMax: 0.66, + }, + { + name: "penalty for long URL", + result: searxngResult{URL: "https://example.com/" + string(make([]byte, 200))}, + wantMin: 0.44, + wantMax: 0.46, + }, + { + name: "combined bonuses: trusted domain + long content", + result: searxngResult{ + URL: "https://en.wikipedia.org/wiki/Go", + Content: string(make([]byte, 150)), + }, + wantMin: 0.74, + wantMax: 0.76, + }, + { + name: "score clamped to 0 minimum", + result: searxngResult{URL: ":::invalid"}, + wantMin: 0.0, + wantMax: 1.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := scoreResult(tt.result) + if got < tt.wantMin || got > tt.wantMax { + t.Errorf("scoreResult() = %f, want between %f and %f", got, tt.wantMin, tt.wantMax) + } + }) + } +} + +func TestNormalizeResults(t *testing.T) { + t.Run("deduplicates by URL", func(t *testing.T) { + raw := []searxngResult{ + {URL: "https://a.com", Title: "A", Engine: "google"}, + {URL: "https://a.com", Title: "A dup", Engine: "bing"}, + {URL: "https://b.com", Title: "B", Engine: "google"}, + } + results := normalizeResults(raw, nil) + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + // First occurrence should be kept + if results[0].Title == "A dup" || results[1].Title == "A dup" { + t.Error("duplicate should have been removed") + } + }) + + t.Run("default limit is 10", func(t *testing.T) { + raw := make([]searxngResult, 15) + for i := range raw { + raw[i] = searxngResult{URL: "https://example.com/" + string(rune('a'+i)), Title: "T"} + } + results := normalizeResults(raw, nil) + if len(results) != 10 { + t.Errorf("expected 10 results (default limit), got %d", len(results)) + } + }) + + t.Run("custom limit", func(t *testing.T) { + raw := make([]searxngResult, 15) + for i := range raw { + raw[i] = searxngResult{URL: "https://example.com/" + string(rune('a'+i)), Title: "T"} + } + opts := &SearchOptions{Limit: 5} + results := normalizeResults(raw, opts) + if len(results) != 5 { + t.Errorf("expected 5 results, got %d", len(results)) + } + }) + + t.Run("limit over 50 uses default", func(t *testing.T) { + raw := make([]searxngResult, 15) + for i := range raw { + raw[i] = searxngResult{URL: "https://example.com/" + string(rune('a'+i)), Title: "T"} + } + opts := &SearchOptions{Limit: 100} + results := normalizeResults(raw, opts) + if len(results) != 10 { + t.Errorf("expected 10 results (limit>50 falls back to default), got %d", len(results)) + } + }) + + t.Run("sorted by score descending", func(t *testing.T) { + raw := []searxngResult{ + {URL: "https://example.com/short", Title: "Short", Content: "x"}, + {URL: "https://en.wikipedia.org/wiki/Go", Title: "Wiki", Content: string(make([]byte, 150))}, + } + results := normalizeResults(raw, nil) + if len(results) < 2 { + t.Fatal("expected at least 2 results") + } + if results[0].Score < results[1].Score { + t.Errorf("results not sorted by score: %f < %f", results[0].Score, results[1].Score) + } + }) + + t.Run("empty input returns nil", func(t *testing.T) { + results := normalizeResults(nil, nil) + if results != nil { + t.Errorf("expected nil for empty input, got %v", results) + } + }) +} + +func TestCacheOptionsIsEnabled(t *testing.T) { + tests := []struct { + name string + opts *CacheOptions + want bool + }{ + {"nil cache options", nil, true}, + {"nil enabled field", &CacheOptions{}, true}, + {"enabled true", &CacheOptions{Enabled: boolPtr(true)}, true}, + {"enabled false", &CacheOptions{Enabled: boolPtr(false)}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.opts.IsEnabled(); got != tt.want { + t.Errorf("IsEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func boolPtr(b bool) *bool { + return &b +}