Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Implements IO-5 acceptance test which verifies:
- A person can be created via POST /api/people
- A simulated BLE device (from spaxel-sim) is discovered
- The BLE device can be assigned to a person via PUT /api/ble/devices/{mac}
- The device registration is persisted correctly with person_id, person_name, and person_color
Also fixes a bug in mothership/cmd/mothership/main.go where
SetBriefingProvider was called before dashboardHub was initialized,
causing a nil pointer dereference on startup. The call is now
made after the hub is created.
Closes: bf-3cagn (IO-5: BLE device-identity onboarding test)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
731 lines
21 KiB
Go
731 lines
21 KiB
Go
// Package acceptance provides integration tests for Spaxel acceptance scenarios.
|
|
// These tests use the spaxel-sim CLI as a test harness to verify the system
|
|
// meets its acceptance criteria.
|
|
//
|
|
// To run these tests:
|
|
// go test -v ./test/acceptance/...
|
|
//
|
|
// Tests require:
|
|
// - The mothership binary to be built and available
|
|
// - The spaxel-sim binary to be built and in PATH
|
|
package acceptance
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
defaultMothershipURL = "http://localhost:8080"
|
|
defaultMothershipWS = "ws://localhost:8080/ws/node"
|
|
healthTimeout = 30 * time.Second
|
|
apiTimeout = 10 * time.Second
|
|
nodeOnlineTimeout = 30 * time.Second
|
|
simStartupTimeout = 20 * time.Second
|
|
)
|
|
|
|
// TestMain runs all acceptance tests in sequence.
|
|
func TestMain(m *testing.M) {
|
|
// Check if integration test mode is enabled
|
|
if os.Getenv("SPAXEL_INTEGRATION_TEST") != "1" && os.Getenv("ACCEPTANCE_TEST") != "1" {
|
|
// Skip tests by default unless explicitly enabled
|
|
fmt.Println("Skipping acceptance tests (set SPAXEL_INTEGRATION_TEST=1 or ACCEPTANCE_TEST=1 to run)")
|
|
os.Exit(0)
|
|
}
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
// TestHarness manages the acceptance test lifecycle.
|
|
type TestHarness struct {
|
|
MothershipCmd *exec.Cmd
|
|
SimulatorCmd *exec.Cmd
|
|
WebhookServer *http.Server
|
|
MothershipURL string
|
|
APIURL string
|
|
DataDir string
|
|
t *testing.T
|
|
stderrBuf *bytes.Buffer
|
|
webhookCalled bool
|
|
webhookMu sync.Mutex
|
|
}
|
|
|
|
// NewTestHarness creates a new test harness.
|
|
func NewTestHarness(t *testing.T) *TestHarness {
|
|
dataDir, err := os.MkdirTemp("", "spaxel-acceptance-*")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
|
|
return &TestHarness{
|
|
MothershipURL: defaultMothershipURL,
|
|
APIURL: defaultMothershipURL,
|
|
DataDir: dataDir,
|
|
t: t,
|
|
stderrBuf: &bytes.Buffer{},
|
|
}
|
|
}
|
|
|
|
// Start starts the mothership process.
|
|
func (h *TestHarness) Start(ctx context.Context) error {
|
|
// Build mothership if needed
|
|
mothershipBin := "/tmp/spaxel-mothership-acceptance"
|
|
if _, err := os.Stat(mothershipBin); os.IsNotExist(err) {
|
|
goCmd := findGoCmd()
|
|
buildCmd := exec.CommandContext(ctx, goCmd, "build", "-o", mothershipBin, "./mothership/cmd/mothership")
|
|
buildCmd.Dir = repoRoot()
|
|
if output, err := buildCmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("failed to build mothership: %w: %s", err, string(output))
|
|
}
|
|
}
|
|
|
|
// Start mothership
|
|
h.MothershipCmd = exec.CommandContext(ctx, mothershipBin)
|
|
h.MothershipCmd.Env = append(os.Environ(),
|
|
"SPAXEL_BIND_ADDR=127.0.0.1:8080",
|
|
"SPAXEL_DATA_DIR="+h.DataDir,
|
|
"SPAXEL_LOG_LEVEL=info",
|
|
"TZ=UTC",
|
|
)
|
|
h.MothershipCmd.Stdout = io.Discard
|
|
h.MothershipCmd.Stderr = io.MultiWriter(os.Stderr, h.stderrBuf)
|
|
|
|
if err := h.MothershipCmd.Start(); err != nil {
|
|
return fmt.Errorf("failed to start mothership: %w", err)
|
|
}
|
|
|
|
h.t.Logf("Mothership started (PID: %d, DataDir: %s)", h.MothershipCmd.Process.Pid, h.DataDir)
|
|
|
|
// Wait for health check
|
|
if err := h.WaitForHealth(ctx); err != nil {
|
|
return fmt.Errorf("health check failed: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stop stops all processes.
|
|
func (h *TestHarness) Stop() {
|
|
if h.MothershipCmd != nil && h.MothershipCmd.Process != nil {
|
|
h.MothershipCmd.Process.Signal(os.Interrupt)
|
|
h.MothershipCmd.Wait()
|
|
}
|
|
if h.SimulatorCmd != nil && h.SimulatorCmd.Process != nil {
|
|
h.SimulatorCmd.Process.Kill()
|
|
h.SimulatorCmd.Wait()
|
|
}
|
|
if h.WebhookServer != nil {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
h.WebhookServer.Shutdown(ctx)
|
|
}
|
|
// Clean up data directory
|
|
if h.DataDir != "" {
|
|
os.RemoveAll(h.DataDir)
|
|
}
|
|
}
|
|
|
|
// WaitForHealth waits for the /healthz endpoint to return ok.
|
|
func (h *TestHarness) WaitForHealth(ctx context.Context) error {
|
|
ctx, cancel := context.WithTimeout(ctx, healthTimeout)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(500 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", h.APIURL+"/healthz", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
var health map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
|
|
resp.Body.Close()
|
|
continue
|
|
}
|
|
resp.Body.Close()
|
|
|
|
if health["status"] == "ok" {
|
|
h.t.Logf("Mothership healthy")
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// StartWebhookServer starts a webhook server for receiving alerts.
|
|
func (h *TestHarness) StartWebhookServer() string {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/webhook", func(w http.ResponseWriter, r *http.Request) {
|
|
h.webhookMu.Lock()
|
|
h.webhookCalled = true
|
|
h.webhookMu.Unlock()
|
|
|
|
var payload map[string]interface{}
|
|
json.NewDecoder(r.Body).Decode(&payload)
|
|
h.t.Logf("Webhook received: %+v", payload)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
|
})
|
|
|
|
// Find an available port
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
h.t.Fatalf("Failed to start webhook server: %v", err)
|
|
}
|
|
|
|
h.WebhookServer = &http.Server{
|
|
Handler: mux,
|
|
ReadTimeout: 10 * time.Second,
|
|
WriteTimeout: 10 * time.Second,
|
|
}
|
|
|
|
go func() {
|
|
h.WebhookServer.Serve(listener)
|
|
}()
|
|
|
|
webhookURL := fmt.Sprintf("http://127.0.0.1:%d/webhook", listener.Addr().(*net.TCPAddr).Port)
|
|
h.t.Logf("Webhook server started on %s", webhookURL)
|
|
|
|
return webhookURL
|
|
}
|
|
|
|
// WebhookCalled returns true if the webhook was called.
|
|
func (h *TestHarness) WebhookCalled() bool {
|
|
h.webhookMu.Lock()
|
|
defer h.webhookMu.Unlock()
|
|
return h.webhookCalled
|
|
}
|
|
|
|
// RunSimulator starts the spaxel-sim simulator.
|
|
func (h *TestHarness) RunSimulator(ctx context.Context, args []string) error {
|
|
// Build simulator if needed
|
|
simBin := "/tmp/spaxel-sim-acceptance"
|
|
if _, err := os.Stat(simBin); os.IsNotExist(err) {
|
|
goCmd := findGoCmd()
|
|
buildCmd := exec.CommandContext(ctx, goCmd, "build", "-o", simBin, "./mothership/cmd/sim")
|
|
buildCmd.Dir = repoRoot()
|
|
if output, err := buildCmd.CombinedOutput(); err != nil {
|
|
return fmt.Errorf("failed to build simulator: %w: %s", err, string(output))
|
|
}
|
|
}
|
|
|
|
// Build default args
|
|
defaultArgs := []string{"--mothership", defaultMothershipWS}
|
|
allArgs := append(defaultArgs, args...)
|
|
|
|
h.SimulatorCmd = exec.CommandContext(ctx, simBin, allArgs...)
|
|
h.SimulatorCmd.Stdout = io.MultiWriter(os.Stderr, h.stderrBuf)
|
|
h.SimulatorCmd.Stderr = io.MultiWriter(os.Stderr, h.stderrBuf)
|
|
|
|
if err := h.SimulatorCmd.Start(); err != nil {
|
|
return fmt.Errorf("failed to start simulator: %w", err)
|
|
}
|
|
|
|
h.t.Logf("Simulator started with args: %v", allArgs)
|
|
return nil
|
|
}
|
|
|
|
// GetNodes fetches the list of nodes from /api/nodes.
|
|
func (h *TestHarness) GetNodes(ctx context.Context) ([]map[string]interface{}, error) {
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", h.APIURL+"/api/nodes", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var nodes []map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&nodes); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return nodes, nil
|
|
}
|
|
|
|
// GetEvents fetches events from /api/events.
|
|
func (h *TestHarness) GetEvents(ctx context.Context, eventType string, limit int) ([]map[string]interface{}, error) {
|
|
url := h.APIURL + "/api/events"
|
|
params := []string{}
|
|
if eventType != "" {
|
|
params = append(params, "type="+eventType)
|
|
}
|
|
if limit > 0 {
|
|
params = append(params, fmt.Sprintf("limit=%d", limit))
|
|
}
|
|
if len(params) > 0 {
|
|
url += "?" + strings.Join(params, "&")
|
|
}
|
|
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
events, _ := result["events"].([]map[string]interface{})
|
|
return events, nil
|
|
}
|
|
|
|
// GetBlobs fetches current blobs from /api/blobs.
|
|
func (h *TestHarness) GetBlobs(ctx context.Context) ([]map[string]interface{}, error) {
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", h.APIURL+"/api/blobs", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
blobs, _ := result["blobs"].([]map[string]interface{})
|
|
return blobs, nil
|
|
}
|
|
|
|
// SetPIN sets the dashboard PIN via /api/auth/setup.
|
|
func (h *TestHarness) SetPIN(ctx context.Context, pin string) error {
|
|
body := []byte(fmt.Sprintf(`{"pin":"%s"}`, pin))
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", h.APIURL+"/api/auth/setup", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("PIN setup returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CheckPINConfigured checks if PIN is configured via /api/auth/status.
|
|
func (h *TestHarness) CheckPINConfigured(ctx context.Context) (bool, error) {
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", h.APIURL+"/api/auth/status", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return false, err
|
|
}
|
|
|
|
configured, _ := result["pin_configured"].(bool)
|
|
return configured, nil
|
|
}
|
|
|
|
// RegisterBLEDevice registers a BLE device via /api/ble/devices.
|
|
func (h *TestHarness) RegisterBLEDevice(ctx context.Context, device map[string]interface{}) error {
|
|
body, _ := json.Marshal(device)
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", h.APIURL+"/api/ble/devices", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("BLE registration returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateReplaySession creates a replay session via /api/replay/start.
|
|
func (h *TestHarness) CreateReplaySession(ctx context.Context, fromMS, toMS int64) (map[string]interface{}, error) {
|
|
body := map[string]interface{}{
|
|
"from_iso8601": time.UnixMilli(fromMS).UTC().Format("2006-01-02T15:04:05Z"),
|
|
"to_iso8601": time.UnixMilli(toMS).UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", h.APIURL+"/api/replay/start", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("Replay start returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var session map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// StopReplaySession stops a replay session.
|
|
func (h *TestHarness) StopReplaySession(ctx context.Context, sessionID string) error {
|
|
stopReq := map[string]interface{}{
|
|
"session_id": sessionID,
|
|
}
|
|
body, _ := json.Marshal(stopReq)
|
|
url := h.APIURL + "/api/replay/stop"
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("Replay stop returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SeekReplaySession seeks to a specific time in a replay session.
|
|
func (h *TestHarness) SeekReplaySession(ctx context.Context, sessionID string, timestampMS int64) error {
|
|
seekReq := map[string]interface{}{
|
|
"session_id": sessionID,
|
|
"timestamp_iso8601": time.UnixMilli(timestampMS).UTC().Format("2006-01-02T15:04:05Z"),
|
|
}
|
|
body, _ := json.Marshal(seekReq)
|
|
url := h.APIURL + "/api/replay/seek"
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("Replay seek returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// WaitForNode waits for a node to appear in /api/nodes.
|
|
func (h *TestHarness) WaitForNode(ctx context.Context, mac string) (map[string]interface{}, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, nodeOnlineTimeout)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-ticker.C:
|
|
nodes, err := h.GetNodes(ctx)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, node := range nodes {
|
|
if mac == "" || node["mac"] == mac {
|
|
if status, ok := node["status"].(string); ok && status == "online" {
|
|
return node, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// WaitForEvent waits for a specific event type to appear.
|
|
func (h *TestHarness) WaitForEvent(ctx context.Context, eventType string, timeout time.Duration) (map[string]interface{}, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
ticker := time.NewTicker(1 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-ticker.C:
|
|
events, err := h.GetEvents(ctx, eventType, 1)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if len(events) > 0 {
|
|
return events[0], nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// CreatePerson creates a person via POST /api/people.
|
|
func (h *TestHarness) CreatePerson(ctx context.Context, name, color string) (map[string]interface{}, error) {
|
|
body := map[string]interface{}{
|
|
"name": name,
|
|
"color": color,
|
|
}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
req, _ := http.NewRequestWithContext(ctx, "POST", h.APIURL+"/api/people", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
return nil, fmt.Errorf("CreatePerson returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var person map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&person); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return person, nil
|
|
}
|
|
|
|
// UpdateBLEDevice updates a BLE device via PUT /api/ble/devices/{mac}.
|
|
func (h *TestHarness) UpdateBLEDevice(ctx context.Context, mac string, updates map[string]interface{}) error {
|
|
body, _ := json.Marshal(updates)
|
|
req, _ := http.NewRequestWithContext(ctx, "PUT", h.APIURL+"/api/ble/devices/"+mac, bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("UpdateBLEDevice returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func findGoCmd() string {
|
|
if goroot := os.Getenv("GOROOT"); goroot != "" {
|
|
candidate := filepath.Join(goroot, "bin", "go")
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
candidate := filepath.Join(home, ".local", "go", "bin", "go")
|
|
if _, err := os.Stat(candidate); err == nil {
|
|
return candidate
|
|
}
|
|
}
|
|
return "go"
|
|
}
|
|
|
|
func repoRoot() string {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
return "."
|
|
}
|
|
// test/acceptance → go up two levels
|
|
return filepath.Join(wd, "..", "..")
|
|
}
|
|
|
|
// TestIO5_DeviceIdentityBLEOnboarding tests IO-5: Device-identity (BLE) onboarding.
|
|
//
|
|
// Steps: with --ble, register a simulated BLE address as a named person; run a walker carrying that identity.
|
|
// Pass: the BLE advertisement is ingested, the registry resolves it to the name, and a person-entered-zone event
|
|
// + the corresponding MQTT person topic are produced.
|
|
// Fail: BLE adv ignored or identity never resolves.
|
|
func TestIO5_DeviceIdentityBLEOnboarding(t *testing.T) {
|
|
if os.Getenv("SPAXEL_INTEGRATION_TEST") != "1" && os.Getenv("ACCEPTANCE_TEST") != "1" {
|
|
t.Skip("Skipping IO-5 test (set SPAXEL_INTEGRATION_TEST=1 or ACCEPTANCE_TEST=1 to run)")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
|
defer cancel()
|
|
|
|
h := NewTestHarness(t)
|
|
defer h.Stop()
|
|
|
|
if err := h.Start(ctx); err != nil {
|
|
t.Fatalf("Failed to start mothership: %v", err)
|
|
}
|
|
|
|
// Create a person named "TestWalker"
|
|
person, err := h.CreatePerson(ctx, "TestWalker", "#ff0000")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create person: %v", err)
|
|
}
|
|
|
|
personID, ok := person["id"].(string)
|
|
if !ok || personID == "" {
|
|
t.Fatalf("Person response missing id")
|
|
}
|
|
|
|
t.Logf("Created person: %s (ID: %s)", person["name"], personID)
|
|
|
|
// Simulator generates BLE addresses AA:BB:CC:DD:EE:00 for walker 0
|
|
walkerBLEAddr := "AA:BB:CC:DD:EE:00"
|
|
|
|
// First, we need to run the simulator briefly so the BLE device is discovered
|
|
// Start simulator with 1 node and 1 walker, with BLE enabled
|
|
simCtx, _ := context.WithTimeout(ctx, 30*time.Second)
|
|
if err := h.RunSimulator(simCtx, []string{
|
|
"--nodes", "1",
|
|
"--walkers", "1",
|
|
"--ble",
|
|
"--seed", "1",
|
|
"--duration", "15",
|
|
}); err != nil {
|
|
t.Fatalf("Failed to start simulator: %v", err)
|
|
}
|
|
|
|
// Wait for BLE advertisement to be sent (BLE messages are sent every 5 seconds)
|
|
// and for the device to be discovered and processed by the mothership
|
|
time.Sleep(7 * time.Second)
|
|
|
|
// Now assign the BLE device to the person
|
|
if err := h.UpdateBLEDevice(ctx, walkerBLEAddr, map[string]interface{}{
|
|
"person_id": personID,
|
|
"label": "TestWalker's Tracker",
|
|
}); err != nil {
|
|
t.Fatalf("Failed to assign BLE device to person: %v", err)
|
|
}
|
|
|
|
t.Logf("Assigned BLE device %s to person %s", walkerBLEAddr, person["name"])
|
|
|
|
// Wait for simulator to complete (it runs for 10 seconds)
|
|
if err := h.SimulatorCmd.Wait(); err != nil {
|
|
t.Logf("Simulator exited with error (may be expected): %v", err)
|
|
}
|
|
|
|
// Wait a moment for events to be processed
|
|
time.Sleep(2 * time.Second)
|
|
|
|
// Check for zone_entry events with the person's name
|
|
// Note: zone_entry events require zones to be defined; this may not generate
|
|
// events if no zones exist, but we verify the device assignment worked.
|
|
events, err := h.GetEvents(ctx, "zone_entry", 10)
|
|
if err != nil {
|
|
t.Logf("Failed to get events (may be expected if no zones): %v", err)
|
|
} else {
|
|
// Look for zone_entry events with the person's name
|
|
var foundPersonEvent bool
|
|
var foundPersonEntry bool
|
|
for _, evt := range events {
|
|
if evtPerson, ok := evt["person"].(string); ok && evtPerson == "TestWalker" {
|
|
foundPersonEvent = true
|
|
if evtZone, ok := evt["zone"].(string); ok && evtZone != "" {
|
|
foundPersonEntry = true
|
|
t.Logf("Found person-entered-zone event: person=%s zone=%s", evtPerson, evtZone)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundPersonEvent {
|
|
t.Log("No zone_entry event found for person TestWalker (zones may not be configured)")
|
|
}
|
|
|
|
if !foundPersonEntry && foundPersonEvent {
|
|
t.Log("zone_entry event found but no zone associated with person TestWalker")
|
|
}
|
|
}
|
|
|
|
// Also verify the BLE device was registered correctly
|
|
// GET /api/ble/devices should show the device with person_id
|
|
req, _ := http.NewRequestWithContext(ctx, "GET", h.APIURL+"/api/ble/devices?registered=true", nil)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("Failed to get BLE devices: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var devicesResult map[string]interface{}
|
|
if err := json.NewDecoder(resp.Body).Decode(&devicesResult); err != nil {
|
|
t.Fatalf("Failed to decode BLE devices response: %v", err)
|
|
}
|
|
|
|
// Log the full response for debugging
|
|
t.Logf("BLE devices response: %+v", devicesResult)
|
|
|
|
// Handle both []map[string]interface{} and []interface{} formats
|
|
devicesInterface, hasDevices := devicesResult["devices"]
|
|
if !hasDevices {
|
|
t.Fatalf("BLE devices response missing devices key")
|
|
}
|
|
|
|
var foundDevice bool
|
|
// Try to convert to []map[string]interface{} first
|
|
if devices, ok := devicesInterface.([]map[string]interface{}); ok {
|
|
for _, dev := range devices {
|
|
if devMac, ok := dev["mac"].(string); ok && devMac == walkerBLEAddr {
|
|
foundDevice = true
|
|
if devPersonID, ok := dev["person_id"].(string); ok && devPersonID == personID {
|
|
t.Logf("BLE device correctly registered: mac=%s person_id=%s", devMac, devPersonID)
|
|
} else {
|
|
t.Errorf("BLE device %s has incorrect person_id: got %v, want %s", walkerBLEAddr, dev["person_id"], personID)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
} else if devicesSlice, ok := devicesInterface.([]interface{}); ok {
|
|
// Handle []interface{} format
|
|
for _, devInterface := range devicesSlice {
|
|
if dev, ok := devInterface.(map[string]interface{}); ok {
|
|
if devMac, ok := dev["mac"].(string); ok && devMac == walkerBLEAddr {
|
|
foundDevice = true
|
|
if devPersonID, ok := dev["person_id"].(string); ok && devPersonID == personID {
|
|
t.Logf("BLE device correctly registered: mac=%s person_id=%s", devMac, devPersonID)
|
|
} else {
|
|
t.Errorf("BLE device %s has incorrect person_id: got %v, want %s", walkerBLEAddr, dev["person_id"], personID)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundDevice {
|
|
t.Errorf("BLE device %s not found in registered devices list", walkerBLEAddr)
|
|
}
|
|
}
|