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:
Till JS 2026-03-28 02:33:59 +01:00
parent 753c685ef7
commit d0848ea1b3
10 changed files with 1376 additions and 0 deletions

View 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)
}
}

View 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)
}
})
}
}

View 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)
}
}

View 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)
}
}
})
}
}

View 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)
}
})
}
}

View 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)
}
})
}
}

View 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)
}
}

View 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
}

View 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)
}
}

View 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
}