spaxel/mothership/internal/api/baseline_test.go
jedarden 21829b9738
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
api: add GET /api/baseline and POST /api/baseline/capture endpoints
Implements baseline read/capture endpoints for the dashboard. GET /api/baseline
returns [{link_id, snapshot_time_ms, confidence, n_sub}] for all links.
POST /api/baseline/capture starts a 60s quiet-room capture with optional
links filter.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 13:50:31 -04:00

311 lines
8.9 KiB
Go

// Package api provides tests for baseline API handlers.
package api
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
// TestBaselineHandler_ListBaselines tests the GET /api/baseline endpoint.
func TestBaselineHandler_ListBaselines(t *testing.T) {
t.Run("empty database returns empty list", func(t *testing.T) {
db := setupBaselineTestDB(t)
defer db.Close()
handler := NewBaselineHandler(db)
req := httptest.NewRequest(http.MethodGet, "/api/baseline", nil)
w := httptest.NewRecorder()
handler.listBaselines(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp []BaselineEntry
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(resp) != 0 {
t.Errorf("expected empty list, got %d entries", len(resp))
}
})
t.Run("returns most recent baseline per link", func(t *testing.T) {
db := setupBaselineTestDB(t)
defer db.Close()
handler := NewBaselineHandler(db)
// Insert test baselines - multiple snapshots for same link
now := int64(1712345678000) // 2024-04-04 12:34:38 UTC
baselines := []struct {
linkID string
capturedAt int64
confidence float64
nSub int
amplitude []float32
phase []float32
}{
{"AA:BB:CC:DD:EE:FF", now - 10000, 0.8, 64, []float32{1.0, 2.0}, []float32{0.1, 0.2}},
{"AA:BB:CC:DD:EE:FF", now, 0.9, 64, []float32{1.1, 2.1}, []float32{0.11, 0.21}}, // Most recent
{"11:22:33:44:55:66", now - 5000, 0.7, 64, []float32{0.5, 1.5}, []float32{0.05, 0.15}},
}
for _, b := range baselines {
_, err := db.Exec(`
INSERT INTO baselines (link_id, captured_at, n_sub, amplitude, phase, confidence)
VALUES (?, ?, ?, ?, ?, ?)
`, b.linkID, b.capturedAt, b.nSub, float32SliceToBytes(b.amplitude), float32SliceToBytes(b.phase), b.confidence)
if err != nil {
t.Fatalf("failed to insert baseline: %v", err)
}
}
req := httptest.NewRequest(http.MethodGet, "/api/baseline", nil)
w := httptest.NewRecorder()
handler.listBaselines(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp []BaselineEntry
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if len(resp) != 2 {
t.Errorf("expected 2 baseline entries (one per link), got %d", len(resp))
}
// Verify the most recent snapshot is returned for the first link
var firstLink *BaselineEntry
for _, b := range resp {
if b.LinkID == "AA:BB:CC:DD:EE:FF" {
firstLink = &b
break
}
}
if firstLink == nil {
t.Fatal("first link not found in response")
}
if firstLink.SnapshotTime != now {
t.Errorf("expected most recent snapshot time %d, got %d", now, firstLink.SnapshotTime)
}
if firstLink.Confidence != 0.9 {
t.Errorf("expected confidence 0.9, got %f", firstLink.Confidence)
}
})
}
// TestBaselineHandler_CaptureBaseline tests the POST /api/baseline/capture endpoint.
func TestBaselineHandler_CaptureBaseline(t *testing.T) {
t.Run("capture with no existing links returns empty response", func(t *testing.T) {
db := setupBaselineTestDB(t)
defer db.Close()
handler := NewBaselineHandler(db)
req := httptest.NewRequest(http.MethodPost, "/api/baseline/capture", nil)
w := httptest.NewRecorder()
handler.captureBaseline(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d", w.Code)
}
var resp captureResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.OK {
t.Errorf("expected ok=true, got %v", resp.OK)
}
if resp.LinksCaptured != 0 {
t.Errorf("expected 0 links captured, got %d", resp.LinksCaptured)
}
if resp.Message == "" {
t.Errorf("expected message about no links found")
}
})
t.Run("capture all links when no specific links requested", func(t *testing.T) {
db := setupBaselineTestDB(t)
defer db.Close()
handler := NewBaselineHandler(db)
// Insert test baselines
now := int64(1712345678000)
_, err := db.Exec(`
INSERT INTO baselines (link_id, captured_at, n_sub, amplitude, phase, confidence)
VALUES (?, ?, ?, ?, ?, ?)
`, "AA:BB:CC:DD:EE:FF", now, 64, float32SliceToBytes([]float32{1.0}), float32SliceToBytes([]float32{0.1}), 0.8)
if err != nil {
t.Fatalf("failed to insert baseline: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/baseline/capture", nil)
w := httptest.NewRecorder()
handler.captureBaseline(w, req)
if w.Code != http.StatusAccepted {
t.Errorf("expected status 202, got %d", w.Code)
}
var resp captureResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if !resp.OK {
t.Errorf("expected ok=true, got %v", resp.OK)
}
if resp.LinksCaptured != 1 {
t.Errorf("expected 1 link captured, got %d", resp.LinksCaptured)
}
if len(resp.Links) != 1 || resp.Links[0] != "AA:BB:CC:DD:EE:FF" {
t.Errorf("expected links=[AA:BB:CC:DD:EE:FF], got %v", resp.Links)
}
// Verify capture marker was inserted
var count int
err = db.QueryRow("SELECT COUNT(*) FROM baselines WHERE link_id = ? AND amplitude = X'' AND phase = X''", "AA:BB:CC:DD:EE:FF").Scan(&count)
if err != nil {
t.Fatalf("failed to query capture marker: %v", err)
}
if count != 1 {
t.Errorf("expected 1 capture marker, got %d", count)
}
})
t.Run("capture specific links when requested", func(t *testing.T) {
db := setupBaselineTestDB(t)
defer db.Close()
handler := NewBaselineHandler(db)
// Insert test baselines for two links
now := int64(1712345678000)
for _, linkID := range []string{"AA:BB:CC:DD:EE:FF", "11:22:33:44:55:66"} {
_, err := db.Exec(`
INSERT INTO baselines (link_id, captured_at, n_sub, amplitude, phase, confidence)
VALUES (?, ?, ?, ?, ?, ?)
`, linkID, now, 64, float32SliceToBytes([]float32{1.0}), float32SliceToBytes([]float32{0.1}), 0.8)
if err != nil {
t.Fatalf("failed to insert baseline: %v", err)
}
}
// Request capture for only the first link
body := `{"links": ["AA:BB:CC:DD:EE:FF"]}`
req := httptest.NewRequest(http.MethodPost, "/api/baseline/capture", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.captureBaseline(w, req)
if w.Code != http.StatusAccepted {
t.Errorf("expected status 202, got %d", w.Code)
}
var resp captureResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("failed to decode response: %v", err)
}
if resp.LinksCaptured != 1 {
t.Errorf("expected 1 link captured, got %d", resp.LinksCaptured)
}
if len(resp.Links) != 1 || resp.Links[0] != "AA:BB:CC:DD:EE:FF" {
t.Errorf("expected links=[AA:BB:CC:DD:EE:FF], got %v", resp.Links)
}
})
t.Run("invalid request body is rejected", func(t *testing.T) {
db := setupBaselineTestDB(t)
defer db.Close()
handler := NewBaselineHandler(db)
req := httptest.NewRequest(http.MethodPost, "/api/baseline/capture", bytes.NewBufferString("invalid json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.captureBaseline(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
}
// setupBaselineTestDB creates an in-memory SQLite database with the baselines table.
func setupBaselineTestDB(t *testing.T) *sql.DB {
t.Helper()
// Create a temporary directory for the database
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test.db")
// Open database with WAL mode
dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("failed to open test database: %v", err)
}
// Create the baselines table
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS baselines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
link_id TEXT NOT NULL,
captured_at INTEGER NOT NULL,
n_sub INTEGER NOT NULL,
amplitude BLOB NOT NULL,
phase BLOB NOT NULL,
confidence REAL NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_baselines_link ON baselines(link_id, captured_at DESC);
`)
if err != nil {
db.Close()
t.Fatalf("failed to create baselines table: %v", err)
}
// Run checkpoint to finalize WAL
if _, err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
db.Close()
t.Fatalf("failed to checkpoint: %v", err)
}
return db
}
// float32SliceToBytes converts a []float32 to a byte slice for BLOB storage.
func float32SliceToBytes(values []float32) []byte {
buf := make([]byte, len(values)*4)
for i, v := range values {
// Little-endian encoding
buf[i*4] = byte(uint32(v) & 0xFF)
buf[i*4+1] = byte((uint32(v) >> 8) & 0xFF)
buf[i*4+2] = byte((uint32(v) >> 16) & 0xFF)
buf[i*4+3] = byte((uint32(v) >> 24) & 0xFF)
}
return buf
}