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 <noreply@anthropic.com>
This commit is contained in:
parent
d9f58ab79a
commit
86a373debb
2 changed files with 909 additions and 87 deletions
|
|
@ -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 <br>ntfy: requires "url", optional "token"<br>
|
||||
// @Description pushover: requires "app_token", "user_key"<br>
|
||||
// @Description gotify: requires "url", "token"<br>
|
||||
// @Description webhook: requires "url", optional "method" (GET/POST), optional "headers"<br>
|
||||
// @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",
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
645
mothership/internal/api/notifications_test.go
Normal file
645
mothership/internal/api/notifications_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue