test(bf-5151): guard canonical identity-field serialization on blob types
Add table-driven serialization tests for the three bf-5151 canonical identity fields (PersonName/AssignedColor/IdentityResolved) on the tracked-blob types whose fields were added ina612584+01a415dand propagated through the JSON projections: - internal/signal/identity_fields_test.go -> signal.TrackedBlob (the canonical type /api/blobs serializes; api.TrackedBlob aliases it) - internal/automation/identity_fields_test.go -> automation.TrackedBlob (Tier-1 #1 type that previously carried NO identity fields) - internal/api/tracks_identity_test.go -> api.Track via the real GET /api/tracks handler, asserting propagation from signal.TrackedBlob Each suite asserts the three task-required properties: 1. zero-value blobs omit all three fields (omitempty -> undefined in JS), satisfying "fields set to undefined for existing blobs"; 2. a resolved identity emits the camelCase keys matching dashboard/types/spaxel.d.ts; 3. the *bool tri-state: nil=omitted, &true=emitted, &false still emits "identityResolved":false (resolution attempted, failed). Verified: gofmt clean, go vet ./... clean, go test ./... passes (50 packages ok, including the 3 new suites). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1871a8a42c
commit
d0ac1b27b0
3 changed files with 270 additions and 0 deletions
112
mothership/internal/api/tracks_identity_test.go
Normal file
112
mothership/internal/api/tracks_identity_test.go
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// canonicalIdentityKeys is the set of bf-5151 canonical identity fields the
|
||||
// /api/tracks Track projection must emit (camelCase, matching
|
||||
// dashboard/types/spaxel.d.ts).
|
||||
var canonicalIdentityKeys = []string{"personName", "assignedColor", "identityResolved"}
|
||||
|
||||
// trackResponseJSON drives GET /api/tracks through the real handler and returns
|
||||
// the raw JSON body so key presence/absence can be asserted precisely.
|
||||
func trackResponseJSON(t *testing.T, blobs []TrackedBlob) string {
|
||||
t.Helper()
|
||||
h := NewTracksHandler(&mockTracksProvider{blobs: blobs})
|
||||
r := chi.NewRouter()
|
||||
h.RegisterRoutes(r)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/tracks", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d", w.Code)
|
||||
}
|
||||
return w.Body.String()
|
||||
}
|
||||
|
||||
// TestListTracks_CanonicalIdentitySerialization guards that listTracks (a)
|
||||
// PROPAGATES the three bf-5151 canonical identity fields from the source
|
||||
// signal.TrackedBlob onto the serialized Track, and (b) leaves them omitted
|
||||
// (undefined in JS) for zero-value blobs. The *bool tri-state is exercised:
|
||||
// nil is omitted, while a non-nil false (resolution attempted, failed) must
|
||||
// still emit "identityResolved":false.
|
||||
func TestListTracks_CanonicalIdentitySerialization(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
blob TrackedBlob
|
||||
wantKey map[string]bool
|
||||
}{
|
||||
{
|
||||
name: "zero-value blob omits all three (undefined)",
|
||||
blob: TrackedBlob{ID: 1, X: 1, Y: 2, Z: 3, Weight: 1},
|
||||
wantKey: map[string]bool{
|
||||
"personName": false, "assignedColor": false, "identityResolved": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolved identity propagated and emitted in camelCase",
|
||||
blob: TrackedBlob{
|
||||
ID: 2, PersonName: "Alice", AssignedColor: "#4488ff",
|
||||
IdentityResolved: ptrBoolTrack(true),
|
||||
},
|
||||
wantKey: map[string]bool{
|
||||
"personName": true, "assignedColor": true, "identityResolved": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "failed resolution propagated as identityResolved=false",
|
||||
blob: TrackedBlob{ID: 3, IdentityResolved: ptrBoolTrack(false)},
|
||||
wantKey: map[string]bool{
|
||||
"personName": false, "assignedColor": false, "identityResolved": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
body := trackResponseJSON(t, []TrackedBlob{tc.blob})
|
||||
|
||||
for _, k := range canonicalIdentityKeys {
|
||||
got := strings.Contains(body, `"`+k+`"`)
|
||||
if got != tc.wantKey[k] {
|
||||
t.Errorf("key %q: present=%v, want %v (body=%s)", k, got, tc.wantKey[k], body)
|
||||
}
|
||||
}
|
||||
|
||||
// Decode and assert the propagated Go values for the resolved case.
|
||||
var tracks []Track
|
||||
if err := json.Unmarshal([]byte(body), &tracks); err != nil { //nolint:errcheck
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if len(tracks) != 1 || tracks[0].ID != tc.blob.ID {
|
||||
t.Fatalf("expected 1 track with ID %d, got %+v", tc.blob.ID, tracks)
|
||||
}
|
||||
tr := tracks[0]
|
||||
if tr.PersonName != tc.blob.PersonName {
|
||||
t.Errorf("PersonName propagation: got %q, want %q", tr.PersonName, tc.blob.PersonName)
|
||||
}
|
||||
if tr.AssignedColor != tc.blob.AssignedColor {
|
||||
t.Errorf("AssignedColor propagation: got %q, want %q", tr.AssignedColor, tc.blob.AssignedColor)
|
||||
}
|
||||
if (tr.IdentityResolved == nil) != (tc.blob.IdentityResolved == nil) {
|
||||
t.Errorf("IdentityResolved propagation: got %v, want %v", tr.IdentityResolved, tc.blob.IdentityResolved)
|
||||
} else if tr.IdentityResolved != nil && *tr.IdentityResolved != *tc.blob.IdentityResolved {
|
||||
t.Errorf("IdentityResolved value: got %v, want %v", *tr.IdentityResolved, *tc.blob.IdentityResolved)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ptrBoolTrack is a small helper for the *bool tri-state IdentityResolved field.
|
||||
func ptrBoolTrack(b bool) *bool { return &b }
|
||||
74
mothership/internal/automation/identity_fields_test.go
Normal file
74
mothership/internal/automation/identity_fields_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package automation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// canonicalIdentityKeys is the set of bf-5151 canonical identity fields the
|
||||
// automation.TrackedBlob type must emit (camelCase, matching
|
||||
// dashboard/types/spaxel.d.ts). automation.TrackedBlob is the Tier-1 #1 type
|
||||
// from bf-1q3m: it previously carried NO identity fields at all, which
|
||||
// structurally blocked person-aware automations ("when Alice enters…").
|
||||
var canonicalIdentityKeys = []string{"personName", "assignedColor", "identityResolved"}
|
||||
|
||||
// TestTrackedBlob_CanonicalIdentitySerialization guards the three bf-5151
|
||||
// canonical identity fields on automation.TrackedBlob. Per the task scope they
|
||||
// are left at their Go zero values for existing blobs: with omitempty they must
|
||||
// serialize as OMITTED (undefined in JS) until a follow-up bead populates them
|
||||
// from the BLE identity sidecar. The *bool tri-state is exercised: nil is
|
||||
// omitted, while a non-nil false must still emit "identityResolved":false.
|
||||
func TestTrackedBlob_CanonicalIdentitySerialization(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
blob TrackedBlob
|
||||
wantKey map[string]bool
|
||||
}{
|
||||
{
|
||||
name: "zero-value blob omits all three (undefined)",
|
||||
blob: TrackedBlob{ID: 1, X: 1, Y: 2, Z: 3, Confidence: 1},
|
||||
wantKey: map[string]bool{
|
||||
"personName": false, "assignedColor": false, "identityResolved": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolved identity emits camelCase keys",
|
||||
blob: TrackedBlob{
|
||||
ID: 2, PersonName: "Alice", AssignedColor: "#4488ff",
|
||||
IdentityResolved: ptrBoolAuto(true),
|
||||
},
|
||||
wantKey: map[string]bool{
|
||||
"personName": true, "assignedColor": true, "identityResolved": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "failed resolution still emits identityResolved=false",
|
||||
blob: TrackedBlob{ID: 3, IdentityResolved: ptrBoolAuto(false)},
|
||||
wantKey: map[string]bool{
|
||||
"personName": false, "assignedColor": false, "identityResolved": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := json.Marshal(tc.blob)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, k := range canonicalIdentityKeys {
|
||||
got := strings.Contains(body, `"`+k+`"`)
|
||||
if got != tc.wantKey[k] {
|
||||
t.Errorf("key %q: present=%v, want %v (body=%s)", k, got, tc.wantKey[k], body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ptrBoolAuto is a small helper for the *bool tri-state IdentityResolved field.
|
||||
func ptrBoolAuto(b bool) *bool { return &b }
|
||||
84
mothership/internal/signal/identity_fields_test.go
Normal file
84
mothership/internal/signal/identity_fields_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package signal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// canonicalIdentityKeys is the set of bf-5151 canonical identity fields every
|
||||
// tracked-blob type must emit (camelCase, matching dashboard/types/spaxel.d.ts).
|
||||
var canonicalIdentityKeys = []string{"personName", "assignedColor", "identityResolved"}
|
||||
|
||||
// TestTrackedBlob_CanonicalIdentitySerialization guards the three bf-5151
|
||||
// canonical identity fields on signal.TrackedBlob — the canonical tracked-blob
|
||||
// type that /api/blobs serializes directly and that api.TrackedBlob aliases.
|
||||
//
|
||||
// Per the task scope the fields are left at their Go zero values for existing
|
||||
// blobs: with omitempty they must serialize as OMITTED (undefined in JS) until a
|
||||
// follow-up bead populates them from the BLE identity sidecar. IdentityResolved
|
||||
// is *bool so that a non-nil false (resolution attempted, failed) is distinct
|
||||
// from nil (unattempted) — omitempty only drops the nil case.
|
||||
func TestTrackedBlob_CanonicalIdentitySerialization(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
blob TrackedBlob
|
||||
wantKey map[string]bool // key → expected present in JSON
|
||||
}{
|
||||
{
|
||||
name: "zero-value blob omits all three (undefined)",
|
||||
blob: TrackedBlob{ID: 1, X: 1, Y: 2, Z: 3, Weight: 1},
|
||||
wantKey: map[string]bool{
|
||||
"personName": false, "assignedColor": false, "identityResolved": false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resolved identity emits camelCase keys",
|
||||
blob: TrackedBlob{
|
||||
ID: 2, PersonName: "Alice", AssignedColor: "#4488ff",
|
||||
IdentityResolved: ptrBool(true),
|
||||
},
|
||||
wantKey: map[string]bool{
|
||||
"personName": true, "assignedColor": true, "identityResolved": true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "failed resolution still emits identityResolved=false",
|
||||
blob: TrackedBlob{ID: 3, IdentityResolved: ptrBool(false)},
|
||||
wantKey: map[string]bool{
|
||||
"personName": false, "assignedColor": false, "identityResolved": true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw, err := json.Marshal(tc.blob)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
body := string(raw)
|
||||
for _, k := range canonicalIdentityKeys {
|
||||
got := strings.Contains(body, `"`+k+`"`)
|
||||
if got != tc.wantKey[k] {
|
||||
t.Errorf("key %q: present=%v, want %v (body=%s)", k, got, tc.wantKey[k], body)
|
||||
}
|
||||
}
|
||||
// Verify the tri-state value round-trips when present.
|
||||
if tc.wantKey["identityResolved"] {
|
||||
var decoded TrackedBlob
|
||||
if err := json.Unmarshal(raw, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if decoded.IdentityResolved == nil {
|
||||
t.Fatalf("identityResolved unexpectedly nil after round-trip (body=%s)", body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ptrBool is a small helper for the *bool tri-state IdentityResolved field.
|
||||
func ptrBool(b bool) *bool { return &b }
|
||||
Loading…
Add table
Reference in a new issue