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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-07 09:04:14 -04:00
parent 827d35002b
commit 72b340d03a
3 changed files with 1052 additions and 155 deletions

View file

@ -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
}

View file

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

View file

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