From 72b340d03a60f235165c4aa6b18186ccc3f3fb3e Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 7 Apr 2026 09:04:14 -0400 Subject: [PATCH] feat: implement Settings REST endpoints Implement GET and POST/PATCH /api/settings endpoints with: - GET /api/settings: Returns all configurable settings as JSON with default values merged in for any settings not explicitly set - POST/PATCH /api/settings: Partial update with merge semantics, only updating the keys provided in the request body - SQLite persistence: Settings stored in settings table with JSON-encoded values, loaded on startup - Comprehensive validation: All known settings validated with proper range checks (fusion_rate_hz, grid_cell_m, delta_rms_threshold, tau_s, fresnel_decay, n_subcarriers, breathing_sensitivity, motion_threshold, dwell_seconds, vacant_seconds, max_tracked_blobs, replay_retention_hours, replay_max_mb, security_mode, events_archive_days) - OpenAPI-style godoc comments: Documentation for all endpoints using swagger annotations - Table-driven tests: Comprehensive test coverage for all functionality including GET, POST, PATCH, validation, persistence, and error cases - Helper utilities: Extracted writeJSON, writeJSONError, writeJSONData to utils.go for reuse Co-Authored-By: Claude Opus 4.6 --- mothership/internal/api/settings.go | 462 ++++++++++----- mothership/internal/api/settings_test.go | 715 +++++++++++++++++++++++ mothership/internal/api/utils.go | 30 + 3 files changed, 1052 insertions(+), 155 deletions(-) create mode 100644 mothership/internal/api/settings_test.go create mode 100644 mothership/internal/api/utils.go diff --git a/mothership/internal/api/settings.go b/mothership/internal/api/settings.go index b905ae4..7294816 100644 --- a/mothership/internal/api/settings.go +++ b/mothership/internal/api/settings.go @@ -6,7 +6,6 @@ import ( "encoding/json" "log" "net/http" - "os" "sync" "time" @@ -15,34 +14,51 @@ import ( ) // SettingsHandler manages application settings. +// Settings are stored as key-value pairs in the settings table with JSON-encoded values. type SettingsHandler struct { mu sync.RWMutex db *sql.DB - data map[string]interface{} + // cache is an in-memory cache of settings for fast reads + cache map[string]interface{} } -// NewSettingsHandler creates a new settings handler. -func NewSettingsHandler(dbPath string) (*SettingsHandler, error) { - if err := os.MkdirAll(dbPath[:len(dbPath)-len("/settings.db")], 0755); err != nil { - return nil, err +// NewSettingsHandler creates a new settings handler using the provided database connection. +// The database connection must be to the main spaxel.db which contains the settings table. +func NewSettingsHandler(db *sql.DB) *SettingsHandler { + s := &SettingsHandler{ + db: db, + cache: make(map[string]interface{}), } + // Load initial settings into cache + if err := s.load(); err != nil { + log.Printf("[WARN] Failed to load settings: %v", err) + } + return s +} - db, err := sql.Open("sqlite", dbPath) +// NewSettingsHandlerWithPath creates a new settings handler by opening a database +// at the specified path. This is a convenience function for handlers that manage +// their own database connections. +func NewSettingsHandlerWithPath(dbPath string) (*SettingsHandler, error) { + db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)") if err != nil { return nil, err } db.SetMaxOpenConns(1) - s := &SettingsHandler{ - db: db, - data: make(map[string]interface{}), - } - - if err := s.migrate(); err != nil { + // Verify the settings table exists + var tableName string + err = db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='settings'").Scan(&tableName) + if err != nil { db.Close() return nil, err } + s := &SettingsHandler{ + db: db, + cache: make(map[string]interface{}), + } + // Load initial settings into cache if err := s.load(); err != nil { log.Printf("[WARN] Failed to load settings: %v", err) } @@ -50,62 +66,89 @@ func NewSettingsHandler(dbPath string) (*SettingsHandler, error) { return s, nil } -func (s *SettingsHandler) migrate() error { - _, err := s.db.Exec(` - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL, - updated_at INTEGER NOT NULL DEFAULT 0 - ); - `) - return err -} - -func (s *SettingsHandler) load() error { - rows, err := s.db.Query(`SELECT key, value FROM settings`) - if err != nil { - return err - } - defer rows.Close() - - s.mu.Lock() - defer s.mu.Unlock() - - for rows.Next() { - var key, valueStr string - if err := rows.Scan(&key, &valueStr); err != nil { - continue - } - - var value interface{} - if err := json.Unmarshal([]byte(valueStr), &value); err != nil { - // If not valid JSON, store as string - value = valueStr - } - s.data[key] = value - } - - return nil -} - // Close closes the database connection. func (s *SettingsHandler) Close() error { return s.db.Close() } +// load reads all settings from the database into the in-memory cache. +// It must be called with the mutex lock held. +func (s *SettingsHandler) load() error { + s.mu.Lock() + defer s.mu.Unlock() + + rows, err := s.db.Query(`SELECT key, value_json FROM settings`) + if err != nil { + return err + } + defer rows.Close() + + // Clear existing cache + s.cache = make(map[string]interface{}) + + for rows.Next() { + var key, valueJSON string + if err := rows.Scan(&key, &valueJSON); err != nil { + log.Printf("[WARN] Failed to scan setting key=%s: %v", key, err) + continue + } + + var value interface{} + if err := json.Unmarshal([]byte(valueJSON), &value); err != nil { + // If not valid JSON, store as string + log.Printf("[WARN] Failed to unmarshal setting key=%s: %v", key, err) + s.cache[key] = valueJSON + } else { + s.cache[key] = value + } + } + + return rows.Err() +} + // Get returns all settings as a map. +// Default values are included for keys that don't exist in the database. func (s *SettingsHandler) Get() map[string]interface{} { s.mu.RLock() defer s.mu.RUnlock() - result := make(map[string]interface{}, len(s.data)) - for k, v := range s.data { + result := make(map[string]interface{}, len(s.cache)+len(defaultSettings)) + + // First add cached values + for k, v := range s.cache { result[k] = v } + + // Then add defaults for any missing keys + for k, v := range defaultSettings { + if _, exists := s.cache[k]; !exists { + result[k] = v + } + } + return result } +// GetSingle returns a single setting value by key. +// Returns the value, true if found, or nil, false if not found. +func (s *SettingsHandler) GetSingle(key string) (interface{}, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + if val, exists := s.cache[key]; exists { + return val, true + } + + // Check defaults + if val, exists := defaultSettings[key]; exists { + return val, true + } + + return nil, false +} + // Set updates a single setting value. +// The value is marshaled to JSON and stored in the database. func (s *SettingsHandler) Set(key string, value interface{}) error { s.mu.Lock() defer s.mu.Unlock() @@ -113,25 +156,29 @@ func (s *SettingsHandler) Set(key string, value interface{}) error { return s.setLocked(key, value) } +// setLocked updates a setting without acquiring the mutex. +// The caller must hold s.mu. func (s *SettingsHandler) setLocked(key string, value interface{}) error { valueJSON, err := json.Marshal(value) if err != nil { return err } + now := time.Now().UnixMilli() _, err = s.db.Exec(` - INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) - ON CONFLICT(key) DO UPDATE SET value = ?, updated_at = ? - `, key, string(valueJSON), nowMS(), string(valueJSON), nowMS()) + INSERT INTO settings (key, value_json, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value_json = ?, updated_at = ? + `, key, string(valueJSON), now, string(valueJSON), now) if err != nil { return err } - s.data[key] = value + s.cache[key] = value return nil } -// Update merges a partial settings map. +// Update merges a partial settings map with existing settings. +// Only the keys provided in the updates map are modified. func (s *SettingsHandler) Update(updates map[string]interface{}) error { s.mu.Lock() defer s.mu.Unlock() @@ -144,7 +191,7 @@ func (s *SettingsHandler) Update(updates map[string]interface{}) error { return nil } -// Delete removes a setting. +// Delete removes a setting from both the database and cache. func (s *SettingsHandler) Delete(key string) error { s.mu.Lock() defer s.mu.Unlock() @@ -154,119 +201,92 @@ func (s *SettingsHandler) Delete(key string) error { return err } - delete(s.data, key) + delete(s.cache, key) return nil } -// RegisterRoutes registers settings endpoints. -// GET /api/settings — return all configurable settings as JSON -// POST /api/settings — update settings (partial update, merge semantics) +// defaultSettings defines the default values for all known settings. +// These are returned when a key hasn't been set in the database. +var defaultSettings = map[string]interface{}{ + "fusion_rate_hz": 10.0, // Fusion loop rate in Hz + "grid_cell_m": 0.2, // Fresnel grid cell size in meters + "delta_rms_threshold": 0.02, // Motion detection threshold + "tau_s": 30.0, // EMA baseline time constant in seconds + "fresnel_decay": 2.0, // Fresnel zone weight decay rate + "n_subcarriers": 16, // Number of subcarriers for NBVI selection + "breathing_sensitivity": 0.005, // Breathing detection threshold (radians RMS) + "motion_threshold": 0.05, // Smooth deltaRMS threshold for motion gating + "dwell_seconds": 30, // Default dwell trigger duration in seconds + "vacant_seconds": 300, // Default vacant trigger duration in seconds + "max_tracked_blobs": 20, // Maximum number of blobs to track simultaneously + "replay_retention_hours": 48, // CSI replay buffer retention in hours + "replay_max_mb": 360, // CSI replay buffer max size in MB + "security_mode": false, // Security mode enabled state + "security_mode_armed_at": nil, // Timestamp when security mode was armed + "events_archive_days": 90, // Events archive retention in days + "quiet_hours_start": "", // Quiet hours start time (HH:MM format) + "quiet_hours_end": "", // Quiet hours end time (HH:MM format) +} + +// RegisterRoutes registers settings endpoints on the given router. +// +// Settings Endpoints: +// +// GET /api/settings — Return all configurable settings as JSON +// +// @Summary Get all settings +// @Description Returns all system settings as a JSON object. Default values are included +// @Description for any settings that haven't been explicitly set. +// @Tags settings +// @Produce json +// @Success 200 {object} map[string]interface{} "Settings object" +// @Router /api/settings [get] +// +// POST /api/settings — Update settings (partial update, merge semantics) +// +// @Summary Update settings +// @Description Updates one or more settings. Only the keys provided in the request body +// @Description are modified; other settings remain unchanged. +// @Tags settings +// @Accept json +// @Produce json +// @Param request body map[string]interface{} true "Settings to update (partial)" +// @Success 200 {object} map[string]interface{} "Updated settings object" +// @Failure 400 {object} map[string]string "Invalid request body or validation error" +// @Failure 500 {object} map[string]string "Failed to update settings" +// @Router /api/settings [post] +// +// PATCH /api/settings — Update settings (alias for POST) func (s *SettingsHandler) RegisterRoutes(r chi.Router) { r.Get("/api/settings", s.handleGetSettings) r.Post("/api/settings", s.handleUpdateSettings) r.Patch("/api/settings", s.handleUpdateSettings) } +// handleGetSettings handles GET /api/settings requests. func (s *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) { settings := s.Get() - - // Add default values for keys that don't exist yet - if _, ok := settings["fusion_rate_hz"]; !ok { - settings["fusion_rate_hz"] = 10 - } - if _, ok := settings["grid_cell_m"]; !ok { - settings["grid_cell_m"] = 0.2 - } - if _, ok := settings["delta_rms_threshold"]; !ok { - settings["delta_rms_threshold"] = 0.02 - } - if _, ok := settings["tau_s"]; !ok { - settings["tau_s"] = 30.0 - } - if _, ok := settings["fresnel_decay"]; !ok { - settings["fresnel_decay"] = 2.0 - } - if _, ok := settings["n_subcarriers"]; !ok { - settings["n_subcarriers"] = 16 - } - if _, ok := settings["breathing_sensitivity"]; !ok { - settings["breathing_sensitivity"] = 0.005 - } - if _, ok := settings["motion_threshold"]; !ok { - settings["motion_threshold"] = 0.05 - } - if _, ok := settings["dwell_seconds"]; !ok { - settings["dwell_seconds"] = 30 - } - if _, ok := settings["vacant_seconds"]; !ok { - settings["vacant_seconds"] = 300 - } - if _, ok := settings["max_tracked_blobs"]; !ok { - settings["max_tracked_blobs"] = 20 - } - if _, ok := settings["replay_retention_hours"]; !ok { - settings["replay_retention_hours"] = 48 - } - if _, ok := settings["replay_max_mb"]; !ok { - settings["replay_max_mb"] = 360 - } - - writeJSON(w, settings) + writeJSON(w, http.StatusOK, settings) } +// handleUpdateSettings handles POST/PATCH /api/settings requests. +// It validates known settings and applies partial updates with merge semantics. func (s *SettingsHandler) handleUpdateSettings(w http.ResponseWriter, r *http.Request) { var updates map[string]interface{} if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body: " + err.Error()}) return } - // Validate some known settings - if v, ok := updates["fusion_rate_hz"]; ok { - if f, ok := v.(float64); !ok || f < 1 || f > 20 { - http.Error(w, "fusion_rate_hz must be between 1 and 20", http.StatusBadRequest) - return - } - } - if v, ok := updates["grid_cell_m"]; ok { - if f, ok := v.(float64); !ok || f < 0.05 || f > 1.0 { - http.Error(w, "grid_cell_m must be between 0.05 and 1.0", http.StatusBadRequest) - return - } - } - if v, ok := updates["delta_rms_threshold"]; ok { - if f, ok := v.(float64); !ok || f < 0.001 || f > 1.0 { - http.Error(w, "delta_rms_threshold must be between 0.001 and 1.0", http.StatusBadRequest) - return - } - } - if v, ok := updates["tau_s"]; ok { - if f, ok := v.(float64); !ok || f < 1 || f > 600 { - http.Error(w, "tau_s must be between 1 and 600", http.StatusBadRequest) - return - } - } - if v, ok := updates["fresnel_decay"]; ok { - if f, ok := v.(float64); !ok || f < 1.0 || f > 4.0 { - http.Error(w, "fresnel_decay must be between 1.0 and 4.0", http.StatusBadRequest) - return - } - } - if v, ok := updates["n_subcarriers"]; ok { - if f, ok := v.(float64); !ok || f < 8 || f > 47 { - http.Error(w, "n_subcarriers must be between 8 and 47", http.StatusBadRequest) - return - } - } - if v, ok := updates["max_tracked_blobs"]; ok { - if f, ok := v.(float64); !ok || f < 1 || f > 100 { - http.Error(w, "max_tracked_blobs must be between 1 and 100", http.StatusBadRequest) - return - } + // Validate known settings + if err := validateSettings(updates); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return } if err := s.Update(updates); err != nil { - http.Error(w, "failed to update settings", http.StatusInternalServerError) + log.Printf("[ERROR] Failed to update settings: %v", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update settings"}) return } @@ -274,11 +294,143 @@ func (s *SettingsHandler) handleUpdateSettings(w http.ResponseWriter, r *http.Re s.handleGetSettings(w, r) } -func nowMS() int64 { - return time.Now().UnixNano() +// validateSettings validates the provided settings values. +// Returns an error if any setting value is outside its valid range. +func validateSettings(settings map[string]interface{}) error { + // Validate fusion_rate_hz: 1-20 Hz + if v, ok := settings["fusion_rate_hz"]; ok { + if f, ok := asFloat64(v); !ok || f < 1 || f > 20 { + return &ValidationError{Key: "fusion_rate_hz", Reason: "must be between 1 and 20"} + } + } + + // Validate grid_cell_m: 0.05-1.0 meters + if v, ok := settings["grid_cell_m"]; ok { + if f, ok := asFloat64(v); !ok || f < 0.05 || f > 1.0 { + return &ValidationError{Key: "grid_cell_m", Reason: "must be between 0.05 and 1.0"} + } + } + + // Validate delta_rms_threshold: 0.001-1.0 + if v, ok := settings["delta_rms_threshold"]; ok { + if f, ok := asFloat64(v); !ok || f < 0.001 || f > 1.0 { + return &ValidationError{Key: "delta_rms_threshold", Reason: "must be between 0.001 and 1.0"} + } + } + + // Validate tau_s: 1-600 seconds + if v, ok := settings["tau_s"]; ok { + if f, ok := asFloat64(v); !ok || f < 1 || f > 600 { + return &ValidationError{Key: "tau_s", Reason: "must be between 1 and 600"} + } + } + + // Validate fresnel_decay: 1.0-4.0 + if v, ok := settings["fresnel_decay"]; ok { + if f, ok := asFloat64(v); !ok || f < 1.0 || f > 4.0 { + return &ValidationError{Key: "fresnel_decay", Reason: "must be between 1.0 and 4.0"} + } + } + + // Validate n_subcarriers: 8-47 + if v, ok := settings["n_subcarriers"]; ok { + if f, ok := asFloat64(v); !ok || f < 8 || f > 47 { + return &ValidationError{Key: "n_subcarriers", Reason: "must be between 8 and 47"} + } + } + + // Validate breathing_sensitivity: 0.001-0.1 + if v, ok := settings["breathing_sensitivity"]; ok { + if f, ok := asFloat64(v); !ok || f < 0.001 || f > 0.1 { + return &ValidationError{Key: "breathing_sensitivity", Reason: "must be between 0.001 and 0.1"} + } + } + + // Validate motion_threshold: 0.01-0.5 + if v, ok := settings["motion_threshold"]; ok { + if f, ok := asFloat64(v); !ok || f < 0.01 || f > 0.5 { + return &ValidationError{Key: "motion_threshold", Reason: "must be between 0.01 and 0.5"} + } + } + + // Validate dwell_seconds: 1-3600 + if v, ok := settings["dwell_seconds"]; ok { + if f, ok := asFloat64(v); !ok || f < 1 || f > 3600 { + return &ValidationError{Key: "dwell_seconds", Reason: "must be between 1 and 3600"} + } + } + + // Validate vacant_seconds: 10-7200 + if v, ok := settings["vacant_seconds"]; ok { + if f, ok := asFloat64(v); !ok || f < 10 || f > 7200 { + return &ValidationError{Key: "vacant_seconds", Reason: "must be between 10 and 7200"} + } + } + + // Validate max_tracked_blobs: 1-100 + if v, ok := settings["max_tracked_blobs"]; ok { + if f, ok := asFloat64(v); !ok || f < 1 || f > 100 { + return &ValidationError{Key: "max_tracked_blobs", Reason: "must be between 1 and 100"} + } + } + + // Validate replay_retention_hours: 1-168 (1 hour to 1 week) + if v, ok := settings["replay_retention_hours"]; ok { + if f, ok := asFloat64(v); !ok || f < 1 || f > 168 { + return &ValidationError{Key: "replay_retention_hours", Reason: "must be between 1 and 168"} + } + } + + // Validate replay_max_mb: 10-10000 + if v, ok := settings["replay_max_mb"]; ok { + if f, ok := asFloat64(v); !ok || f < 10 || f > 10000 { + return &ValidationError{Key: "replay_max_mb", Reason: "must be between 10 and 10000"} + } + } + + // Validate security_mode: boolean + if v, ok := settings["security_mode"]; ok { + if _, ok := v.(bool); !ok { + return &ValidationError{Key: "security_mode", Reason: "must be a boolean"} + } + } + + // Validate events_archive_days: 1-365 + if v, ok := settings["events_archive_days"]; ok { + if f, ok := asFloat64(v); !ok || f < 1 || f > 365 { + return &ValidationError{Key: "events_archive_days", Reason: "must be between 1 and 365"} + } + } + + return nil } -func writeJSON(w http.ResponseWriter, v interface{}) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(v) //nolint:errcheck +// asFloat64 attempts to convert a value to float64. +// JSON numbers are unmarshaled as float64, but integers may be unmarshaled +// differently depending on the JSON decoder. +func asFloat64(v interface{}) (float64, bool) { + switch val := v.(type) { + case float64: + return val, true + case float32: + return float64(val), true + case int: + return float64(val), true + case int64: + return float64(val), true + case int32: + return float64(val), true + default: + return 0, false + } +} + +// ValidationError represents a validation error for a specific setting. +type ValidationError struct { + Key string + Reason string +} + +func (e *ValidationError) Error() string { + return e.Key + ": " + e.Reason } diff --git a/mothership/internal/api/settings_test.go b/mothership/internal/api/settings_test.go new file mode 100644 index 0000000..f5d5bac --- /dev/null +++ b/mothership/internal/api/settings_test.go @@ -0,0 +1,715 @@ +// Package api provides tests for the settings API handler. +package api + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/go-chi/chi" + _ "modernc.org/sqlite" +) + +// TestSettingsHandler tests the settings handler with table-driven tests. +func TestSettingsHandler(t *testing.T) { + // Create a temporary database + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create settings table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT 0 + ) + `) + if err != nil { + t.Fatalf("Failed to create settings table: %v", err) + } + + // Insert some default settings + _, err = db.Exec(` + INSERT INTO settings (key, value_json, updated_at) VALUES + ('fusion_rate_hz', '10', 1000), + ('delta_rms_threshold', '0.02', 1000) + `) + if err != nil { + t.Fatalf("Failed to insert default settings: %v", err) + } + + handler := NewSettingsHandler(db) + + tests := []struct { + name string + method string + path string + body interface{} + expectedStatus int + checkResponse func(*testing.T, *httptest.ResponseRecorder) + }{ + { + name: "GET settings returns all settings with defaults", + method: "GET", + path: "/api/settings", + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { + var settings map[string]interface{} + if err := json.NewDecoder(rr.Body).Decode(&settings); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + // Check that we have values from DB and defaults + if v, ok := settings["fusion_rate_hz"]; !ok || v.(float64) != 10.0 { + t.Errorf("Expected fusion_rate_hz=10.0, got %v", v) + } + if v, ok := settings["delta_rms_threshold"]; !ok || v.(float64) != 0.02 { + t.Errorf("Expected delta_rms_threshold=0.02, got %v", v) + } + if v, ok := settings["grid_cell_m"]; !ok || v.(float64) != 0.2 { + t.Errorf("Expected default grid_cell_m=0.2, got %v", v) + } + if v, ok := settings["tau_s"]; !ok || v.(float64) != 30.0 { + t.Errorf("Expected default tau_s=30.0, got %v", v) + } + }, + }, + { + name: "POST single setting update", + method: "POST", + path: "/api/settings", + body: map[string]interface{}{ + "fusion_rate_hz": 15.0, + }, + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { + var settings map[string]interface{} + if err := json.NewDecoder(rr.Body).Decode(&settings); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if v, ok := settings["fusion_rate_hz"]; !ok || v.(float64) != 15.0 { + t.Errorf("Expected fusion_rate_hz=15.0, got %v", v) + } + }, + }, + { + name: "POST multiple settings update", + method: "POST", + path: "/api/settings", + body: map[string]interface{}{ + "fusion_rate_hz": 12.0, + "delta_rms_threshold": 0.03, + "grid_cell_m": 0.15, + "max_tracked_blobs": 30, + }, + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { + var settings map[string]interface{} + if err := json.NewDecoder(rr.Body).Decode(&settings); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if v, ok := settings["fusion_rate_hz"]; !ok || v.(float64) != 12.0 { + t.Errorf("Expected fusion_rate_hz=12.0, got %v", v) + } + if v, ok := settings["delta_rms_threshold"]; !ok || v.(float64) != 0.03 { + t.Errorf("Expected delta_rms_threshold=0.03, got %v", v) + } + if v, ok := settings["grid_cell_m"]; !ok || v.(float64) != 0.15 { + t.Errorf("Expected grid_cell_m=0.15, got %v", v) + } + if v, ok := settings["max_tracked_blobs"]; !ok { + t.Errorf("Expected max_tracked_blobs=30, got %v", v) + } + }, + }, + { + name: "PATCH settings (same as POST)", + method: "PATCH", + path: "/api/settings", + body: map[string]interface{}{ + "security_mode": true, + }, + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { + var settings map[string]interface{} + if err := json.NewDecoder(rr.Body).Decode(&settings); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if v, ok := settings["security_mode"]; !ok || v.(bool) != true { + t.Errorf("Expected security_mode=true, got %v", v) + } + }, + }, + { + name: "POST invalid fusion_rate_hz (too high)", + method: "POST", + path: "/api/settings", + body: map[string]interface{}{ + "fusion_rate_hz": 100.0, + }, + expectedStatus: http.StatusBadRequest, + checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { + var errResp map[string]string + if err := json.NewDecoder(rr.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + + if errResp["error"] == "" { + t.Error("Expected error message, got empty string") + } + }, + }, + { + name: "POST invalid delta_rms_threshold (negative)", + method: "POST", + path: "/api/settings", + body: map[string]interface{}{ + "delta_rms_threshold": -0.01, + }, + expectedStatus: http.StatusBadRequest, + checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { + var errResp map[string]string + if err := json.NewDecoder(rr.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + + if errResp["error"] == "" { + t.Error("Expected error message, got empty string") + } + }, + }, + { + name: "POST invalid grid_cell_m (too small)", + method: "POST", + path: "/api/settings", + body: map[string]interface{}{ + "grid_cell_m": 0.01, + }, + expectedStatus: http.StatusBadRequest, + checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { + var errResp map[string]string + if err := json.NewDecoder(rr.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + + if errResp["error"] == "" { + t.Error("Expected error message, got empty string") + } + }, + }, + { + name: "POST invalid n_subcarriers (out of range)", + method: "POST", + path: "/api/settings", + body: map[string]interface{}{ + "n_subcarriers": 50, + }, + expectedStatus: http.StatusBadRequest, + checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { + var errResp map[string]string + if err := json.NewDecoder(rr.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + + if errResp["error"] == "" { + t.Error("Expected error message, got empty string") + } + }, + }, + { + name: "POST invalid max_tracked_blobs (too high)", + method: "POST", + path: "/api/settings", + body: map[string]interface{}{ + "max_tracked_blobs": 200, + }, + expectedStatus: http.StatusBadRequest, + checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { + var errResp map[string]string + if err := json.NewDecoder(rr.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + + if errResp["error"] == "" { + t.Error("Expected error message, got empty string") + } + }, + }, + { + name: "POST invalid security_mode (not boolean)", + method: "POST", + path: "/api/settings", + body: map[string]interface{}{ + "security_mode": "true", + }, + expectedStatus: http.StatusBadRequest, + checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { + var errResp map[string]string + if err := json.NewDecoder(rr.Body).Decode(&errResp); err != nil { + t.Fatalf("Failed to decode error response: %v", err) + } + + if errResp["error"] == "" { + t.Error("Expected error message, got empty string") + } + }, + }, + { + name: "GET settings after update persists changes", + method: "GET", + path: "/api/settings", + expectedStatus: http.StatusOK, + checkResponse: func(t *testing.T, rr *httptest.ResponseRecorder) { + // This test should run after the POST test above + // Just verify we can get settings without error + var settings map[string]interface{} + if err := json.NewDecoder(rr.Body).Decode(&settings); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(settings) == 0 { + t.Error("Expected at least some settings") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := chi.NewRouter() + handler.RegisterRoutes(r) + + var body []byte + if tt.body != nil { + var err error + body, err = json.Marshal(tt.body) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + } + + req := httptest.NewRequest(tt.method, tt.path, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, rr.Code) + t.Logf("Response body: %s", rr.Body.String()) + } + + if tt.checkResponse != nil { + tt.checkResponse(t, rr) + } + }) + } +} + +// TestSettingsGetSingle tests the GetSingle method. +func TestSettingsGetSingle(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create settings table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT 0 + ) + `) + if err != nil { + t.Fatalf("Failed to create settings table: %v", err) + } + + handler := NewSettingsHandler(db) + + tests := []struct { + name string + key string + wantExists bool + checkValue func(*testing.T, interface{}) + }{ + { + name: "cached value exists", + key: "fusion_rate_hz", + wantExists: true, + checkValue: func(t *testing.T, v interface{}) { + if f, ok := v.(float64); !ok || f != 10.0 { + t.Errorf("Expected 10.0, got %v", v) + } + }, + }, + { + name: "default value exists", + key: "grid_cell_m", + wantExists: true, + checkValue: func(t *testing.T, v interface{}) { + if f, ok := v.(float64); !ok || f != 0.2 { + t.Errorf("Expected 0.2, got %v", v) + } + }, + }, + { + name: "unknown key doesn't exist", + key: "unknown_key_xyz", + wantExists: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + val, exists := handler.GetSingle(tt.key) + if exists != tt.wantExists { + t.Errorf("GetSingle(%q) exists=%v, want %v", tt.key, exists, tt.wantExists) + } + if tt.checkValue != nil && exists { + tt.checkValue(t, val) + } + }) + } +} + +// TestSettingsSetAndGet tests setting and getting a single value. +func TestSettingsSetAndGet(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create settings table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT 0 + ) + `) + if err != nil { + t.Fatalf("Failed to create settings table: %v", err) + } + + handler := NewSettingsHandler(db) + + // Set a new value + err = handler.Set("custom_key", "custom_value") + if err != nil { + t.Fatalf("Failed to set value: %v", err) + } + + // Get it back + val, exists := handler.GetSingle("custom_key") + if !exists { + t.Fatal("Value should exist after Set") + } + if val != "custom_value" { + t.Errorf("Expected 'custom_value', got %v", val) + } + + // Verify it's in the database + var dbVal string + err = db.QueryRow("SELECT value_json FROM settings WHERE key = ?", "custom_key").Scan(&dbVal) + if err != nil { + t.Fatalf("Failed to query database: %v", err) + } + if dbVal != `"custom_value"` { // JSON encoded string + t.Errorf("Expected '\"custom_value\"' in database, got %s", dbVal) + } +} + +// TestSettingsDelete tests deleting a setting. +func TestSettingsDelete(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Create settings table + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT 0 + ) + `) + if err != nil { + t.Fatalf("Failed to create settings table: %v", err) + } + + handler := NewSettingsHandler(db) + + // Set a value + err = handler.Set("to_delete", "value") + if err != nil { + t.Fatalf("Failed to set value: %v", err) + } + + // Verify it exists + _, exists := handler.GetSingle("to_delete") + if !exists { + t.Fatal("Value should exist before delete") + } + + // Delete it + err = handler.Delete("to_delete") + if err != nil { + t.Fatalf("Failed to delete: %v", err) + } + + // Verify it's gone from cache + _, exists = handler.GetSingle("to_delete") + if exists { + t.Fatal("Value should not exist after delete") + } + + // Verify it's gone from database + var count int + err = db.QueryRow("SELECT COUNT(*) FROM settings WHERE key = ?", "to_delete").Scan(&count) + if err != nil { + t.Fatalf("Failed to query database: %v", err) + } + if count != 0 { + t.Errorf("Expected 0 rows in database, got %d", count) + } +} + +// TestValidateSettings tests the settings validation. +func TestValidateSettings(t *testing.T) { + tests := []struct { + name string + settings map[string]interface{} + wantErr bool + errKey string + }{ + { + name: "all valid settings", + settings: map[string]interface{}{ + "fusion_rate_hz": 10.0, + "grid_cell_m": 0.2, + "delta_rms_threshold": 0.02, + "tau_s": 30.0, + "fresnel_decay": 2.0, + "n_subcarriers": 16, + "breathing_sensitivity": 0.005, + "motion_threshold": 0.05, + "dwell_seconds": 30, + "vacant_seconds": 300, + "max_tracked_blobs": 20, + "security_mode": true, + }, + wantErr: false, + }, + { + name: "fusion_rate_hz too high", + settings: map[string]interface{}{ + "fusion_rate_hz": 100.0, + }, + wantErr: true, + errKey: "fusion_rate_hz", + }, + { + name: "grid_cell_m too small", + settings: map[string]interface{}{ + "grid_cell_m": 0.01, + }, + wantErr: true, + errKey: "grid_cell_m", + }, + { + name: "n_subcarriers out of range", + settings: map[string]interface{}{ + "n_subcarriers": 50, + }, + wantErr: true, + errKey: "n_subcarriers", + }, + { + name: "security_mode not boolean", + settings: map[string]interface{}{ + "security_mode": "true", + }, + wantErr: true, + errKey: "security_mode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateSettings(tt.settings) + if (err != nil) != tt.wantErr { + t.Errorf("validateSettings() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil && tt.errKey != "" { + if ve, ok := err.(*ValidationError); ok { + if ve.Key != tt.errKey { + t.Errorf("Expected error key %q, got %q", tt.errKey, ve.Key) + } + } else { + t.Errorf("Expected ValidationError, got %T", err) + } + } + }) + } +} + +// TestAsFloat64 tests the asFloat64 helper. +func TestAsFloat64(t *testing.T) { + tests := []struct { + name string + input interface{} + want float64 + wantBool bool + }{ + {"float64", 3.14, 3.14, true}, + {"float32", float32(3.14), 3.14, true}, + {"int", 42, 42.0, true}, + {"int64", int64(42), 42.0, true}, + {"int32", int32(42), 42.0, true}, + {"string", "42", 0, false}, + {"bool", true, 0, false}, + {"nil", nil, 0, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := asFloat64(tt.input) + if ok != tt.wantBool { + t.Errorf("asFloat64(%v) ok = %v, want %v", tt.input, ok, tt.wantBool) + } + if ok && got != tt.want { + t.Errorf("asFloat64(%v) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +// TestDefaultSettings tests that default settings are defined. +func TestDefaultSettings(t *testing.T) { + requiredDefaults := []string{ + "fusion_rate_hz", + "grid_cell_m", + "delta_rms_threshold", + "tau_s", + "fresnel_decay", + "n_subcarriers", + "breathing_sensitivity", + "motion_threshold", + "dwell_seconds", + "vacant_seconds", + "max_tracked_blobs", + "replay_retention_hours", + "replay_max_mb", + "security_mode", + "events_archive_days", + } + + for _, key := range requiredDefaults { + if _, exists := defaultSettings[key]; !exists { + t.Errorf("Missing default setting: %s", key) + } + } +} + +// TestSettingsPersistence tests that settings persist across handler reloads. +func TestSettingsPersistence(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + // First handler + db1, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + + _, err = db1.Exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT 0 + ) + `) + if err != nil { + db1.Close() + t.Fatalf("Failed to create settings table: %v", err) + } + + handler1 := NewSettingsHandler(db1) + err = handler1.Set("persistent_key", "persistent_value") + if err != nil { + db1.Close() + t.Fatalf("Failed to set value: %v", err) + } + db1.Close() + + // Second handler (simulates restart) + db2, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatalf("Failed to reopen database: %v", err) + } + defer db2.Close() + + handler2 := NewSettingsHandler(db2) + val, exists := handler2.GetSingle("persistent_key") + if !exists { + t.Fatal("Value should persist across handler reloads") + } + if val != "persistent_value" { + t.Errorf("Expected 'persistent_value', got %v", val) + } +} + +// TestNewSettingsHandlerLoadFailure tests that handler still works even if load fails. +func TestNewSettingsHandlerLoadFailure(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + // Create a database without the settings table + db, err := sql.Open("sqlite", dbPath+"?mode=ro") // Read-only to prevent table creation + if err != nil { + t.Fatalf("Failed to open database: %v", err) + } + defer db.Close() + + // Handler should still be created (load fails but doesn't crash) + handler := NewSettingsHandler(db) + + // Get should return defaults even though load failed + settings := handler.Get() + if len(settings) == 0 { + t.Error("Expected default settings even with failed load") + } +} + +// cleanTestFile removes a test file if it exists. +func cleanTestFile(path string) { + os.Remove(path) +} diff --git a/mothership/internal/api/utils.go b/mothership/internal/api/utils.go new file mode 100644 index 0000000..3c3b2f8 --- /dev/null +++ b/mothership/internal/api/utils.go @@ -0,0 +1,30 @@ +// Package api provides REST API handlers for Spaxel. +package api + +import ( + "encoding/json" + "log" + "net/http" +) + +// writeJSON writes a JSON response with the given status code. +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("[ERROR] Failed to encode JSON response: %v", err) + } +} + +// writeJSONData writes a JSON response without setting status (assumes status already set). +func writeJSONData(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("[ERROR] Failed to encode JSON response: %v", err) + } +} + +// writeJSONError writes a JSON error response. +func writeJSONError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +}