From d0ac1b27b09d41d914fad5da1ec817dfb62ba021 Mon Sep 17 00:00:00 2001 From: jedarden Date: Mon, 6 Jul 2026 18:42:26 -0400 Subject: [PATCH] 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 --- .../internal/api/tracks_identity_test.go | 112 ++++++++++++++++++ .../automation/identity_fields_test.go | 74 ++++++++++++ .../internal/signal/identity_fields_test.go | 84 +++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 mothership/internal/api/tracks_identity_test.go create mode 100644 mothership/internal/automation/identity_fields_test.go create mode 100644 mothership/internal/signal/identity_fields_test.go diff --git a/mothership/internal/api/tracks_identity_test.go b/mothership/internal/api/tracks_identity_test.go new file mode 100644 index 0000000..212df3b --- /dev/null +++ b/mothership/internal/api/tracks_identity_test.go @@ -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 } diff --git a/mothership/internal/automation/identity_fields_test.go b/mothership/internal/automation/identity_fields_test.go new file mode 100644 index 0000000..5abc1c6 --- /dev/null +++ b/mothership/internal/automation/identity_fields_test.go @@ -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 } diff --git a/mothership/internal/signal/identity_fields_test.go b/mothership/internal/signal/identity_fields_test.go new file mode 100644 index 0000000..989691f --- /dev/null +++ b/mothership/internal/signal/identity_fields_test.go @@ -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 }