mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
753c685ef7
commit
d0848ea1b3
10 changed files with 1376 additions and 0 deletions
174
services/mana-notify-go/internal/auth/auth_test.go
Normal file
174
services/mana-notify-go/internal/auth/auth_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
31
services/mana-notify-go/internal/channel/push_test.go
Normal file
31
services/mana-notify-go/internal/channel/push_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
166
services/mana-notify-go/internal/channel/webhook_test.go
Normal file
166
services/mana-notify-go/internal/channel/webhook_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
200
services/mana-notify-go/internal/handler/notifications_test.go
Normal file
200
services/mana-notify-go/internal/handler/notifications_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
86
services/mana-notify-go/internal/template/engine_test.go
Normal file
86
services/mana-notify-go/internal/template/engine_test.go
Normal file
|
|
@ -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: "<h1>{{.title}}</h1><p>{{.body}}</p>",
|
||||
data: map[string]any{"title": "Welcome", "body": "Hello world"},
|
||||
want: "<h1>Welcome</h1><p>Hello world</p>",
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
141
services/mana-search-go/internal/extract/extractor_test.go
Normal file
141
services/mana-search-go/internal/extract/extractor_test.go
Normal file
|
|
@ -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: "<p>Hello <strong>world</strong></p>",
|
||||
want: "Hello world",
|
||||
},
|
||||
{
|
||||
name: "strips script tags and content",
|
||||
input: "Before<script>alert('xss')</script>After",
|
||||
want: "BeforeAfter",
|
||||
},
|
||||
{
|
||||
name: "strips style tags and content",
|
||||
input: "Before<style>.a{color:red}</style>After",
|
||||
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: "A<script type=\"text/javascript\">\nvar x = 1;\nconsole.log(x);\n</script>B",
|
||||
want: "AB",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "only tags",
|
||||
input: "<div><span></span></div>",
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
181
services/mana-search-go/internal/handler/extract_test.go
Normal file
181
services/mana-search-go/internal/handler/extract_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
25
services/mana-search-go/internal/handler/helpers_test.go
Normal file
25
services/mana-search-go/internal/handler/helpers_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
108
services/mana-search-go/internal/handler/search_test.go
Normal file
108
services/mana-search-go/internal/handler/search_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
264
services/mana-search-go/internal/search/searxng_test.go
Normal file
264
services/mana-search-go/internal/search/searxng_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue