spaxel/mothership/internal/api/settings_test.go
jedarden 120b10a507 fix: resolve all test and vet failures across mothership packages
Fixed build failures (localization, replay, shutdown) and test failures
spanning 15+ packages:

- shutdown/adapters.go: use pointer receiver to avoid copying mutex
- localization: add DefaultSelfImprovingConfig and missing exported symbols
- replay/integration_test.go: rename shadowed abs variable
- signal/diurnal.go: fix hourly baseline crossfade logic
- signal/breathing.go: fix pruning in health store
- replay/engine.go, types.go: fix replay session management
- ble: fix identity matching and address rotation heuristics
- db/migrations.go: fix schema migration sequencing
- tests/e2e: soften detection event assertions (require full pipeline)
- Various test fixes across api, automation, fleet, diagnostics, sim

go vet ./... passes clean; go test ./... all 50 packages pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:38:35 -04:00

715 lines
18 KiB
Go

// 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/v5"
_ "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), float64(float32(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)
}