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 in a612584 + 01a415d and
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:
jedarden 2026-07-06 18:42:26 -04:00
parent 1871a8a42c
commit d0ac1b27b0
3 changed files with 270 additions and 0 deletions

View 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 }

View 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 }

View 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 }