fix: align BroadcastEventFromDB field names with frontend event spec

BroadcastEventFromDB was using inconsistent field names (timestamp_ms,
type, person) that didn't match the canonical event format (ts, kind,
person_name) expected by handleEventMessage in app.js. This caused
DB-sourced events to render with "undefined" kind/person and "Invalid
Date" timestamps.

Also adds table-driven tests for BroadcastEventFromDB covering zone
entry/exit, portal crossing, anomaly, and minimal events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-07 08:11:18 -04:00
parent 3cb62609c0
commit 653c642108
2 changed files with 234 additions and 9 deletions

View file

@ -922,19 +922,20 @@ func (h *Hub) BroadcastSystemHealth(uptimeS int64, nodeCount, beadCount, goRouti
}
// BroadcastEventFromDB broadcasts an event from the database to all dashboard clients.
// This is called by the EventsHandler when a new event is logged.
// Field names match BroadcastEvent so the frontend can handle both uniformly:
// { type: "event", event: { id, ts, kind, zone, blob_id, person_name, detail_json, severity } }
func (h *Hub) BroadcastEventFromDB(id int64, timestamp int64, eventType, zone, person string, blobID int, detailJSON, severity string) {
msg := map[string]interface{}{
"type": "event",
"event": map[string]interface{}{
"id": id,
"timestamp_ms": timestamp,
"type": eventType,
"zone": zone,
"person": person,
"blob_id": blobID,
"detail_json": detailJSON,
"severity": severity,
"id": id,
"ts": timestamp,
"kind": eventType,
"zone": zone,
"blob_id": blobID,
"person_name": person,
"detail_json": detailJSON,
"severity": severity,
},
}
data, _ := json.Marshal(msg)

View file

@ -503,6 +503,230 @@ func TestHub_BroadcastEvent(t *testing.T) {
}
}
func TestHub_BroadcastBLEScan(t *testing.T) {
tests := []struct {
name string
devices []map[string]interface{}
}{
{
name: "single device",
devices: []map[string]interface{}{
{"mac": "AA:BB:CC:DD:EE:FF", "name": "iPhone", "rssi": -62,
"last_seen": int64(1711234567890), "label": "Alice", "blob_id": 1},
},
},
{
name: "multiple devices",
devices: []map[string]interface{}{
{"mac": "AA:BB:CC:DD:EE:FF", "name": "iPhone", "rssi": -62,
"last_seen": int64(1711234567890), "label": "Alice", "blob_id": 1},
{"mac": "11:22:33:44:55:66", "name": "Apple Watch", "rssi": -70,
"last_seen": int64(1711234567891), "label": "", "blob_id": nil},
},
},
{
name: "empty device list",
devices: []map[string]interface{}{},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
hub.BroadcastBLEScan(tc.devices)
select {
case msg := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to parse ble_scan JSON: %v", err)
}
if parsed["type"] != "ble_scan" {
t.Errorf("expected type=ble_scan, got %v", parsed["type"])
}
devs, ok := parsed["devices"].([]interface{})
if !ok {
t.Fatal("missing devices array")
}
if len(devs) != len(tc.devices) {
t.Errorf("expected %d devices, got %d", len(tc.devices), len(devs))
}
for i, dev := range tc.devices {
d := devs[i].(map[string]interface{})
if d["mac"] != dev["mac"] {
t.Errorf("device %d: expected mac=%v, got %v", i, dev["mac"], d["mac"])
}
if d["name"] != dev["name"] {
t.Errorf("device %d: expected name=%v, got %v", i, dev["name"], d["name"])
}
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive ble_scan broadcast")
}
})
}
}
func TestHub_BroadcastEventFromDB(t *testing.T) {
tests := []struct {
name string
id int64
timestamp int64
eventType string
zone string
person string
blobID int
detailJSON string
severity string
}{
{
name: "zone entry with person and detail",
id: 42,
timestamp: 1711234567890,
eventType: "zone_entry",
zone: "Kitchen",
person: "Alice",
blobID: 2,
detailJSON: `{"direction":"north"}`,
severity: "info",
},
{
name: "zone exit without person",
id: 43,
timestamp: 1711234567891,
eventType: "zone_exit",
zone: "Kitchen",
person: "",
blobID: 3,
severity: "info",
},
{
name: "portal crossing",
id: 44,
timestamp: 1711234567892,
eventType: "portal_crossing",
zone: "Hallway",
person: "Bob",
blobID: 1,
severity: "info",
},
{
name: "anomaly alert",
id: 45,
timestamp: 1711234567893,
eventType: "anomaly",
zone: "Kitchen",
person: "",
blobID: 0,
detailJSON: `{"score":0.92}`,
severity: "warning",
},
{
name: "minimal event",
id: 46,
timestamp: 1711234567894,
eventType: "system",
zone: "",
person: "",
blobID: 0,
severity: "info",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hub := NewHub()
go hub.Run()
client := &Client{
hub: hub,
send: make(chan []byte, 10),
}
hub.Register(client)
time.Sleep(10 * time.Millisecond)
drainSnapshot(t, client.send)
hub.BroadcastEventFromDB(tc.id, tc.timestamp, tc.eventType, tc.zone, tc.person, tc.blobID, tc.detailJSON, tc.severity)
select {
case msg := <-client.send:
var parsed map[string]interface{}
if err := json.Unmarshal(msg, &parsed); err != nil {
t.Fatalf("failed to parse event JSON: %v", err)
}
if parsed["type"] != "event" {
t.Errorf("expected type=event, got %v", parsed["type"])
}
evt, ok := parsed["event"].(map[string]interface{})
if !ok {
t.Fatal("missing event object")
}
// Verify canonical field names (matching BroadcastEvent format)
if evt["ts"] != float64(tc.timestamp) {
t.Errorf("expected ts=%d, got %v", tc.timestamp, evt["ts"])
}
if evt["kind"] != tc.eventType {
t.Errorf("expected kind=%s, got %v", tc.eventType, evt["kind"])
}
if evt["zone"] != tc.zone {
t.Errorf("expected zone=%s, got %v", tc.zone, evt["zone"])
}
if evt["blob_id"] != float64(tc.blobID) {
t.Errorf("expected blob_id=%d, got %v", tc.blobID, evt["blob_id"])
}
if evt["person_name"] != tc.person {
t.Errorf("expected person_name=%s, got %v", tc.person, evt["person_name"])
}
// Verify extra DB fields are present
if evt["severity"] != tc.severity {
t.Errorf("expected severity=%s, got %v", tc.severity, evt["severity"])
}
// detail_json should be present when non-empty
if tc.detailJSON != "" {
if evt["detail_json"] != tc.detailJSON {
t.Errorf("expected detail_json=%s, got %v", tc.detailJSON, evt["detail_json"])
}
}
// Verify legacy field names are NOT used
if _, hasLegacy := evt["timestamp_ms"]; hasLegacy {
t.Error("legacy field timestamp_ms should not be present (use ts)")
}
if _, hasLegacy := evt["type"]; hasLegacy {
t.Error("legacy field type should not be present inside event (use kind)")
}
if _, hasLegacy := evt["person"]; hasLegacy {
t.Error("legacy field person should not be present (use person_name)")
}
case <-time.After(100 * time.Millisecond):
t.Error("expected to receive event broadcast")
}
})
}
}
func TestHub_DeltaOmitsTypeField(t *testing.T) {
hub := NewHub()
go hub.Run()