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:
parent
827d35002b
commit
72b340d03a
3 changed files with 1052 additions and 155 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
715
mothership/internal/api/settings_test.go
Normal file
715
mothership/internal/api/settings_test.go
Normal 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)
|
||||
}
|
||||
30
mothership/internal/api/utils.go
Normal file
30
mothership/internal/api/utils.go
Normal 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})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue