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