api: complete GET /api/portals/:id/crossings endpoint

- Add ID field to CrossingEvent struct for database row ID
- Update GetPortalCrossings to query and return the database id column
- Fix getPortalCrossings handler to populate all response fields:
  - ID: database row ID
  - PortalID: portal ID from the crossing event
  - BlobID: blob identifier
  - Direction: a_to_b or b_to_a
  - FromZone: source zone name
  - ToZone: destination zone name
  - Timestamp: crossing timestamp
  - Person: BLE identity if available
- Update tests to verify all fields are properly set

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-05 14:31:14 -04:00
parent a60777cc47
commit 62133dff5b
21 changed files with 12622 additions and 14 deletions

View file

@ -13,12 +13,12 @@
{"id":"bf-3gr58","title":"Simple mode (progressive disclosure)","description":"## Goal\nCard-based mobile-first UI for household members who don't need the full 3D engineering view.\n\n## Scope\n- No 3D scene — replaces with responsive card layout\n- Room cards: one per defined zone, shows occupancy count, person names (if BLE-identified), status color\n- Activity feed: chronological list of events (from timeline), tap to expand\n- Alert banner: fall detection, anomaly alerts, system warnings\n- Quick actions: arm/disarm security mode, trigger re-baseline, silence alerts\n- Sleep summary card: morning card showing last night's sleep data\n- Mobile-first: touch-friendly, no gestures required\n- Switching: toggle button in toolbar, per-user default stored in localStorage\n- Optional: simple mode requires no auth, expert mode requires PIN\n\n## Location\ndashboard/simple.html (new route)\ndashboard/static/js/simple-mode.js (new module)\n\n## Acceptance\n- Non-technical user can check occupancy without training\n- Room cards show current status with color coding\n- Activity feed shows recent events\n- Toggle between simple/expert mode works\n- Mobile-responsive layout","design":"","acceptance_criteria":"","notes":"","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-05T04:06:11.506623704Z","updated_at":"2026-05-05T04:06:11.506623704Z","source_repo":".","compaction_level":0}
{"id":"bf-3h1hk","title":"Playwright e2e acceptance scenarios AS-1 through AS-6","description":"Open bead bf-3a2py tracks acceptance scenario integration tests but they're not implemented. The tests/e2e/run.sh exists as a bash harness skeleton. The plan implies specific acceptance scenarios covering the full happy path: (AS-1) first-node provisioning → presence detection in <30s; (AS-2) multi-node localization accuracy; (AS-3) portal crossing detection; (AS-4) fall detection alert chain; (AS-5) OTA update flow; (AS-6) security mode arm/alert/disarm. These should be Playwright tests against a running mothership+sim stack.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-05-02T18:26:08.138470605Z","updated_at":"2026-05-02T18:26:38.599373498Z","closed_at":"2026-05-02T18:26:38.599373498Z","close_reason":"Duplicate of existing open bead bf-3a2py which already tracks acceptance scenario integration tests AS-1 through AS-6","source_repo":".","compaction_level":0}
{"id":"bf-3h5kd","title":"golangci-lint config and CI gate for mothership","description":"The open bead bf-w15bj tracks a CI pipeline gate for golangci-lint, but no .golangci.yml exists in the repo root or mothership/ directory. The sim Makefile references golangci-lint but only for cmd/sim. The main mothership codebase (~50+ packages) has no lint config. Needed: a .golangci.yml at mothership/ level with appropriate linter set (errcheck, staticcheck, unused, govet, etc.) and a CI step in the Argo WorkflowTemplate mta-my-way-build (or a separate lint-only workflow).","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-05-02T18:25:46.332413911Z","updated_at":"2026-05-02T18:26:44.610380579Z","closed_at":"2026-05-02T18:26:44.610380579Z","close_reason":"Duplicate of existing open bead bf-w15bj which already tracks golangci-lint CI gate with full config specification","source_repo":".","compaction_level":0}
{"id":"bf-3jv1x","title":"Complete portal crossings GET /api/portals/:id/crossings endpoint","description":"Plan's REST API spec defines GET /api/portals/:id/crossings with ?limit and ?before cursor pagination, returning [{timestamp, direction, person, blob_id}]. The portals package and CRUD endpoints exist (internal/api/zones.go handles portals), and portal_crossings is in the SQLite schema, but the crossings query endpoint is not implemented. This is needed for the timeline 'crossing log' quick action and for the portal detail view.","design":"","acceptance_criteria":"","notes":"","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-02T18:25:57.312746596Z","updated_at":"2026-05-02T18:25:57.312746596Z","source_repo":".","compaction_level":0}
{"id":"bf-3jv1x","title":"Complete portal crossings GET /api/portals/:id/crossings endpoint","description":"Plan's REST API spec defines GET /api/portals/:id/crossings with ?limit and ?before cursor pagination, returning [{timestamp, direction, person, blob_id}]. The portals package and CRUD endpoints exist (internal/api/zones.go handles portals), and portal_crossings is in the SQLite schema, but the crossings query endpoint is not implemented. This is needed for the timeline 'crossing log' quick action and for the portal detail view.","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":2,"issue_type":"task","assignee":"claude-code-glm-4.7-foxtrot","created_at":"2026-05-02T18:25:57.312746596Z","updated_at":"2026-05-05T18:14:05.471351033Z","source_repo":".","compaction_level":0}
{"id":"bf-3p4bj","title":"Command palette (Component 34)","description":"## Goal\nCtrl+K (Cmd+K on Mac) opens universal search and command interface. Invisible to casual users, indispensable for power users.\n\n## Scope\nSearch:\n- 'kitchen' → Kitchen zone, kitchen nodes, kitchen automations, recent kitchen events\n- 'alice' → Alice's current location, today's timeline, sleep report, BLE devices\n- 'node 3' → Node details, diagnostics, link health\n\nNavigate time:\n- 'last night 2am' → timeline jumps there\n- 'yesterday kitchen' → filters timeline to kitchen events yesterday\n- 'this morning' → jumps to first detection today\n\nExecute commands:\n- 'update all nodes', 're-baseline kitchen', 'add node', 'arm security', 'disarm security'\n- 'dark mode'/'light mode', 'export config', 'restart node kitchen-north'\n\nGet help:\n- 'help fall detection', 'why false positive', 'troubleshoot kitchen'\n\nBehavior:\n- Fuzzy matching: 'flr pln' matches 'Floor Plan settings'\n- Recently used commands appear first\n- Results show keyboard shortcut hints where applicable\n- Escape closes, Enter executes top result\n- Works in expert mode only\n\n## Implementation\nFrontend-only component\nCommand registry maps keywords to actions\nSearch runs against: zone names, person names, node names, setting names, help topics\n\n## Acceptance\n- Ctrl+K/Cmd+K opens command palette\n- Search finds zones, people, nodes, settings, help topics\n- Commands execute correctly\n- Time navigation jumps to correct moments\n- Fuzzy matching works\n- Escape closes palette","design":"","acceptance_criteria":"","notes":"","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-05T04:06:11.571696739Z","updated_at":"2026-05-05T04:06:11.571696739Z","source_repo":".","compaction_level":0}
{"id":"bf-4truh","title":"Comprehensive notification system tests (open bead spaxel-40tl expansion)","description":"Open bead spaxel-40tl 'Write comprehensive tests for notification system' is open. The notify package (internal/notify/) has ntfy.go, pushover.go, webhook.go but tests are missing or incomplete. Needs tests covering: batching logic (30s dedup window), quiet hours gate (suppress non-critical during quiet window), morning digest aggregation, delivery retry logic, channel enable/disable, test-notification endpoint, and notification history API. The existing service_enhanced.go has complex batching logic that needs coverage.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-05-02T18:26:14.365679205Z","updated_at":"2026-05-02T18:26:51.067231767Z","closed_at":"2026-05-02T18:26:51.067231767Z","close_reason":"Duplicate of existing open bead spaxel-40tl which already comprehensively tracks notification system tests","source_repo":".","compaction_level":0}
{"id":"bf-55sg5","title":"Mobile-responsive expert mode","description":"## Goal\nMake expert mode fully functional on mobile devices with touch gestures.\n\n## Scope\n- Touch orbit/pan/zoom: single-finger rotate, two-finger pan, pinch to zoom (already supported by Three.js OrbitControls)\n- Hamburger menu for panels: collapsible sidebar for fleet status, settings, zones, triggers\n- Responsive layout: panels slide in from bottom on mobile, from right on desktop\n- Touch-optimized buttons: minimum 44×44px tap targets\n- No hover-dependent UI: all interactions work with tap\n- Mobile-specific shortcuts: long-press for context menu (replaces right-click)\n\n## Location\ndashboard/static/js/mobile.js (new module)\ndashboard/static/css/mobile.css (new stylesheet)\n\n## Acceptance\n- Three.js scene responds to touch gestures (orbit, pan, zoom)\n- Hamburger menu opens panel navigation\n- Panels slide in from bottom on mobile\n- All buttons are touch-friendly (≥44px)\n- No features require hover\n- Long-press context menu works on mobile","design":"","acceptance_criteria":"","notes":"","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-05T04:06:29.813640292Z","updated_at":"2026-05-05T04:06:29.813640292Z","source_repo":".","compaction_level":0}
{"id":"bf-59me3","title":"GET /api/status and GET /api/occupancy endpoints","description":"Plan's REST API spec defines: (1) GET /api/status returning {version, nodes, blobs, uptime_s, detection_quality} and (2) GET /api/occupancy returning {zones:{<name>:{count, people:[]}}}. Neither endpoint is registered in main.go. /api/blobs exists. These are simple read-only endpoints that dashboard and HA users would expect for quick system checks and occupancy queries without WebSocket.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","assignee":"claude-code-glm-4.7-foxtrot","created_at":"2026-05-02T18:25:26.769707158Z","updated_at":"2026-05-05T17:40:16.692260524Z","closed_at":"2026-05-05T17:40:16.692260524Z","close_reason":"Completed","source_repo":".","compaction_level":0}
{"id":"bf-5fo3h","title":"Node disable/enable API endpoints","description":"Plan's REST API spec defines POST /api/nodes/:mac/disable (sets role to IDLE) and POST /api/nodes/:mac/enable (restores prior role). The fleet handler (internal/fleet/handler.go) has identify, reboot, OTA, position, role endpoints but no dedicated disable/enable. The quick-actions.js context menu exposes 'Disable / Enable' for nodes but there's no corresponding backend route.","design":"","acceptance_criteria":"","notes":"","status":"in_progress","priority":2,"issue_type":"task","assignee":"claude-code-glm-4.7-foxtrot","created_at":"2026-05-02T18:25:37.488896455Z","updated_at":"2026-05-05T18:00:00.932422629Z","source_repo":".","compaction_level":0}
{"id":"bf-5fo3h","title":"Node disable/enable API endpoints","description":"Plan's REST API spec defines POST /api/nodes/:mac/disable (sets role to IDLE) and POST /api/nodes/:mac/enable (restores prior role). The fleet handler (internal/fleet/handler.go) has identify, reboot, OTA, position, role endpoints but no dedicated disable/enable. The quick-actions.js context menu exposes 'Disable / Enable' for nodes but there's no corresponding backend route.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","assignee":"claude-code-glm-4.7-foxtrot","created_at":"2026-05-02T18:25:37.488896455Z","updated_at":"2026-05-05T18:13:57.459444611Z","closed_at":"2026-05-05T18:13:57.459444611Z","close_reason":"Completed","source_repo":".","compaction_level":0}
{"id":"bf-5o576","title":"Fuzz tests for binary frame parser and JSON protocol","description":"Open bead bf-3d55l tracks this but has been sitting open with no implementation started. The ingestion frame parser (internal/ingestion/frame.go) and JSON message parser (internal/ingestion/message.go) parse untrusted input from ESP32 nodes. Need Go fuzz tests (testing.F) in frame_fuzz_test.go and message_fuzz_test.go covering: malformed header lengths, n_sub overflow, invalid channel values, truncated payloads, invalid JSON type discriminators, and extra fields.","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-05-02T18:25:51.799073946Z","updated_at":"2026-05-02T18:26:32.910185523Z","closed_at":"2026-05-02T18:26:32.910185523Z","close_reason":"Duplicate of existing open bead bf-3d55l which already tracks fuzz tests for binary frame parser and JSON protocol","source_repo":".","compaction_level":0}
{"id":"bf-5txbb","title":"Fleet status page","description":"## Goal\nFull table view of all registered nodes with all metrics, bulk actions, camera fly-to on click.\n\n## Scope\nTable columns:\n- Name: user-assigned friendly name\n- MAC: hardware address\n- Role: TX/RX/TX_RX — editable dropdown\n- Position: (x, y, z) — click to highlight node in 3D view and fly camera to it\n- Firmware: version string + 'Update available' badge\n- RSSI: last reported WiFi signal strength\n- Status: ONLINE/STALE/OFFLINE with colored indicator\n- Uptime: time since last boot\n- Actions: Restart, Update, Remove, Identify (blink LED)\n\nGlobal actions:\n- Update All (rolling OTA)\n- Re-baseline All\n- Export Config\n- Import Config\n\n## Location\ndashboard/static/js/fleet.js (new module, extract from existing code)\ninternal/api/fleet.go (already exists)\n\n## Acceptance\n- Table shows all registered nodes\n- Click position → camera flies to node in 3D view\n- Role dropdown changes node role\n- Actions execute correctly\n- Bulk actions work on all nodes\n- Export/import config works","design":"","acceptance_criteria":"","notes":"","status":"open","priority":2,"issue_type":"task","created_at":"2026-05-05T04:06:29.834674580Z","updated_at":"2026-05-05T04:06:29.834674580Z","source_repo":".","compaction_level":0}
{"id":"bf-5vhya","title":"CI: pipeline timing benchmark gate","description":"## Goal\nAdd a benchmark that enforces the fusion loop timing budget as a CI quality gate, per plan §Quality Gates / Definition of Done (item 9).\n\n## What to build\n\nFile: internal/localizer/fusion/timing_budget_test.go\n\nRun the full fusion pipeline (phase sanitization → feature extraction → Fresnel accumulation → peak extraction → UKF update) against synthetic CSI data from spaxel-sim output.\n\nAssert:\n- Median fusion iteration < 15 ms over 600 iterations (60 seconds at 10 Hz)\n- P99 < 40 ms (hard limit)\n\n## CI integration\nAdd to Argo Workflows CI step after go test ./...:\n go test -bench=BenchmarkFusionLoop -benchtime=60s -count=1 ./internal/localizer/fusion/ | tee /tmp/bench.txt\n # fail if median exceeds 15ms threshold\n\n## Acceptance\n- Benchmark runs in the Argo CI workflow\n- Workflow fails if median latency exceeds 15 ms on the CI runner (allowance: 2x for slower hardware → 30 ms CI threshold, 15 ms production target)","design":"","acceptance_criteria":"","notes":"","status":"closed","priority":2,"issue_type":"task","assignee":"claude-code-glm-4.7-golf","created_at":"2026-05-02T12:09:00.487025943Z","updated_at":"2026-05-04T14:25:01.352506963Z","closed_at":"2026-05-04T14:25:01.352506963Z","close_reason":"Implementation already complete in commit 7afbdc9. The timing budget benchmark:\n\n1. File: mothership/internal/localizer/fusion/timing_budget_test.go\n - Runs full fusion pipeline (phase sanitization → feature extraction → Fresnel accumulation → peak extraction → UKF update)\n - Uses synthetic CSI data simulating 4 nodes with 2 walkers\n - Runs 600 iterations (60 seconds at 10 Hz)\n\n2. Timing constraints enforced:\n - Median fusion iteration: 2.6ms (well below 15ms production target and 30ms CI threshold)\n - P99: ~10ms (well below 40ms hard limit)\n\n3. CI integration: .github/workflows/benchmark-ci.yml\n - Benchmark runs on every push/PR to main\n - Workflow fails if median exceeds 30ms (CI threshold)\n - Workflow fails if P99 exceeds 40ms (hard limit)\n\nAll acceptance criteria met.","source_repo":".","compaction_level":0}

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-232u3",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 0,
"outcome": "success",
"duration_ms": 259562,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-05T17:25:17.761003555Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-3jv1x",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 1,
"outcome": "failure",
"duration_ms": 391175,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-05T18:20:37.009020406Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-59me3",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 0,
"outcome": "success",
"duration_ms": 279366,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-05T17:40:24.598940604Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-5fo3h",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 0,
"outcome": "success",
"duration_ms": 175318,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-05T18:14:05.387953571Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
{
"bead_id": "bf-m6f5g",
"agent": "claude-code-glm-4.7",
"provider": "zai",
"model": "glm-4.7",
"exit_code": 0,
"outcome": "success",
"duration_ms": 248314,
"input_tokens": null,
"output_tokens": null,
"cost_usd": null,
"captured_at": "2026-05-05T17:55:09.314500736Z",
"trace_format": "claude_json",
"pruned": false,
"template_version": null
}

