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:
jedarden 2026-04-07 09:10:23 -04:00
parent d9f58ab79a
commit 86a373debb
2 changed files with 909 additions and 87 deletions

View file

@ -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",
})
}

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