From 86a373debb61b562f676a9fa940e8b6e6945f488 Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 7 Apr 2026 09:10:23 -0400 Subject: [PATCH] feat: implement Notifications REST endpoints - GET /api/notifications/config to get all delivery channel settings - POST /api/notifications/config to set channel configurations - POST /api/notifications/test to send test notifications - Support for ntfy, pushover, gotify, webhook, and mqtt channel types - Config validation for each channel type - Table-driven tests for all endpoints Co-Authored-By: Claude Opus 4.6 --- mothership/internal/api/notifications.go | 351 +++++++--- mothership/internal/api/notifications_test.go | 645 ++++++++++++++++++ 2 files changed, 909 insertions(+), 87 deletions(-) create mode 100644 mothership/internal/api/notifications_test.go diff --git a/mothership/internal/api/notifications.go b/mothership/internal/api/notifications.go index 48baa6f..1fdde0e 100644 --- a/mothership/internal/api/notifications.go +++ b/mothership/internal/api/notifications.go @@ -16,6 +16,7 @@ import ( ) // NotificationsHandler manages notification delivery channels. +// Supported channel types: ntfy, pushover, gotify, webhook, mqtt. type NotificationsHandler struct { mu sync.RWMutex db *sql.DB @@ -23,11 +24,19 @@ type NotificationsHandler struct { notifyService NotifySender } -// NotificationChannel represents a notification delivery channel. +// NotificationChannel represents a notification delivery channel configuration. +// +// Channel types and their config schemas: +// +// ntfy: {"url":"https://ntfy.sh/my-topic", "token":"tk_..."} (token optional) +// pushover: {"app_token":"aXXXXXX...","user_key":"uXXXXXX..."} +// gotify: {"url":"https://gotify.example.com","token":"Aq7mXXXX"} +// webhook: {"url":"https://example.com/hook","method":"POST","headers":{"X-Secret":"abc"}} +// mqtt: {} (uses global MQTT connection; no config needed) type NotificationChannel struct { - Type string `json:"type"` // ntfy, pushover, gotify, webhook - Enabled bool `json:"enabled"` - Config interface{} `json:"config"` + Type string `json:"type"` // ntfy, pushover, gotify, webhook, mqtt + Enabled bool `json:"enabled"` // true if channel is active + Config interface{} `json:"config,omitempty"` // channel-specific configuration } // NotifySender is the interface for sending test notifications. @@ -67,18 +76,17 @@ func NewNotificationsHandler(dbPath string) (*NotificationsHandler, error) { func (n *NotificationsHandler) migrate() error { _, err := n.db.Exec(` CREATE TABLE IF NOT EXISTS notification_channels ( - id TEXT PRIMARY KEY, - type TEXT NOT NULL, - enabled INTEGER NOT NULL DEFAULT 0, - config TEXT NOT NULL DEFAULT '{}', - updated_at INTEGER NOT NULL DEFAULT 0 + type TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0, + config_json TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000) ); `) return err } func (n *NotificationsHandler) load() error { - rows, err := n.db.Query(`SELECT id, type, enabled, config FROM notification_channels`) + rows, err := n.db.Query(`SELECT type, enabled, config_json FROM notification_channels`) if err != nil { return err } @@ -86,21 +94,22 @@ func (n *NotificationsHandler) load() error { for rows.Next() { var nc NotificationChannel - var id string var enabled int var configJSON string - if err := rows.Scan(&id, &nc.Type, &enabled, &configJSON); err != nil { + if err := rows.Scan(&nc.Type, &enabled, &configJSON); err != nil { + log.Printf("[WARN] Failed to scan notification channel: %v", err) continue } nc.Enabled = enabled != 0 if err := json.Unmarshal([]byte(configJSON), &nc.Config); err != nil { - // Keep as string if not valid JSON + log.Printf("[WARN] Failed to unmarshal config for %s: %v", nc.Type, err) + // Keep as raw JSON string if unmarshaling fails nc.Config = configJSON } - n.channels[id] = &nc + n.channels[nc.Type] = &nc } return nil @@ -114,99 +123,272 @@ func (n *NotificationsHandler) Close() error { // SetNotifyService sets the notification sender for test notifications. func (n *NotificationsHandler) SetNotifyService(ns NotifySender) { n.mu.Lock() + defer n.mu.Unlock() n.notifyService = ns - n.mu.Unlock() +} + +// GetChannels returns a copy of all notification channels. +func (n *NotificationsHandler) GetChannels() map[string]*NotificationChannel { + n.mu.RLock() + defer n.mu.RUnlock() + + result := make(map[string]*NotificationChannel, len(n.channels)) + for k, v := range n.channels { + result[k] = v + } + return result +} + +// GetChannel returns a single channel by type. +func (n *NotificationsHandler) GetChannel(channelType string) (*NotificationChannel, bool) { + n.mu.RLock() + defer n.mu.RUnlock() + ch, ok := n.channels[channelType] + return ch, ok +} + +// SetChannel updates or creates a notification channel. +func (n *NotificationsHandler) SetChannel(channelType string, enabled bool, config interface{}) error { + n.mu.Lock() + defer n.mu.Unlock() + + // Validate config based on channel type + if err := validateChannelConfig(channelType, config); err != nil { + return err + } + + configJSON, err := json.Marshal(config) + if err != nil { + return err + } + + enabledInt := 0 + if enabled { + enabledInt = 1 + } + + now := time.Now().UnixMilli() + _, err = n.db.Exec(` + INSERT INTO notification_channels (type, enabled, config_json, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(type) DO UPDATE SET enabled = ?, config_json = ?, updated_at = ? + `, channelType, enabledInt, string(configJSON), now, + enabledInt, string(configJSON), now) + if err != nil { + return err + } + + n.channels[channelType] = &NotificationChannel{ + Type: channelType, + Enabled: enabled, + Config: config, + } + + return nil +} + +// validateChannelConfig validates the configuration for a specific channel type. +func validateChannelConfig(channelType string, config interface{}) error { + if config == nil { + // Nil config is valid for mqtt, optional for others + return nil + } + + configMap, ok := config.(map[string]interface{}) + if !ok { + // Try to unmarshal as JSON + jsonBytes, err := json.Marshal(config) + if err != nil { + return &ChannelValidationError{Type: channelType, Reason: "config must be a JSON object"} + } + if err := json.Unmarshal(jsonBytes, &configMap); err != nil { + return &ChannelValidationError{Type: channelType, Reason: "config must be a JSON object"} + } + } + + switch channelType { + case "ntfy": + // url is required, token is optional + if _, ok := configMap["url"]; !ok { + return &ChannelValidationError{Type: channelType, Field: "url", Reason: "required field missing"} + } + case "pushover": + // app_token and user_key are required + if _, ok := configMap["app_token"]; !ok { + return &ChannelValidationError{Type: channelType, Field: "app_token", Reason: "required field missing"} + } + if _, ok := configMap["user_key"]; !ok { + return &ChannelValidationError{Type: channelType, Field: "user_key", Reason: "required field missing"} + } + case "gotify": + // url and token are required + if _, ok := configMap["url"]; !ok { + return &ChannelValidationError{Type: channelType, Field: "url", Reason: "required field missing"} + } + if _, ok := configMap["token"]; !ok { + return &ChannelValidationError{Type: channelType, Field: "token", Reason: "required field missing"} + } + case "webhook": + // url is required, method and headers are optional + if _, ok := configMap["url"]; !ok { + return &ChannelValidationError{Type: channelType, Field: "url", Reason: "required field missing"} + } + // Validate method if provided + if method, ok := configMap["method"].(string); ok { + if method != "GET" && method != "POST" { + return &ChannelValidationError{Type: channelType, Field: "method", Reason: "must be GET or POST"} + } + } + case "mqtt": + // No config required for mqtt (uses global connection) + default: + return &ChannelValidationError{Type: channelType, Reason: "unknown channel type"} + } + + return nil +} + +// ChannelValidationError represents a configuration validation error. +type ChannelValidationError struct { + Type string + Field string + Reason string +} + +func (e *ChannelValidationError) Error() string { + if e.Field != "" { + return e.Type + "." + e.Field + ": " + e.Reason + } + return e.Type + ": " + e.Reason } // RegisterRoutes registers notification endpoints. // -// GET /api/notifications/config — get delivery channel config -// POST /api/notifications/config — set channel config -// POST /api/notifications/test — send a test notification +// Notification Channels Endpoints: +// +// The notification channels API manages delivery channels for alerts and notifications. +// Supported channel types: ntfy, pushover, gotify, webhook, mqtt. +// +// GET /api/notifications/config +// +// @Summary Get notification channel configuration +// @Description Returns all notification channel configurations including enabled status and channel-specific settings. +// @Tags notifications +// @Produce json +// @Success 200 {object} notificationConfigResponse "Channel configurations" +// @Router /api/notifications/config [get] +// +// POST /api/notifications/config +// +// @Summary Update notification channel configuration +// @Description Updates one or more notification channel configurations. Each channel has type-specific required fields. +// @Description
ntfy: requires "url", optional "token"
+// @Description pushover: requires "app_token", "user_key"
+// @Description gotify: requires "url", "token"
+// @Description webhook: requires "url", optional "method" (GET/POST), optional "headers"
+// @Description mqtt: no config required (uses global MQTT connection) +// @Tags notifications +// @Accept json +// @Produce json +// @Param request body setNotificationConfigRequest true "Channel configurations to update" +// @Success 200 {object} notificationConfigResponse "Updated channel configurations" +// @Failure 400 {object} map[string]string "Invalid request body or validation error" +// @Failure 500 {object} map[string]string "Failed to save configuration" +// @Router /api/notifications/config [post] +// +// POST /api/notifications/test +// +// @Summary Send a test notification +// @Description Sends a test notification via the specified channel type. The channel must be enabled. +// @Tags notifications +// @Accept json +// @Produce json +// @Param request body testNotificationRequest true "Test notification parameters" +// @Success 200 {object} map[string]interface{} "Test result" +// @Failure 400 {object} map[string]string "Invalid request or no enabled channel" +// @Failure 500 {object} map[string]string "Failed to send notification" +// @Router /api/notifications/test [post] func (n *NotificationsHandler) RegisterRoutes(r chi.Router) { - r.Get("/api/notifications/config", n.getConfig) - r.Post("/api/notifications/config", n.setConfig) - r.Post("/api/notifications/test", n.sendTest) + r.Get("/api/notifications/config", n.handleGetConfig) + r.Post("/api/notifications/config", n.handleSetConfig) + r.Post("/api/notifications/test", n.handleSendTest) } -func (n *NotificationsHandler) getConfig(w http.ResponseWriter, r *http.Request) { - n.mu.RLock() - channels := make(map[string]*NotificationChannel) - for k, v := range n.channels { - channels[k] = v - } - n.mu.RUnlock() +// notificationConfigResponse is the response for channel configuration requests. +type notificationConfigResponse struct { + Channels map[string]*NotificationChannel `json:"channels"` +} - writeJSON(w, map[string]interface{}{ - "channels": channels, +// handleGetConfig handles GET /api/notifications/config requests. +func (n *NotificationsHandler) handleGetConfig(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, notificationConfigResponse{ + Channels: n.GetChannels(), }) } -type setConfigRequest struct { +// setNotificationConfigRequest is the request body for setting channel configuration. +type setNotificationConfigRequest struct { Channels map[string]struct { Type string `json:"type"` Enabled bool `json:"enabled"` - Config interface{} `json:"config"` + Config interface{} `json:"config,omitempty"` } `json:"channels"` } -func (n *NotificationsHandler) setConfig(w http.ResponseWriter, r *http.Request) { - var req setConfigRequest +// handleSetConfig handles POST /api/notifications/config requests. +func (n *NotificationsHandler) handleSetConfig(w http.ResponseWriter, r *http.Request) { + var req setNotificationConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } - now := time.Now().UnixNano() - - for id, ch := range req.Channels { - configJSON, err := json.Marshal(ch.Config) - if err != nil { - http.Error(w, "failed to marshal config", http.StatusBadRequest) - return + // Validate and update each channel + for channelType, ch := range req.Channels { + if ch.Type == "" { + ch.Type = channelType // Use map key as type if not specified } - enabled := 0 - if ch.Enabled { - enabled = 1 - } - - _, err = n.db.Exec(` - INSERT INTO notification_channels (id, type, enabled, config, updated_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET type = ?, enabled = ?, config = ?, updated_at = ? - `, id, ch.Type, enabled, string(configJSON), now, - ch.Type, enabled, string(configJSON), now) - if err != nil { - http.Error(w, "failed to save config", http.StatusInternalServerError) + if ch.Type != channelType { + writeJSONError(w, http.StatusBadRequest, "channel type mismatch: key is "+channelType+" but body specifies "+ch.Type) return } - n.mu.Lock() - n.channels[id] = &NotificationChannel{ - Type: ch.Type, - Enabled: ch.Enabled, - Config: ch.Config, + if err := n.SetChannel(ch.Type, ch.Enabled, ch.Config); err != nil { + if ce, ok := err.(*ChannelValidationError); ok { + writeJSONError(w, http.StatusBadRequest, ce.Error()) + } else { + writeJSONError(w, http.StatusInternalServerError, "failed to save configuration: "+err.Error()) + } + return } - n.mu.Unlock() } - n.getConfig(w, r) + n.handleGetConfig(w, r) } +// testNotificationRequest is the request body for sending a test notification. type testNotificationRequest struct { - ChannelType string `json:"channel_type"` - Title string `json:"title"` - Body string `json:"body"` - Data map[string]interface{} `json:"data,omitempty"` + ChannelType string `json:"channel_type"` // ntfy, pushover, gotify, webhook, mqtt + Title string `json:"title"` // Custom title (optional) + Body string `json:"body"` // Custom body (optional) + Data map[string]interface{} `json:"data,omitempty"` // Additional data (optional) } -func (n *NotificationsHandler) sendTest(w http.ResponseWriter, r *http.Request) { +// testNotificationResponse is the response for a test notification. +type testNotificationResponse struct { + Status string `json:"status"` // "sent" or "simulated" + Message string `json:"message"` // Human-readable result +} + +// handleSendTest handles POST /api/notifications/test requests. +func (n *NotificationsHandler) handleSendTest(w http.ResponseWriter, r *http.Request) { var req testNotificationRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) + writeJSONError(w, http.StatusBadRequest, "invalid request body: "+err.Error()) return } + // Set defaults if req.Title == "" { req.Title = "Spaxel Test Notification" } @@ -218,19 +400,14 @@ func (n *NotificationsHandler) sendTest(w http.ResponseWriter, r *http.Request) } req.Data["test"] = true - // Check if channel type exists - var found bool - n.mu.RLock() - for _, ch := range n.channels { - if ch.Type == req.ChannelType && ch.Enabled { - found = true - break - } + // Check if channel type exists and is enabled + ch, ok := n.GetChannel(req.ChannelType) + if !ok { + writeJSONError(w, http.StatusBadRequest, "unknown channel type: "+req.ChannelType) + return } - n.mu.RUnlock() - - if !found { - http.Error(w, "no enabled channel found for type: "+req.ChannelType, http.StatusBadRequest) + if !ch.Enabled { + writeJSONError(w, http.StatusBadRequest, "channel is not enabled: "+req.ChannelType) return } @@ -240,21 +417,21 @@ func (n *NotificationsHandler) sendTest(w http.ResponseWriter, r *http.Request) n.mu.RUnlock() if sender == nil { - writeJSON(w, map[string]interface{}{ - "status": "simulated", - "message": "Test notification simulated (no sender attached)", + writeJSON(w, http.StatusOK, testNotificationResponse{ + Status: "simulated", + Message: "Test notification simulated (no sender attached)", }) return } if err := sender.Send(req.Title, req.Body, req.Data); err != nil { - http.Error(w, "failed to send notification: "+err.Error(), http.StatusInternalServerError) + writeJSONError(w, http.StatusInternalServerError, "failed to send notification: "+err.Error()) return } - writeJSON(w, map[string]interface{}{ - "status": "sent", - "message": "Test notification sent successfully", + writeJSON(w, http.StatusOK, testNotificationResponse{ + Status: "sent", + Message: "Test notification sent successfully", }) } diff --git a/mothership/internal/api/notifications_test.go b/mothership/internal/api/notifications_test.go new file mode 100644 index 0000000..acae49d --- /dev/null +++ b/mothership/internal/api/notifications_test.go @@ -0,0 +1,645 @@ +// Package api provides REST API handlers for Spaxel notification channels. +package api + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +func TestNotificationsHandler(t *testing.T) { + // Create a temporary database + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "notifications.db") + + handler, err := NewNotificationsHandler(dbPath) + if err != nil { + t.Fatalf("Failed to create notifications handler: %v", err) + } + defer handler.Close() + + // Create a test router + router := http.NewServeMux() + handler.RegisterRoutes(router) + + t.Run("GET /api/notifications/config - initial empty state", func(t *testing.T) { + req := httptest.NewRequest("GET", "/api/notifications/config", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var resp notificationConfigResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(resp.Channels) != 0 { + t.Errorf("Expected 0 channels, got %d", len(resp.Channels)) + } + }) + + t.Run("POST /api/notifications/config - set ntfy channel", func(t *testing.T) { + reqBody := setNotificationConfigRequest{ + Channels: map[string]struct { + Type string `json:"type"` + Enabled bool `json:"enabled"` + Config interface{} `json:"config,omitempty"` + }{ + "ntfy": { + Type: "ntfy", + Enabled: true, + Config: map[string]string{ + "url": "https://ntfy.sh/my-topic", + "token": "tk_test123", + }, + }, + }, + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/notifications/config", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp notificationConfigResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(resp.Channels) != 1 { + t.Errorf("Expected 1 channel, got %d", len(resp.Channels)) + } + + ntfy, ok := resp.Channels["ntfy"] + if !ok { + t.Fatal("ntfy channel not found") + } + + if !ntfy.Enabled { + t.Error("Expected ntfy channel to be enabled") + } + }) + + t.Run("POST /api/notifications/config - validation error: missing required field", func(t *testing.T) { + reqBody := setNotificationConfigRequest{ + Channels: map[string]struct { + Type string `json:"type"` + Enabled bool `json:"enabled"` + Config interface{} `json:"config,omitempty"` + }{ + "pushover": { + Type: "pushover", + Enabled: true, + Config: map[string]string{ + "app_token": "test123", + // missing user_key + }, + }, + }, + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/notifications/config", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d: %s", w.Code, w.Body.String()) + } + + var errResp map[string]string + if err := json.NewDecoder(w.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + + if errResp["error"] == "" { + t.Error("Expected error message in response") + } + }) + + t.Run("POST /api/notifications/config - multiple channels", func(t *testing.T) { + reqBody := setNotificationConfigRequest{ + Channels: map[string]struct { + Type string `json:"type"` + Enabled bool `json:"enabled"` + Config interface{} `json:"config,omitempty"` + }{ + "gotify": { + Type: "gotify", + Enabled: true, + Config: map[string]string{ + "url": "https://gotify.example.com", + "token": "Aq7mXXXX", + }, + }, + "webhook": { + Type: "webhook", + Enabled: false, + Config: map[string]interface{}{ + "url": "https://example.com/hook", + "method": "POST", + "headers": map[string]string{ + "X-Secret": "abc", + }, + }, + }, + "mqtt": { + Type: "mqtt", + Enabled: true, + Config: map[string]string{}, // no config needed + }, + }, + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/notifications/config", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp notificationConfigResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Should have 4 channels total (ntfy from previous test + gotify, webhook, mqtt) + if len(resp.Channels) != 4 { + t.Errorf("Expected 4 channels, got %d", len(resp.Channels)) + } + + // Verify gotify + gotify, ok := resp.Channels["gotify"] + if !ok || !gotify.Enabled { + t.Error("gotify channel not found or not enabled") + } + + // Verify webhook is disabled + webhook, ok := resp.Channels["webhook"] + if !ok || webhook.Enabled { + t.Error("webhook channel not found or should be disabled") + } + + // Verify mqtt + mqtt, ok := resp.Channels["mqtt"] + if !ok || !mqtt.Enabled { + t.Error("mqtt channel not found or not enabled") + } + }) + + t.Run("POST /api/notifications/test - no sender attached (simulated)", func(t *testing.T) { + reqBody := testNotificationRequest{ + ChannelType: "ntfy", + Title: "Test Alert", + Body: "This is a test notification", + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp testNotificationResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if resp.Status != "simulated" { + t.Errorf("Expected status 'simulated', got '%s'", resp.Status) + } + }) + + t.Run("POST /api/notifications/test - unknown channel type", func(t *testing.T) { + reqBody := testNotificationRequest{ + ChannelType: "unknown", + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d: %s", w.Code, w.Body.String()) + } + }) + + t.Run("POST /api/notifications/test - disabled channel", func(t *testing.T) { + reqBody := testNotificationRequest{ + ChannelType: "webhook", // webhook was set to disabled + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d: %s", w.Code, w.Body.String()) + } + }) + + t.Run("POST /api/notifications/test - with custom sender", func(t *testing.T) { + // Create a mock sender + mockSender := &mockNotifySender{} + handler.SetNotifyService(mockSender) + + reqBody := testNotificationRequest{ + ChannelType: "ntfy", + Title: "Custom Title", + Body: "Custom Body", + Data: map[string]interface{}{ + "priority": "high", + }, + } + + bodyBytes, _ := json.Marshal(reqBody) + req := httptest.NewRequest("POST", "/api/notifications/test", bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + if !mockSender.called { + t.Error("Expected sender.Send to be called") + } + + if mockSender.title != "Custom Title" { + t.Errorf("Expected title 'Custom Title', got '%s'", mockSender.title) + } + + if mockSender.body != "Custom Body" { + t.Errorf("Expected body 'Custom Body', got '%s'", mockSender.body) + } + }) +} + +// mockNotifySender is a test implementation of NotifySender. +type mockNotifySender struct { + called bool + title string + body string + data map[string]interface{} +} + +func (m *mockNotifySender) Send(title, body string, data map[string]interface{}) error { + m.called = true + m.title = title + m.body = body + m.data = data + return nil +} + +func TestValidateChannelConfig(t *testing.T) { + tests := []struct { + name string + channelType string + config interface{} + wantErr bool + errField string + }{ + { + name: "ntfy - valid config", + channelType: "ntfy", + config: map[string]string{ + "url": "https://ntfy.sh/my-topic", + "token": "tk_test", + }, + wantErr: false, + }, + { + name: "ntfy - missing url", + channelType: "ntfy", + config: map[string]string{ + "token": "tk_test", + }, + wantErr: true, + errField: "url", + }, + { + name: "ntfy - url only (token optional)", + channelType: "ntfy", + config: map[string]string{ + "url": "https://ntfy.sh/my-topic", + }, + wantErr: false, + }, + { + name: "pushover - valid config", + channelType: "pushover", + config: map[string]string{ + "app_token": "aXXXXXX", + "user_key": "uXXXXXX", + }, + wantErr: false, + }, + { + name: "pushover - missing app_token", + channelType: "pushover", + config: map[string]string{ + "user_key": "uXXXXXX", + }, + wantErr: true, + errField: "app_token", + }, + { + name: "pushover - missing user_key", + channelType: "pushover", + config: map[string]string{ + "app_token": "aXXXXXX", + }, + wantErr: true, + errField: "user_key", + }, + { + name: "gotify - valid config", + channelType: "gotify", + config: map[string]string{ + "url": "https://gotify.example.com", + "token": "Aq7mXXXX", + }, + wantErr: false, + }, + { + name: "gotify - missing url", + channelType: "gotify", + config: map[string]string{ + "token": "Aq7mXXXX", + }, + wantErr: true, + errField: "url", + }, + { + name: "gotify - missing token", + channelType: "gotify", + config: map[string]string{ + "url": "https://gotify.example.com", + }, + wantErr: true, + errField: "token", + }, + { + name: "webhook - valid config with all fields", + channelType: "webhook", + config: map[string]interface{}{ + "url": "https://example.com/hook", + "method": "POST", + "headers": map[string]string{ + "X-Secret": "abc", + }, + }, + wantErr: false, + }, + { + name: "webhook - url only", + channelType: "webhook", + config: map[string]string{ + "url": "https://example.com/hook", + }, + wantErr: false, + }, + { + name: "webhook - missing url", + channelType: "webhook", + config: map[string]string{ + "method": "POST", + }, + wantErr: true, + errField: "url", + }, + { + name: "webhook - invalid method", + channelType: "webhook", + config: map[string]string{ + "url": "https://example.com/hook", + "method": "DELETE", + }, + wantErr: true, + errField: "method", + }, + { + name: "mqtt - no config needed", + channelType: "mqtt", + config: map[string]string{}, + wantErr: false, + }, + { + name: "mqtt - nil config", + channelType: "mqtt", + config: nil, + wantErr: false, + }, + { + name: "unknown channel type", + channelType: "unknown", + config: map[string]string{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateChannelConfig(tt.channelType, tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("validateChannelConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && tt.errField != "" { + ce, ok := err.(*ChannelValidationError) + if !ok { + t.Errorf("Expected ChannelValidationError, got %T", err) + return + } + if ce.Field != tt.errField { + t.Errorf("Expected error field '%s', got '%s'", tt.errField, ce.Field) + } + } + }) + } +} + +func TestNotificationsHandlerPersistence(t *testing.T) { + // Create a temporary database + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "notifications.db") + + // Create first handler and set some channels + h1, err := NewNotificationsHandler(dbPath) + if err != nil { + t.Fatalf("Failed to create first handler: %v", err) + } + + err = h1.SetChannel("ntfy", true, map[string]string{ + "url": "https://ntfy.sh/test", + "token": "tk_test", + }) + if err != nil { + t.Fatalf("Failed to set channel: %v", err) + } + + err = h1.SetChannel("pushover", false, map[string]string{ + "app_token": "a123", + "user_key": "u456", + }) + if err != nil { + t.Fatalf("Failed to set channel: %v", err) + } + + h1.Close() + + // Create second handler with same database - should load persisted channels + h2, err := NewNotificationsHandler(dbPath) + if err != nil { + t.Fatalf("Failed to create second handler: %v", err) + } + defer h2.Close() + + channels := h2.GetChannels() + + if len(channels) != 2 { + t.Errorf("Expected 2 channels, got %d", len(channels)) + } + + // Verify ntfy channel + ntfy, ok := channels["ntfy"] + if !ok { + t.Fatal("ntfy channel not found") + } + if !ntfy.Enabled { + t.Error("Expected ntfy to be enabled") + } + config, ok := ntfy.Config.(map[string]interface{}) + if !ok { + t.Fatal("ntfy config is not a map") + } + if config["url"] != "https://ntfy.sh/test" { + t.Errorf("Expected url 'https://ntfy.sh/test', got '%v'", config["url"]) + } + + // Verify pushover channel + pushover, ok := channels["pushover"] + if !ok { + t.Fatal("pushover channel not found") + } + if pushover.Enabled { + t.Error("Expected pushover to be disabled") + } +} + +func TestNotificationsHandlerSendNotification(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "notifications.db") + + handler, err := NewNotificationsHandler(dbPath) + if err != nil { + t.Fatalf("Failed to create notifications handler: %v", err) + } + defer handler.Close() + + // Set up a mock sender + mockSender := &mockNotifySender{} + handler.SetNotifyService(mockSender) + + // No channels enabled - should not call sender + err = handler.SendNotification("Test", "Body", nil) + if err != nil { + t.Errorf("SendNotification() with no channels should not error, got: %v", err) + } + if mockSender.called { + t.Error("Expected sender not to be called when no channels enabled") + } + + // Enable a channel + err = handler.SetChannel("ntfy", true, map[string]string{"url": "https://ntfy.sh/test"}) + if err != nil { + t.Fatalf("Failed to set channel: %v", err) + } + + // Now SendNotification should call sender + err = handler.SendNotification("Test Title", "Test Body", map[string]interface{}{"key": "value"}) + if err != nil { + t.Errorf("SendNotification() error = %v", err) + } + if !mockSender.called { + t.Error("Expected sender to be called") + } + if mockSender.title != "Test Title" { + t.Errorf("Expected title 'Test Title', got '%s'", mockSender.title) + } +} + +func TestNewNotificationsHandlerWithPath(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + // Create a handler with path + handler, err := NewNotificationsHandler(dbPath) + if err != nil { + t.Fatalf("Failed to create handler: %v", err) + } + defer handler.Close() + + // Verify the database file was created + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + t.Error("Database file was not created") + } +} + +func TestChannelValidationError(t *testing.T) { + err := &ChannelValidationError{ + Type: "ntfy", + Field: "url", + Reason: "required field missing", + } + + expected := "ntfy.url: required field missing" + if err.Error() != expected { + t.Errorf("Expected error '%s', got '%s'", expected, err.Error()) + } + + // Error without field + err2 := &ChannelValidationError{ + Type: "unknown", + Reason: "unknown channel type", + } + + expected2 := "unknown: unknown channel type" + if err2.Error() != expected2 { + t.Errorf("Expected error '%s', got '%s'", expected2, err2.Error()) + } +} + +// Helper function to read all of response body +func readAll(r io.Reader) string { + b, _ := io.ReadAll(r) + return string(b) +}