View file

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
9d656ab93eccbecebebaa4bf9ccc07360fe28e33
15c082c344dee29cf2c196ac041ff77021130f6d

View file

@ -636,6 +636,32 @@ func (h *ZonesHandler) getPortalCrossings(w http.ResponseWriter, r *http.Request
}
}
events := h.mgr.GetRecentCrossings(limit)
writeJSON(w, http.StatusOK, events)
var before int64
if beforeStr := r.URL.Query().Get("before"); beforeStr != "" {
if n, err := strconv.ParseInt(beforeStr, 10, 64); err == nil {
before = n
}
}
events := h.mgr.GetPortalCrossings(id, limit, before)
response := make([]crossingResponse, 0, len(events))
for _, e := range events {
direction := "a_to_b"
if e.Direction == -1 {
direction = "b_to_a"
}
response = append(response, crossingResponse{
ID: e.ID,
PortalID: e.PortalID,
BlobID: e.BlobID,
Direction: direction,
FromZone: e.FromZone,
ToZone: e.ToZone,
Timestamp: e.Timestamp,
Person: e.Identity,
})
}
writeJSON(w, http.StatusOK, response)
}

View file

@ -6,9 +6,11 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/spaxel/mothership/internal/dashboard"
@ -822,23 +824,161 @@ func TestGetPortalCrossings(t *testing.T) {
})
tests := []struct {
name string
portalID string
wantCode int
name string
portalID string
wantCode int
query string // query string to append
wantCount int // expected number of crossings in response
wantFields bool // whether to verify field presence
}{
{"existing portal", "p1", http.StatusOK},
{"nonexistent portal", "nope", http.StatusNotFound},
{"existing portal - empty", "p1", http.StatusOK, "", 0, false},
{"nonexistent portal", "nope", http.StatusNotFound, "", 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := setupRouter(h)
req := httptest.NewRequest("GET", "/api/portals/"+tt.portalID+"/crossings", nil)
req := httptest.NewRequest("GET", "/api/portals/"+tt.portalID+"/crossings"+tt.query, nil)
rr := httptest.NewRecorder()
r.ServeHTTP(rr, req)
if rr.Code != tt.wantCode {
t.Errorf("Expected %d, got %d", tt.wantCode, rr.Code)
t.Errorf("Expected %d, got %d: %s", tt.wantCode, rr.Code, rr.Body.String())
}
if tt.wantCode == http.StatusOK {
var result []crossingResponse
if err := json.NewDecoder(rr.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if len(result) != tt.wantCount {
t.Errorf("Expected %d crossings, got %d", tt.wantCount, len(result))
}
}
})
}
}
// TestGetPortalCrossingsWithData tests GET /api/portals/{id}/crossings with actual crossing data.
func TestGetPortalCrossingsWithData(t *testing.T) {
h, cleanup := newTestHandler(t)
defer cleanup()
h.mgr.CreateZone(&zones.Zone{ID: "z1", Name: "Kitchen", MinX: 0, MinY: 0, MinZ: 0, MaxX: 1, MaxY: 1, MaxZ: 1})
h.mgr.CreateZone(&zones.Zone{ID: "z2", Name: "Hallway", MinX: 1, MinY: 0, MinZ: 0, MaxX: 2, MaxY: 1, MaxZ: 1})
h.mgr.CreatePortal(&zones.Portal{
ID: "p1", Name: "Kitchen Door", ZoneAID: "z1", ZoneBID: "z2",
P1X: 1, P1Y: 0, P1Z: 0, P2X: 1, P2Y: 0.5, P2Z: 0, P3X: 1, P3Y: 0.5, P3Z: 1,
Width: 1, Height: 1,
})
// Insert test crossing data directly into the database
nowMs := time.Now().UnixMilli()
_, err := h.mgr.DB().Exec(`
INSERT INTO crossing_events (portal_id, blob_id, direction, from_zone, to_zone, timestamp, identity)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, "p1", 1, 1, "z1", "z2", nowMs-3000, "Alice")
if err != nil {
t.Fatalf("Failed to insert crossing: %v", err)
}
_, err = h.mgr.DB().Exec(`
INSERT INTO crossing_events (portal_id, blob_id, direction, from_zone, to_zone, timestamp, identity)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, "p1", 2, -1, "z2", "z1", nowMs-2000, "")
if err != nil {
t.Fatalf("Failed to insert crossing: %v", err)
}
tests := []struct {
name string
query string
wantCount int
wantDirs []string // expected directions in order
}{
{
name: "default parameters",
query: "",
wantCount: 2,
wantDirs: []string{"b_to_a", "a_to_b"},
},
{
name: "limit 1",
query: "?limit=1",
wantCount: 1,
wantDirs: []string{"b_to_a"},
},
{
name: "before cursor pagination",
query: "?before=" + strconv.FormatInt(nowMs-2500, 10),
wantCount: 1,
wantDirs: []string{"a_to_b"},
},
{
name: "limit with before",
query: "?limit=1&before=" + strconv.FormatInt(nowMs-2500, 10),
wantCount: 1,
wantDirs: []string{"a_to_b"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := setupRouter(h)
req := httptest.NewRequest("GET", "/api/portals/p1/crossings"+tt.query, 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 []crossingResponse
if err := json.NewDecoder(rr.Body).Decode(&result); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if len(result) != tt.wantCount {
t.Errorf("Expected %d crossings, got %d", tt.wantCount, len(result))
}
// Check all fields
for i, r := range result {
// Verify ID is set (database row ID should be >= 1)
if r.ID == 0 {
t.Errorf("Crossing %d: ID should be non-zero", i)
}
// Verify PortalID
if r.PortalID != "p1" {
t.Errorf("Crossing %d: expected PortalID 'p1', got %s", i, r.PortalID)
}
// Verify BlobID
if r.BlobID == 0 {
t.Errorf("Crossing %d: BlobID should be non-zero", i)
}
// Verify direction
gotDirs := make([]string, len(result))
gotDirs[i] = r.Direction
// Verify Timestamp
if r.Timestamp.IsZero() {
t.Errorf("Crossing %d: Timestamp should not be zero", i)
}
// Verify zone fields
if r.FromZone == "" {
t.Errorf("Crossing %d: FromZone should not be empty", i)
}
if r.ToZone == "" {
t.Errorf("Crossing %d: ToZone should not be empty", i)
}
}
// Check directions match expected
gotDirs := make([]string, len(result))
for i, r := range result {
gotDirs[i] = r.Direction
}
if !sliceEqual(gotDirs, tt.wantDirs) {
t.Errorf("Got directions %v, want %v", gotDirs, tt.wantDirs)
}
})
}
@ -1322,3 +1462,16 @@ func TestNoBroadcastWithoutBroadcaster(t *testing.T) {
t.Fatalf("Expected 201 without broadcaster, got %d: %s", rr.Code, rr.Body.String())
}
}
// sliceEqual compares two string slices for equality.
func sliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View file

@ -77,6 +77,7 @@ type Portal struct {
// CrossingEvent represents a detected portal crossing.
type CrossingEvent struct {
ID int64 `json:"id"` // Database row ID
PortalID string `json:"portal_id"`
BlobID int `json:"blob_id"`
Direction int `json:"direction"` // 1 = A->B, -1 = B->A
@ -319,6 +320,11 @@ func (m *Manager) Close() error {
return m.db.Close()
}
// DB returns the underlying SQLite database connection for testing and direct access.
func (m *Manager) DB() *sql.DB {
return m.db
}
// SetOnCrossing sets the callback for crossing events.
func (m *Manager) SetOnCrossing(cb func(CrossingEvent)) {
m.mu.Lock()
@ -864,7 +870,7 @@ func (m *Manager) GetRecentCrossings(limit int) []CrossingEvent {
defer m.mu.RUnlock()
rows, err := m.db.Query(`
SELECT portal_id, blob_id, direction, from_zone, to_zone, timestamp, identity
SELECT id, portal_id, blob_id, direction, from_zone, to_zone, timestamp, identity
FROM crossing_events
ORDER BY timestamp DESC
LIMIT ?
@ -879,7 +885,49 @@ func (m *Manager) GetRecentCrossings(limit int) []CrossingEvent {
for rows.Next() {
var event CrossingEvent
var ts int64
if err := rows.Scan(&event.PortalID, &event.BlobID, &event.Direction, &event.FromZone, &event.ToZone, &ts, &event.Identity); err != nil {
if err := rows.Scan(&event.ID, &event.PortalID, &event.BlobID, &event.Direction, &event.FromZone, &event.ToZone, &ts, &event.Identity); err != nil {
continue
}
event.Timestamp = time.UnixMilli(ts)
events = append(events, event)
}
return events
}
// GetPortalCrossings returns crossing events for a specific portal with cursor pagination.
// The before parameter is a Unix millisecond timestamp for cursor pagination (exclusive).
// If before is 0, returns the most recent crossings.
func (m *Manager) GetPortalCrossings(portalID string, limit int, before int64) []CrossingEvent {
m.mu.RLock()
defer m.mu.RUnlock()
query := `
SELECT id, portal_id, blob_id, direction, from_zone, to_zone, timestamp, identity
FROM crossing_events
WHERE portal_id = ?
`
args := []interface{}{portalID}
if before > 0 {
query += ` AND timestamp < ?`
args = append(args, before)
}
query += ` ORDER BY timestamp DESC LIMIT ?`
args = append(args, limit)
rows, err := m.db.Query(query, args...)
if err != nil {
log.Printf("[WARN] Failed to query portal crossings: %v", err)
return nil
}
defer rows.Close() //nolint:errcheck
var events []CrossingEvent
for rows.Next() {
var event CrossingEvent
var ts int64
if err := rows.Scan(&event.ID, &event.PortalID, &event.BlobID, &event.Direction, &event.FromZone, &event.ToZone, &ts, &event.Identity); err != nil {
continue
}
event.Timestamp = time.UnixMilli(ts)

View file

@ -1361,6 +1361,198 @@ func TestZoneTransitionWebSocket_Broadcast(t *testing.T) {
}
}
// --- GetPortalCrossings tests ---
func TestGetPortalCrossings(t *testing.T) {
tests := []struct {
name string
crossings []struct {
portalID string
blobID int
dir int // 1 = A->B, -1 = B->A
fromZone string
toZone string
tsMs int64
identity string
}
queryPortal string
limit int
before int64
wantCount int
wantDirs []string // expected direction strings in order
}{
{
name: "returns all crossings for portal",
crossings: []struct {
portalID string
blobID int
dir int
fromZone string
toZone string
tsMs int64
identity string
}{
{"p1", 1, 1, "kitchen", "hallway", 3000, "Alice"},
{"p1", 2, -1, "hallway", "kitchen", 2000, ""},
{"p1", 1, 1, "kitchen", "hallway", 1000, "Bob"},
{"p2", 3, 1, "living", "hallway", 2500, "Charlie"},
},
queryPortal: "p1",
limit: 50,
before: 0,
wantCount: 3,
wantDirs: []string{"a_to_b", "b_to_a", "a_to_b"},
},
{
name: "respects limit parameter",
crossings: []struct {
portalID string
blobID int
dir int
fromZone string
toZone string
tsMs int64
identity string
}{
{"p1", 1, 1, "kitchen", "hallway", 3000, "Alice"},
{"p1", 2, -1, "hallway", "kitchen", 2000, ""},
{"p1", 3, 1, "kitchen", "hallway", 1000, "Bob"},
},
queryPortal: "p1",
limit: 2,
before: 0,
wantCount: 2,
wantDirs: []string{"a_to_b", "b_to_a"},
},
{
name: "respects before cursor pagination",
crossings: []struct {
portalID string
blobID int
dir int
fromZone string
toZone string
tsMs int64
identity string
}{
{"p1", 1, 1, "kitchen", "hallway", 3000, "Alice"},
{"p1", 2, -1, "hallway", "kitchen", 2000, ""},
{"p1", 3, 1, "kitchen", "hallway", 1000, "Bob"},
},
queryPortal: "p1",
limit: 50,
before: 2500, // only return events before 2500ms
wantCount: 2,
wantDirs: []string{"b_to_a", "a_to_b"},
},
{
name: "returns empty for nonexistent portal",
crossings: []struct {
portalID string
blobID int
dir int
fromZone string
toZone string
tsMs int64
identity string
}{
{"p1", 1, 1, "kitchen", "hallway", 1000, "Alice"},
},
queryPortal: "nonexistent",
limit: 50,
before: 0,
wantCount: 0,
wantDirs: []string{},
},
{
name: "returns empty when no crossings",
crossings: []struct {
portalID string
blobID int
dir int
fromZone string
toZone string
tsMs int64
identity string
}{},
queryPortal: "p1",
limit: 50,
before: 0,
wantCount: 0,
wantDirs: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m, cleanup := setupManager(t, time.UTC)
defer cleanup()
// Create portal
if err := m.CreatePortal(&Portal{
ID: "p1", Name: "Kitchen Door",
ZoneAID: "kitchen", ZoneBID: "hallway",
P1X: 0, P1Y: 0, P1Z: 0,
P2X: 1, P2Y: 0, P2Z: 0,
P3X: 1, P3Y: 0, P3Z: 1,
Width: 1, Height: 1,
}); err != nil {
t.Fatalf("CreatePortal: %v", err)
}
// Insert crossings directly into DB
for _, c := range tt.crossings {
_, err := m.db.Exec(`
INSERT INTO crossing_events (portal_id, blob_id, direction, from_zone, to_zone, timestamp, identity)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, c.portalID, c.blobID, c.dir, c.fromZone, c.toZone, c.tsMs, c.identity)
if err != nil {
t.Fatalf("Insert crossing: %v", err)
}
}
// Query crossings
events := m.GetPortalCrossings(tt.queryPortal, tt.limit, tt.before)
if len(events) != tt.wantCount {
t.Errorf("got %d events, want %d", len(events), tt.wantCount)
}
// Check directions
gotDirs := make([]string, len(events))
for i, e := range events {
wantDir := "a_to_b"
if e.Direction == -1 {
wantDir = "b_to_a"
}
gotDirs[i] = wantDir
// Verify portal ID matches
if e.PortalID != tt.queryPortal {
t.Errorf("event[%d].PortalID = %s, want %s", i, e.PortalID, tt.queryPortal)
}
}
if !sliceEqual(gotDirs, tt.wantDirs) {
t.Errorf("got directions %v, want %v", gotDirs, tt.wantDirs)
}
})
}
}
// sliceEqual compares two string slices for equality.
func sliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// --- Helper ---
// nowMsSinceMidnight returns a Unix ms timestamp the given duration after midnight today.