feat: implement BLE Devices REST endpoints with OpenAPI docs
- Add GET /api/ble/devices to list known devices with filtering
- Add PUT /api/ble/devices/{mac} to set label and assign to person
- Add comprehensive OpenAPI-style godoc comments for all BLE endpoints
- Add table-driven tests for BLE handler endpoints
Endpoints support:
- Filtering by registration status (registered/discovered)
- Time window filtering (hours parameter)
- Device labels and person assignment
- Sighting history per device
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
52aeb7a6ef
commit
56c28bce63
2 changed files with 843 additions and 3 deletions
768
mothership/internal/api/ble_test.go
Normal file
768
mothership/internal/api/ble_test.go
Normal file
|
|
@ -0,0 +1,768 @@
|
|||
// Package api provides REST API tests for Spaxel BLE device endpoints.
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi"
|
||||
"github.com/spaxel/mothership/internal/ble"
|
||||
)
|
||||
|
||||
// newTestBLEHandler creates a BLE handler backed by a temporary database.
|
||||
func newTestBLEHandler(t *testing.T) (*ble.Handler, *ble.Registry, func()) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "ble.db")
|
||||
|
||||
registry, err := ble.NewRegistry(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create BLE registry: %v", err)
|
||||
}
|
||||
|
||||
handler := ble.NewHandler(registry)
|
||||
cleanup := func() { registry.Close() }
|
||||
|
||||
return handler, registry, cleanup
|
||||
}
|
||||
|
||||
// setupBLERouter creates a chi.Router with all BLE routes registered.
|
||||
func setupBLERouter(h *ble.Handler) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
h.RegisterRoutes(r)
|
||||
return r
|
||||
}
|
||||
|
||||
// TestListBLEDevices tests GET /api/ble/devices.
|
||||
func TestListBLEDevices(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed some test devices via ProcessRelayMessage
|
||||
now := time.Now().UnixNano()
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:01", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:01", Name: "iPhone", MfrID: 0x004C, MfrDataHex: "0215", RSSIdBm: -45},
|
||||
})
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:02", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:02", Name: "Samsung", MfrID: 0x0075, MfrDataHex: "", RSSIdBm: -60},
|
||||
})
|
||||
|
||||
// Create a person and assign one device
|
||||
person, err := registry.CreatePerson("Alice", "#ff0000")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePerson: %v", err)
|
||||
}
|
||||
registry.UpdateDevice("AA:BB:CC:DD:EE:01", map[string]interface{}{
|
||||
"person_id": person.ID,
|
||||
"name": "Alice's Phone",
|
||||
})
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("GET", "/api/ble/devices", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(rr.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
devices, ok := result["devices"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected 'devices' key in response")
|
||||
}
|
||||
if len(devices) != 2 {
|
||||
t.Fatalf("Expected 2 devices, got %d", len(devices))
|
||||
}
|
||||
|
||||
// Check privacy notice header
|
||||
if privacyNotice := rr.Header().Get("X-Privacy-Notice"); privacyNotice == "" {
|
||||
t.Error("Expected X-Privacy-Notice header")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListBLEDevicesRegistered tests GET /api/ble/devices?registered=true.
|
||||
func TestListBLEDevicesRegistered(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed devices
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:01", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:01", Name: "iPhone", MfrID: 0x004C, RSSIdBm: -45},
|
||||
})
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:02", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:02", Name: "Unknown", MfrID: 0, RSSIdBm: -60},
|
||||
})
|
||||
|
||||
// Create a person and assign one device
|
||||
person, _ := registry.CreatePerson("Alice", "#ff0000")
|
||||
registry.UpdateDevice("AA:BB:CC:DD:EE:01", map[string]interface{}{
|
||||
"person_id": person.ID,
|
||||
"name": "Alice's Phone",
|
||||
})
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("GET", "/api/ble/devices?registered=true", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&result)
|
||||
devices := result["devices"].([]interface{})
|
||||
|
||||
// Should only return the registered device
|
||||
if len(devices) != 1 {
|
||||
t.Fatalf("Expected 1 registered device, got %d", len(devices))
|
||||
}
|
||||
}
|
||||
|
||||
// TestListBLEDevicesDiscovered tests GET /api/ble/devices?discovered=true.
|
||||
func TestListBLEDevicesDiscovered(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed devices
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:01", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:01", Name: "iPhone", MfrID: 0x004C, RSSIdBm: -45},
|
||||
})
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:02", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:02", Name: "Unknown", MfrID: 0, RSSIdBm: -60},
|
||||
})
|
||||
|
||||
// Create a person and assign one device
|
||||
person, _ := registry.CreatePerson("Alice", "#ff0000")
|
||||
registry.UpdateDevice("AA:BB:CC:DD:EE:01", map[string]interface{}{
|
||||
"person_id": person.ID,
|
||||
})
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("GET", "/api/ble/devices?discovered=true", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&result)
|
||||
devices := result["devices"].([]interface{})
|
||||
|
||||
// Should only return unregistered devices
|
||||
if len(devices) != 1 {
|
||||
t.Fatalf("Expected 1 unregistered device, got %d", len(devices))
|
||||
}
|
||||
}
|
||||
|
||||
// TestListBLEDevicesEmpty tests GET /api/ble/devices with no devices.
|
||||
func TestListBLEDevicesEmpty(t *testing.T) {
|
||||
h, _, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("GET", "/api/ble/devices", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&result)
|
||||
devices := result["devices"].([]interface{})
|
||||
if len(devices) != 0 {
|
||||
t.Errorf("Expected 0 devices, got %d", len(devices))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetBLEDevice tests GET /api/ble/devices/{mac}.
|
||||
func TestGetBLEDevice(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed a device
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:01", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:01", Name: "iPhone", MfrID: 0x004C, MfrDataHex: "0215", RSSIdBm: -45},
|
||||
})
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("GET", "/api/ble/devices/AA:BB:CC:DD:EE:01", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var device ble.DeviceRecord
|
||||
if err := json.NewDecoder(rr.Body).Decode(&device); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if device.Addr != "AA:BB:CC:DD:EE:01" {
|
||||
t.Errorf("Expected MAC AA:BB:CC:DD:EE:01, got %s", device.Addr)
|
||||
}
|
||||
if device.DeviceName != "iPhone" {
|
||||
t.Errorf("Expected name 'iPhone', got %s", device.DeviceName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetBLEDeviceNotFound tests GET /api/ble/devices/{mac} for nonexistent device.
|
||||
func TestGetBLEDeviceNotFound(t *testing.T) {
|
||||
h, _, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("GET", "/api/ble/devices/AA:BB:CC:DD:EE:FF", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBLEDevice tests PUT /api/ble/devices/{mac}.
|
||||
func TestUpdateBLEDevice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mac string
|
||||
body string
|
||||
wantStatus int
|
||||
wantLabel string
|
||||
wantPerson string
|
||||
}{
|
||||
{
|
||||
name: "update label only",
|
||||
mac: "AA:BB:CC:DD:EE:01",
|
||||
body: `{"label": "Alice's iPhone"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantLabel: "Alice's iPhone",
|
||||
},
|
||||
{
|
||||
name: "update device type",
|
||||
mac: "AA:BB:CC:DD:EE:02",
|
||||
body: `{"device_type": "apple_phone"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "update all fields",
|
||||
mac: "AA:BB:CC:DD:EE:03",
|
||||
body: `{"label": "Bob's Phone", "device_type": "samsung"}`,
|
||||
wantStatus: http.StatusOK,
|
||||
wantLabel: "Bob's Phone",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed a device
|
||||
registry.ProcessRelayMessage(tt.mac, []ble.BLEObservation{
|
||||
{Addr: tt.mac, Name: "Device", MfrID: 0x004C, RSSIdBm: -50},
|
||||
})
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("PUT", "/api/ble/devices/"+tt.mac, bytes.NewReader([]byte(tt.body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != tt.wantStatus {
|
||||
t.Fatalf("Expected %d, got %d: %s", tt.wantStatus, rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var device ble.DeviceRecord
|
||||
if err := json.NewDecoder(rr.Body).Decode(&device); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if tt.wantLabel != "" && device.Label != tt.wantLabel {
|
||||
t.Errorf("Expected label %q, got %q", tt.wantLabel, device.Label)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBLEDeviceAssignToPerson tests PUT /api/ble/devices/{mac} with person assignment.
|
||||
func TestUpdateBLEDeviceAssignToPerson(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed a device and create a person
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:01", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:01", Name: "iPhone", MfrID: 0x004C, RSSIdBm: -45},
|
||||
})
|
||||
|
||||
person, err := registry.CreatePerson("Alice", "#ff0000")
|
||||
if err != nil {
|
||||
t.Fatalf("CreatePerson: %v", err)
|
||||
}
|
||||
|
||||
r := setupBLERouter(h)
|
||||
body := `{"label": "Alice's Phone", "person_id": "` + person.ID + `"}`
|
||||
req := httptest.NewRequest("PUT", "/api/ble/devices/AA:BB:CC:DD:EE:01", bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var device ble.DeviceRecord
|
||||
json.NewDecoder(rr.Body).Decode(&device)
|
||||
|
||||
if device.Label != "Alice's Phone" {
|
||||
t.Errorf("Expected label 'Alice's Phone', got %s", device.Label)
|
||||
}
|
||||
if device.PersonID != person.ID {
|
||||
t.Errorf("Expected person_id %s, got %s", person.ID, device.PersonID)
|
||||
}
|
||||
|
||||
// Verify persistence via GET
|
||||
req2 := httptest.NewRequest("GET", "/api/ble/devices/AA:BB:CC:DD:EE:01", nil)
|
||||
rr2 := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr2, req2)
|
||||
|
||||
var device2 ble.DeviceRecord
|
||||
json.NewDecoder(rr2.Body).Decode(&device2)
|
||||
|
||||
if device2.Label != "Alice's Phone" {
|
||||
t.Errorf("After GET: expected label 'Alice's Phone', got %s", device2.Label)
|
||||
}
|
||||
if device2.PersonID != person.ID {
|
||||
t.Errorf("After GET: expected person_id %s, got %s", person.ID, device2.PersonID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBLEDeviceInvalidPerson tests PUT /api/ble/devices/{mac} with invalid person_id.
|
||||
func TestUpdateBLEDeviceInvalidPerson(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed a device
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:01", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:01", Name: "iPhone", MfrID: 0x004C, RSSIdBm: -45},
|
||||
})
|
||||
|
||||
r := setupBLERouter(h)
|
||||
body := `{"person_id": "nonexistent-person-id"}`
|
||||
req := httptest.NewRequest("PUT", "/api/ble/devices/AA:BB:CC:DD:EE:01", bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for invalid person_id, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBLEDeviceNotFound tests PUT /api/ble/devices/{mac} for nonexistent device.
|
||||
func TestUpdateBLEDeviceNotFound(t *testing.T) {
|
||||
h, _, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
r := setupBLERouter(h)
|
||||
body := `{"label": "Test"}`
|
||||
req := httptest.NewRequest("PUT", "/api/ble/devices/AA:BB:CC:DD:EE:FF", bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBLEDeviceInvalid tests PUT /api/ble/devices/{mac} with invalid input.
|
||||
func TestUpdateBLEDeviceInvalid(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed a device
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:01", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:01", Name: "iPhone", MfrID: 0x004C, RSSIdBm: -45},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{"malformed JSON", `{bad`, http.StatusBadRequest},
|
||||
{"empty body", ``, http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("PUT", "/api/ble/devices/AA:BB:CC:DD:EE:01", bytes.NewReader([]byte(tt.body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != tt.want {
|
||||
t.Errorf("Expected %d, got %d", tt.want, rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteBLEDevice tests DELETE /api/ble/devices/{mac}.
|
||||
func TestDeleteBLEDevice(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed devices
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:01", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:01", Name: "Device1", MfrID: 0x004C, RSSIdBm: -45},
|
||||
})
|
||||
registry.ProcessRelayMessage("AA:BB:CC:DD:EE:02", []ble.BLEObservation{
|
||||
{Addr: "AA:BB:CC:DD:EE:02", Name: "Device2", MfrID: 0x004C, RSSIdBm: -50},
|
||||
})
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("DELETE", "/api/ble/devices/AA:BB:CC:DD:EE:01", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("Expected 204, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Verify device is archived
|
||||
device, err := registry.GetDevice("AA:BB:CC:DD:EE:01")
|
||||
if err != nil {
|
||||
t.Fatal("Device should still exist (archived)")
|
||||
}
|
||||
if !device.IsArchived {
|
||||
t.Error("Device should be archived")
|
||||
}
|
||||
|
||||
// Verify other device still exists
|
||||
device2, _ := registry.GetDevice("AA:BB:CC:DD:EE:02")
|
||||
if device2 == nil {
|
||||
t.Error("Other device should still exist")
|
||||
}
|
||||
if device2.IsArchived {
|
||||
t.Error("Other device should not be archived")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteBLEDeviceNotFound tests DELETE /api/ble/devices/{mac} for nonexistent device.
|
||||
func TestDeleteBLEDeviceNotFound(t *testing.T) {
|
||||
h, _, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("DELETE", "/api/ble/devices/AA:BB:CC:DD:EE:FF", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreregisterBLEDevice tests POST /api/ble/devices/preregister.
|
||||
func TestPreregisterBLEDevice(t *testing.T) {
|
||||
h, _, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
r := setupBLERouter(h)
|
||||
body := `{"mac": "AA:BB:CC:DD:EE:FF", "label": "My Tile Tracker"}`
|
||||
req := httptest.NewRequest("POST", "/api/ble/devices/preregister", bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("Expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var device ble.DeviceRecord
|
||||
if err := json.NewDecoder(rr.Body).Decode(&device); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if device.Addr != "AA:BB:CC:DD:EE:FF" {
|
||||
t.Errorf("Expected MAC AA:BB:CC:DD:EE:FF, got %s", device.Addr)
|
||||
}
|
||||
if device.Label != "My Tile Tracker" {
|
||||
t.Errorf("Expected label 'My Tile Tracker', got %s", device.Label)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreregisterBLEDeviceInvalid tests POST /api/ble/devices/preregister with invalid input.
|
||||
func TestPreregisterBLEDeviceInvalid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "missing MAC",
|
||||
body: `{"label": "Test"}`,
|
||||
wantMsg: "mac is required",
|
||||
},
|
||||
{
|
||||
name: "malformed JSON",
|
||||
body: `{invalid}`,
|
||||
wantMsg: "invalid request body",
|
||||
},
|
||||
{
|
||||
name: "empty body",
|
||||
body: ``,
|
||||
wantMsg: "invalid request body",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h, _, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("POST", "/api/ble/devices/preregister", bytes.NewReader([]byte(tt.body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDeviceHistory tests GET /api/ble/devices/{mac}/history.
|
||||
func TestGetDeviceHistory(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Seed a device with multiple observations
|
||||
mac := "AA:BB:CC:DD:EE:01"
|
||||
registry.ProcessRelayMessage("node1", []ble.BLEObservation{
|
||||
{Addr: mac, Name: "iPhone", MfrID: 0x004C, RSSIdBm: -45},
|
||||
})
|
||||
registry.ProcessRelayMessage("node2", []ble.BLEObservation{
|
||||
{Addr: mac, Name: "iPhone", MfrID: 0x004C, RSSIdBm: -55},
|
||||
})
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("GET", "/api/ble/devices/"+mac+"/history", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&result)
|
||||
|
||||
if result["mac"] != mac {
|
||||
t.Errorf("Expected mac %s, got %v", mac, result["mac"])
|
||||
}
|
||||
|
||||
history, ok := result["history"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected 'history' key in response")
|
||||
}
|
||||
// Should have at least 2 sightings
|
||||
if len(history) < 2 {
|
||||
t.Errorf("Expected at least 2 history entries, got %d", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDeviceHistoryNotFound tests GET /api/ble/devices/{mac}/history for nonexistent device.
|
||||
func TestGetDeviceHistoryNotFound(t *testing.T) {
|
||||
h, _, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("GET", "/api/ble/devices/AA:BB:CC:DD:EE:FF/history", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListPeople tests GET /api/people.
|
||||
func TestListPeople(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create people
|
||||
registry.CreatePerson("Alice", "#ff0000")
|
||||
registry.CreatePerson("Bob", "#0000ff")
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("GET", "/api/people", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var people []map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&people)
|
||||
|
||||
if len(people) != 2 {
|
||||
t.Fatalf("Expected 2 people, got %d", len(people))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatePerson tests POST /api/people.
|
||||
func TestCreatePerson(t *testing.T) {
|
||||
h, _, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
r := setupBLERouter(h)
|
||||
body := `{"name": "Charlie", "color": "#00ff00"}`
|
||||
req := httptest.NewRequest("POST", "/api/people", bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("Expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var person map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&person)
|
||||
|
||||
if person["name"] != "Charlie" {
|
||||
t.Errorf("Expected name 'Charlie', got %v", person["name"])
|
||||
}
|
||||
if person["color"] != "#00ff00" {
|
||||
t.Errorf("Expected color '#00ff00', got %v", person["color"])
|
||||
}
|
||||
if person["id"] == nil || person["id"] == "" {
|
||||
t.Error("Expected non-empty id")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreatePersonDefaultColor tests POST /api/people with default color.
|
||||
func TestCreatePersonDefaultColor(t *testing.T) {
|
||||
h, _, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
r := setupBLERouter(h)
|
||||
body := `{"name": "Dana"}`
|
||||
req := httptest.NewRequest("POST", "/api/people", bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("Expected 201, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var person map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&person)
|
||||
|
||||
// Default color should be #3b82f6
|
||||
if person["color"] != "#3b82f6" {
|
||||
t.Errorf("Expected default color '#3b82f6', got %v", person["color"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetPerson tests GET /api/people/{id}.
|
||||
func TestGetPerson(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
person, _ := registry.CreatePerson("Alice", "#ff0000")
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("GET", "/api/people/"+person.ID, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&result)
|
||||
|
||||
if result["name"] != "Alice" {
|
||||
t.Errorf("Expected name 'Alice', got %v", result["name"])
|
||||
}
|
||||
if result["id"] != person.ID {
|
||||
t.Errorf("Expected id %s, got %v", person.ID, result["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdatePerson tests PUT /api/people/{id}.
|
||||
func TestUpdatePerson(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
person, _ := registry.CreatePerson("Alice", "#ff0000")
|
||||
|
||||
r := setupBLERouter(h)
|
||||
body := `{"name": "Alice Smith", "color": "#ff5500"}`
|
||||
req := httptest.NewRequest("PUT", "/api/people/"+person.ID, bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rr.Body).Decode(&result)
|
||||
|
||||
if result["name"] != "Alice Smith" {
|
||||
t.Errorf("Expected name 'Alice Smith', got %v", result["name"])
|
||||
}
|
||||
if result["color"] != "#ff5500" {
|
||||
t.Errorf("Expected color '#ff5500', got %v", result["color"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeletePerson tests DELETE /api/people/{id}.
|
||||
func TestDeletePerson(t *testing.T) {
|
||||
h, registry, cleanup := newTestBLEHandler(t)
|
||||
defer cleanup()
|
||||
|
||||
person, _ := registry.CreatePerson("Alice", "#ff0000")
|
||||
|
||||
r := setupBLERouter(h)
|
||||
req := httptest.NewRequest("DELETE", "/api/people/"+person.ID, nil)
|
||||
rr := httptest.NewRecorder()
|
||||
r.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("Expected 204, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Verify person is deleted
|
||||
_, err := registry.GetPerson(person.ID)
|
||||
if err == nil {
|
||||
t.Error("Person should be deleted")
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,26 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||
|
||||
// ── Device endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
// listDevices handles GET /api/ble/devices.
|
||||
//
|
||||
// Returns a list of all BLE devices seen by the system. Devices can be filtered
|
||||
// by registration status (registered/discovered), time window (hours parameter),
|
||||
// and archival status.
|
||||
//
|
||||
// Query parameters:
|
||||
// - registered: "true" to return only devices assigned to a person
|
||||
// - discovered: "true" to return only unassigned devices
|
||||
// - archived: "true" to include archived (soft-deleted) devices
|
||||
// - hours: time window in hours (default: 24)
|
||||
//
|
||||
// Response: JSON object with "devices" array and "privacy_notice" string.
|
||||
// Each device includes: mac, name, label, manufacturer, device_type, device_name,
|
||||
// person_id, person_name, rssi_min, rssi_max, rssi_avg, first_seen_at, last_seen_at,
|
||||
// last_seen_node, is_archived, is_wearable, enabled, last_location.
|
||||
//
|
||||
// Status codes:
|
||||
// - 200: Success
|
||||
// - 500: Internal error
|
||||
func (h *Handler) listDevices(w http.ResponseWriter, r *http.Request) {
|
||||
includeArchived := r.URL.Query().Get("archived") == "true"
|
||||
registered := r.URL.Query().Get("registered")
|
||||
|
|
@ -122,6 +142,20 @@ func (h *Handler) listDevices(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
// getDevice handles GET /api/ble/devices/{mac}.
|
||||
//
|
||||
// Returns detailed information about a single BLE device by its MAC address.
|
||||
// The MAC address should be in uppercase colon-separated hex format (e.g., "AA:BB:CC:DD:EE:FF").
|
||||
//
|
||||
// URL parameters:
|
||||
// - mac: BLE device MAC address
|
||||
//
|
||||
// Response: JSON device object with all fields including location history.
|
||||
//
|
||||
// Status codes:
|
||||
// - 200: Success, device found
|
||||
// - 404: Device not found
|
||||
// - 500: Internal error
|
||||
func (h *Handler) getDevice(w http.ResponseWriter, r *http.Request) {
|
||||
mac := chi.URLParam(r, "mac")
|
||||
device, err := h.registry.GetDevice(mac)
|
||||
|
|
@ -136,6 +170,24 @@ func (h *Handler) getDevice(w http.ResponseWriter, r *http.Request) {
|
|||
writeJSON(w, device)
|
||||
}
|
||||
|
||||
// getDeviceHistory handles GET /api/ble/devices/{mac}/history.
|
||||
//
|
||||
// Returns the sighting history for a specific BLE device. This includes
|
||||
// RSSI observations from nodes that have detected this device over time.
|
||||
//
|
||||
// URL parameters:
|
||||
// - mac: BLE device MAC address
|
||||
//
|
||||
// Query parameters:
|
||||
// - limit: maximum number of history entries to return (default: 100, max: 1000)
|
||||
//
|
||||
// Response: JSON object with "mac", "history" (array of sighting entries),
|
||||
// and "limit" fields. Each history entry includes timestamp, rssi_dbm, and node_mac.
|
||||
//
|
||||
// Status codes:
|
||||
// - 200: Success
|
||||
// - 404: Device not found
|
||||
// - 500: Internal error
|
||||
func (h *Handler) getDeviceHistory(w http.ResponseWriter, r *http.Request) {
|
||||
mac := chi.URLParam(r, "mac")
|
||||
|
||||
|
|
@ -166,11 +218,31 @@ func (h *Handler) getDeviceHistory(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type updateDeviceRequest struct {
|
||||
Label string `json:"label"`
|
||||
DeviceType string `json:"device_type"`
|
||||
PersonID string `json:"person_id"`
|
||||
Label string `json:"label"` // User-assigned display label
|
||||
DeviceType string `json:"device_type"` // Device type (apple_phone, apple_watch, tile, etc.)
|
||||
PersonID string `json:"person_id"` // Person ID to assign device to
|
||||
}
|
||||
|
||||
// updateDevice handles PUT /api/ble/devices/{mac}.
|
||||
//
|
||||
// Updates a BLE device's properties. This endpoint is used to set a human-readable
|
||||
// label for a device and/or assign it to a person for identity tracking.
|
||||
//
|
||||
// URL parameters:
|
||||
// - mac: BLE device MAC address (uppercase colon-separated hex)
|
||||
//
|
||||
// Request body: JSON object with optional fields:
|
||||
// - label: User-assigned display label (e.g., "Alice's iPhone")
|
||||
// - device_type: Device type identifier (e.g., "apple_phone", "tile", "fitbit")
|
||||
// - person_id: UUID of person to assign this device to (must exist)
|
||||
//
|
||||
// Response: Updated device object as JSON.
|
||||
//
|
||||
// Status codes:
|
||||
// - 200: Success, device updated
|
||||
// - 400: Invalid request body or person_id not found
|
||||
// - 404: Device not found
|
||||
// - 500: Internal error
|
||||
func (h *Handler) updateDevice(w http.ResponseWriter, r *http.Request) {
|
||||
mac := chi.URLParam(r, "mac")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue