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: "
{{.body}}
", + data: map[string]any{"title": "Welcome", "body": "Hello world"}, + want: "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: "