spaxel/mothership/internal/volume/shape_test.go
jedarden e74834a3bf feat: implement webhook action firing & fault tolerance for automations
Backend: HTTP client with 5s timeout, fire-and-forget webhook delivery,
4xx disables trigger with error message, 5xx/timeout increments error count,
test endpoint, enable/disable endpoints, webhook_log audit table,
WebSocket alert broadcasting on trigger errors.

Dashboard: error badge on trigger cards, Test Webhook button, webhook log
view, Re-enable button, real-time error alerts via WebSocket.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:39:51 -04:00

1579 lines
40 KiB
Go

// Package volume provides tests for trigger volume geometry and point-in-volume testing.
package volume
import (
"testing"
"time"
)
// TestShapeJSON_BoxInside tests point-inside-box detection.
func TestShapeJSON_BoxInside(t *testing.T) {
shape := ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0),
Y: float64Ptr(0),
Z: float64Ptr(0),
W: float64Ptr(2),
D: float64Ptr(2),
H: float64Ptr(1),
}
tests := []struct {
name string
point Point3D
expected bool
}{
{"origin inside", Point3D{0, 0, 0}, true},
{"center inside", Point3D{1, 0.5, 1}, true},
{"corner inside", Point3D{1.999, 0.999, 1.999}, true},
{"outside x-", Point3D{-0.001, 0.5, 1}, false},
{"outside x+", Point3D{2.001, 0.5, 1}, false},
{"outside y-", Point3D{1, -0.001, 1}, false},
{"outside y+", Point3D{1, 1.001, 1}, false},
{"outside z-", Point3D{1, 0.5, -0.001}, false},
{"outside z+", Point3D{1, 0.5, 2.001}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shape.IsInside(tt.point)
if got != tt.expected {
t.Errorf("IsInside(%v) = %v, want %v", tt.point, got, tt.expected)
}
})
}
}
// TestShapeJSON_CylinderInside tests point-inside-cylinder detection.
func TestShapeJSON_CylinderInside(t *testing.T) {
shape := ShapeJSON{
Type: ShapeCylinder,
CX: float64Ptr(0),
CY: float64Ptr(0),
Z: float64Ptr(0),
R: float64Ptr(1),
H: float64Ptr(2),
}
tests := []struct {
name string
point Point3D
expected bool
}{
{"center bottom", Point3D{0, 0, 0}, true},
{"center top", Point3D{0, 0, 1.999}, true},
{"edge inside", Point3D{0.999, 0, 1}, true},
{"inside at height", Point3D{0, 0, 1}, true},
{"outside radius", Point3D{1.001, 0, 1}, false},
{"outside height-", Point3D{0, 0, -0.001}, false},
{"outside height+", Point3D{0, 0, 2.001}, false},
{"diagonal outside", Point3D{0.8, 0.8, 1}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shape.IsInside(tt.point)
if got != tt.expected {
t.Errorf("IsInside(%v) = %v, want %v", tt.point, got, tt.expected)
}
})
}
}
// TestShapeJSON_InvalidShape tests that invalid shapes return false.
func TestShapeJSON_InvalidShape(t *testing.T) {
tests := []struct {
name string
shape ShapeJSON
point Point3D
}{
{
"missing box fields",
ShapeJSON{Type: ShapeBox, X: float64Ptr(0)},
Point3D{0, 0, 0},
},
{
"missing cylinder fields",
ShapeJSON{Type: ShapeCylinder, CX: float64Ptr(0)},
Point3D{0, 0, 0},
},
{
"unknown type",
ShapeJSON{Type: "unknown"},
Point3D{0, 0, 0},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.shape.IsInside(tt.point)
if got {
t.Errorf("IsInside(%v) = true, want false for invalid shape", tt.point)
}
})
}
}
// TestStore_EvaluateEnter tests enter trigger evaluation.
func TestStore_EvaluateEnter(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "test enter",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0),
Y: float64Ptr(0),
Z: float64Ptr(0),
W: float64Ptr(1),
D: float64Ptr(1),
H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
// Blob outside - should not fire
blobsOutside := []BlobPos{{ID: 1, X: 2, Y: 2, Z: 2}}
fired := store.Evaluate(blobsOutside, time.Now())
if len(fired) != 0 {
t.Errorf("Expected 0 firings, got %d", len(fired))
}
// Blob enters - should fire once
blobsEnter := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
fired = store.Evaluate(blobsEnter, time.Now())
if len(fired) != 1 {
t.Errorf("Expected 1 firing, got %d", len(fired))
}
if fired[0] != id {
t.Errorf("Expected trigger %s to fire, got %s", id, fired[0])
}
// Blob still inside - should not fire again
fired = store.Evaluate(blobsEnter, time.Now())
if len(fired) != 0 {
t.Errorf("Expected 0 firings (blob already inside), got %d", len(fired))
}
// Blob leaves and re-enters - should fire again
blobsOutside = []BlobPos{{ID: 1, X: 2, Y: 2, Z: 2}}
store.Evaluate(blobsOutside, time.Now())
blobsEnter = []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
fired = store.Evaluate(blobsEnter, time.Now())
if len(fired) != 1 {
t.Errorf("Expected 1 firing (re-entry), got %d", len(fired))
}
}
// TestStore_EvaluateLeave tests leave trigger evaluation.
func TestStore_EvaluateLeave(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "test leave",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0),
Y: float64Ptr(0),
Z: float64Ptr(0),
W: float64Ptr(1),
D: float64Ptr(1),
H: float64Ptr(1),
},
Condition: "leave",
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
// Blob inside first
blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
store.Evaluate(blobsInside, time.Now())
// Blob leaves - should fire
blobsOutside := []BlobPos{{ID: 1, X: 2, Y: 2, Z: 2}}
fired := store.Evaluate(blobsOutside, time.Now())
if len(fired) != 1 {
t.Errorf("Expected 1 firing, got %d", len(fired))
}
if fired[0] != id {
t.Errorf("Expected trigger %s to fire, got %s", id, fired[0])
}
// Blob still outside - should not fire again
fired = store.Evaluate(blobsOutside, time.Now())
if len(fired) != 0 {
t.Errorf("Expected 0 firings (blob already outside), got %d", len(fired))
}
}
// TestStore_EvaluateDwell tests dwell trigger evaluation.
// Per spec: fires once per entry; re-fires after blob leaves and re-enters.
func TestStore_EvaluateDwell(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
durationSec := 1
trigger := &Trigger{
Name: "test dwell",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0),
Y: float64Ptr(0),
Z: float64Ptr(0),
W: float64Ptr(1),
D: float64Ptr(1),
H: float64Ptr(1),
},
Condition: "dwell",
ConditionParams: ConditionParams{
DurationS: &durationSec,
},
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
blobsOutside := []BlobPos{{ID: 1, X: 5, Y: 5, Z: 5}}
now := time.Now()
// First evaluation - blob enters, no fire yet
fired := store.Evaluate(blobsInside, now)
if len(fired) != 0 {
t.Errorf("Expected 0 firings (just entered), got %d", len(fired))
}
// Before dwell threshold - no fire
fired = store.Evaluate(blobsInside, now.Add(500*time.Millisecond))
if len(fired) != 0 {
t.Errorf("Expected 0 firings (before threshold), got %d", len(fired))
}
// After dwell threshold - should fire
fired = store.Evaluate(blobsInside, now.Add(time.Duration(durationSec)*time.Second))
if len(fired) != 1 {
t.Errorf("Expected 1 firing (after threshold), got %d", len(fired))
}
if fired[0] != id {
t.Errorf("Expected trigger %s to fire, got %s", id, fired[0])
}
// Still inside after fire - should NOT fire again (must exit first)
fired = store.Evaluate(blobsInside, now.Add(time.Duration(durationSec)*time.Second+5*time.Second))
if len(fired) != 0 {
t.Errorf("Expected 0 firings (still inside, must exit first), got %d", len(fired))
}
// Blob leaves and stays outside for a bit
fired = store.Evaluate(blobsOutside, now.Add(time.Duration(durationSec)*time.Second+6*time.Second))
if len(fired) != 0 {
t.Errorf("Expected 0 firings (blob outside), got %d", len(fired))
}
// Blob re-enters and stays for duration threshold - should fire again
reEntry := now.Add(time.Duration(durationSec)*time.Second+10*time.Second)
store.Evaluate(blobsInside, reEntry) // enters
fired = store.Evaluate(blobsInside, reEntry.Add(time.Duration(durationSec)*time.Second))
if len(fired) != 1 {
t.Errorf("Expected 1 firing (re-entry after dwell), got %d", len(fired))
}
}
// TestStore_EvaluateDwell_Accuracy tests that dwell fires at correct time ±1s.
func TestStore_EvaluateDwell_Accuracy(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
durationSec := 3
trigger := &Trigger{
Name: "test dwell accuracy",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "dwell",
ConditionParams: ConditionParams{
DurationS: &durationSec,
},
Enabled: true,
}
_, err = store.Create(trigger)
if err != nil {
t.Fatal(err)
}
blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
now := time.Now()
// Blob enters
store.Evaluate(blobsInside, now)
// Check every 100ms around the threshold
var firedAt time.Time
for offset := 0; offset <= 4000; offset += 100 {
checkTime := now.Add(time.Duration(offset) * time.Millisecond)
fired := store.Evaluate(blobsInside, checkTime)
if len(fired) > 0 {
firedAt = checkTime
break
}
}
if firedAt.IsZero() {
t.Fatal("Expected trigger to fire within dwell duration")
}
actualDuration := firedAt.Sub(now)
// Should fire within durationSec ± 200ms (100ms evaluation granularity)
if actualDuration < time.Duration(durationSec-1)*time.Second || actualDuration > time.Duration(durationSec+1)*time.Second {
t.Errorf("Dwell fired at %v, expected ~%v ± 1s", actualDuration, time.Duration(durationSec)*time.Second)
}
}
// TestStore_EvaluateDwell_Cylinder tests dwell with a cylinder volume.
func TestStore_EvaluateDwell_Cylinder(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
durationSec := 1
trigger := &Trigger{
Name: "test dwell cylinder",
Shape: ShapeJSON{
Type: ShapeCylinder,
CX: float64Ptr(0),
CY: float64Ptr(0),
Z: float64Ptr(0),
R: float64Ptr(1),
H: float64Ptr(2),
},
Condition: "dwell",
ConditionParams: ConditionParams{
DurationS: &durationSec,
},
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
now := time.Now()
// Blob at center of cylinder - enters
blobsInside := []BlobPos{{ID: 1, X: 0, Y: 0, Z: 1.0}}
store.Evaluate(blobsInside, now)
// After duration - should fire
fired := store.Evaluate(blobsInside, now.Add(time.Duration(durationSec)*time.Second))
if len(fired) != 1 || fired[0] != id {
t.Errorf("Expected trigger %s to fire after dwell in cylinder, got %v", id, fired)
}
}
// TestStore_EvaluateLeave_BlobDisappears tests that leave fires when a tracked blob vanishes.
func TestStore_EvaluateLeave_BlobDisappears(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "test leave disappear",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "leave",
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
now := time.Now()
// Blob enters
blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
store.Evaluate(blobsInside, now)
// Blob disappears (not in the blobs list at all) - should fire leave
blobsEmpty := []BlobPos{}
fired := store.Evaluate(blobsEmpty, now.Add(1*time.Second))
if len(fired) != 1 || fired[0] != id {
t.Errorf("Expected trigger %s to fire when blob disappears, got %v", id, fired)
}
}
// TestStore_EvaluateVacant_Cancelled tests that vacant timer resets if blob returns.
func TestStore_EvaluateVacant_Cancelled(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
durationSec := 2
trigger := &Trigger{
Name: "test vacant cancelled",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "vacant",
ConditionParams: ConditionParams{
DurationS: &durationSec,
},
Enabled: true,
}
_, err = store.Create(trigger)
if err != nil {
t.Fatal(err)
}
now := time.Now()
// Blob inside - no fire
blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
store.Evaluate(blobsInside, now)
// Blob leaves - starts vacant timer
blobsOutside := []BlobPos{{ID: 1, X: 5, Y: 5, Z: 5}}
store.Evaluate(blobsOutside, now.Add(1*time.Second))
// Before threshold - no fire
fired := store.Evaluate(blobsOutside, now.Add(1500*time.Millisecond))
if len(fired) != 0 {
t.Errorf("Expected 0 firings (before threshold), got %d", len(fired))
}
// Blob returns before threshold - should cancel timer
store.Evaluate(blobsInside, now.Add(1800*time.Millisecond))
// Wait past original threshold - should NOT fire (timer was cancelled)
fired = store.Evaluate(blobsInside, now.Add(3*time.Second))
if len(fired) != 0 {
t.Errorf("Expected 0 firings (timer cancelled by blob return), got %d", len(fired))
}
}
// TestStore_MultipleBlobs tests trigger evaluation with multiple blobs.
func TestStore_MultipleBlobs(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "test multi-blob enter",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(4), D: float64Ptr(4), H: float64Ptr(2),
},
Condition: "enter",
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
now := time.Now()
// Two blobs enter simultaneously
blobs := []BlobPos{
{ID: 1, X: 1.0, Y: 1.0, Z: 1.0},
{ID: 2, X: 2.0, Y: 2.0, Z: 1.0},
}
fired := store.Evaluate(blobs, now)
if len(fired) != 1 {
t.Errorf("Expected 1 firing (two blobs entering), got %d", len(fired))
}
if fired[0] != id {
t.Errorf("Expected trigger %s to fire, got %s", id, fired[0])
}
// Same blobs still inside - no fire
fired = store.Evaluate(blobs, now.Add(1*time.Second))
if len(fired) != 0 {
t.Errorf("Expected 0 firings (blobs still inside), got %d", len(fired))
}
// One blob leaves, one enters - fires for the entering blob
blobs2 := []BlobPos{
{ID: 2, X: 2.0, Y: 2.0, Z: 1.0}, // still inside
{ID: 3, X: 1.5, Y: 1.5, Z: 1.0}, // new blob entering
}
fired = store.Evaluate(blobs2, now.Add(2*time.Second))
if len(fired) != 1 {
t.Errorf("Expected 1 firing (new blob entering), got %d", len(fired))
}
}
// TestStore_Cylinder_MultiplePoints tests cylinder volume with many points.
func TestStore_Cylinder_MultiplePoints(t *testing.T) {
shape := ShapeJSON{
Type: ShapeCylinder,
CX: float64Ptr(5),
CY: float64Ptr(5),
Z: float64Ptr(0),
R: float64Ptr(2),
H: float64Ptr(3),
}
tests := []struct {
name string
point Point3D
expected bool
}{
{"center base", Point3D{5, 5, 0}, true},
{"center mid-height", Point3D{5, 5, 1.5}, true},
{"center top", Point3D{5, 5, 2.999}, true},
{"on radius", Point3D{7, 5, 1}, true},
{"on radius diagonal", Point3D{5, 7, 1}, true},
{"just outside radius", Point3D{7.001, 5, 1}, false},
{"outside radius", Point3D{8, 5, 1}, false},
{"above top", Point3D{5, 5, 3.001}, false},
{"below bottom", Point3D{5, 5, -0.001}, false},
{"far away", Point3D{100, 100, 100}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shape.IsInside(tt.point)
if got != tt.expected {
t.Errorf("IsInside(%v) = %v, want %v", tt.point, got, tt.expected)
}
})
}
}
// TestStore_Box_Edges tests boundary conditions for box volume.
func TestStore_Box_Edges(t *testing.T) {
shape := ShapeJSON{
Type: ShapeBox,
X: float64Ptr(1),
Y: float64Ptr(2),
Z: float64Ptr(3),
W: float64Ptr(4),
D: float64Ptr(5),
H: float64Ptr(6),
}
// Box spans: x=[1,5), y=[2,8), z=[3,8)
// All 6 faces — on the low edge should be inside, just outside should not
tests := []struct {
name string
point Point3D
expected bool
}{
// X faces
{"x_min inside", Point3D{1, 4, 6}, true},
{"x_min outside", Point3D{0.999, 4, 6}, false},
{"x_max inside", Point3D{4.999, 4, 6}, true},
{"x_max outside", Point3D{5, 4, 6}, false},
// Y faces
{"y_min inside", Point3D{3, 2, 6}, true},
{"y_min outside", Point3D{3, 1.999, 6}, false},
{"y_max inside", Point3D{3, 7.999, 6}, true},
{"y_max outside", Point3D{3, 8, 6}, false},
// Z faces
{"z_min inside", Point3D{3, 4, 3}, true},
{"z_min outside", Point3D{3, 4, 2.999}, false},
{"z_max inside", Point3D{3, 4, 7.999}, true},
{"z_max outside", Point3D{3, 4, 8}, false},
// Exact center
{"center", Point3D{3, 4.5, 6}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shape.IsInside(tt.point)
if got != tt.expected {
t.Errorf("IsInside(%v) = %v, want %v", tt.point, got, tt.expected)
}
})
}
}
// TestStore_Cylinder_Edges tests boundary conditions for cylinder volume.
func TestStore_Cylinder_Edges(t *testing.T) {
shape := ShapeJSON{
Type: ShapeCylinder,
CX: float64Ptr(10),
CY: float64Ptr(10),
Z: float64Ptr(5),
R: float64Ptr(3),
H: float64Ptr(4),
}
// Cylinder: center (10,10), z=[5,9), r=3
tests := []struct {
name string
point Point3D
expected bool
}{
// On boundary
{"on radius edge", Point3D{13, 10, 7}, true},
{"outside radius", Point3D{13.001, 10, 7}, false},
{"on base edge", Point3D{10, 10, 5}, true},
{"below base", Point3D{10, 10, 4.999}, false},
{"below top", Point3D{10, 10, 8.999}, true},
{"above top", Point3D{10, 10, 9}, false},
{"center", Point3D{10, 10, 7}, true},
{"opposite edge", Point3D{7, 10, 7}, true},
{"opposite outside", Point3D{6.999, 10, 7}, false}, // sqrt(3.001^2) = 3.001... distSq = 9.006 > 9 (r^2)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := shape.IsInside(tt.point)
if got != tt.expected {
t.Errorf("IsInside(%v) = %v, want %v", tt.point, got, tt.expected)
}
})
}
}
// TestStore_NilDurationParams tests triggers with nil duration params.
func TestStore_NilDurationParams(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
// Dwell with no duration — should not fire
dwellTrigger := &Trigger{
Name: "dwell no duration",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "dwell",
Enabled: true,
}
_, err = store.Create(dwellTrigger)
if err != nil {
t.Fatal(err)
}
now := time.Now()
blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
fired := store.Evaluate(blobsInside, now.Add(10*time.Second))
if len(fired) != 0 {
t.Errorf("Expected 0 firings for dwell with no duration, got %d", len(fired))
}
// Count with no threshold — should not fire
countTrigger := &Trigger{
Name: "count no threshold",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "count",
Enabled: true,
}
_, err = store.Create(countTrigger)
if err != nil {
t.Fatal(err)
}
fired = store.Evaluate(blobsInside, now)
if len(fired) != 0 {
t.Errorf("Expected 0 firings for count with no threshold, got %d", len(fired))
}
}
// TestStore_DisabledTrigger tests that disabled triggers are not evaluated.
func TestStore_DisabledTrigger(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "disabled trigger",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: false,
}
_, err = store.Create(trigger)
if err != nil {
t.Fatal(err)
}
blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
fired := store.Evaluate(blobsInside, time.Now())
if len(fired) != 0 {
t.Errorf("Expected 0 firings for disabled trigger, got %d", len(fired))
}
}
// TestStore_FiringCallback tests that the firing callback is invoked correctly.
func TestStore_FiringCallback(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
var receivedEvents []FiredEvent
store.SetOnFired(func(event FiredEvent) {
receivedEvents = append(receivedEvents, event)
})
trigger := &Trigger{
Name: "callback test",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
now := time.Now()
blobsOutside := []BlobPos{{ID: 1, X: 5, Y: 5, Z: 5}}
blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
// Enter — should invoke callback
store.Evaluate(blobsOutside, now)
store.Evaluate(blobsInside, now.Add(100*time.Millisecond))
if len(receivedEvents) != 1 {
t.Fatalf("Expected 1 callback event, got %d", len(receivedEvents))
}
evt := receivedEvents[0]
if evt.TriggerID != id {
t.Errorf("Expected trigger ID %s, got %s", id, evt.TriggerID)
}
if evt.TriggerName != "callback test" {
t.Errorf("Expected trigger name 'callback test', got %s", evt.TriggerName)
}
if evt.Condition != "enter" {
t.Errorf("Expected condition 'enter', got %s", evt.Condition)
}
if len(evt.BlobIDs) == 0 {
t.Error("Expected at least one blob ID in event")
}
}
// TestStore_BlobVolumeTracking tests that blobVolumes tracks which trigger contains a blob.
func TestStore_BlobVolumeTracking(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
box1 := &Trigger{
Name: "box1",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
}
box2 := &Trigger{
Name: "box2",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(5), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
}
id1, _ := store.Create(box1)
id2, _ := store.Create(box2)
now := time.Now()
// Blob in box1
blobsInBox1 := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
store.Evaluate(blobsInBox1, now)
// Check IsInVolume
if !store.IsInVolume(id1, 0.5, 0.5, 0.5) {
t.Error("Expected blob at (0.5, 0.5, 0.5) to be in box1")
}
if store.IsInVolume(id2, 0.5, 0.5, 0.5) {
t.Error("Expected blob at (0.5, 0.5, 0.5) to NOT be in box2")
}
// Blob moves to box2
blobsInBox2 := []BlobPos{{ID: 1, X: 5.5, Y: 0.5, Z: 0.5}}
store.Evaluate(blobsInBox2, now.Add(1*time.Second))
if store.IsInVolume(id1, 5.5, 0.5, 0.5) {
t.Error("Expected blob at (5.5, 0.5, 0.5) to NOT be in box1")
}
if !store.IsInVolume(id2, 5.5, 0.5, 0.5) {
t.Error("Expected blob at (5.5, 0.5, 0.5) to be in box2")
}
}
// TestStore_EvaluateCount tests count trigger evaluation.
func TestStore_EvaluateCount(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
threshold := 2
trigger := &Trigger{
Name: "test count",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0),
Y: float64Ptr(0),
Z: float64Ptr(0),
W: float64Ptr(2),
D: float64Ptr(2),
H: float64Ptr(1),
},
Condition: "count",
ConditionParams: ConditionParams{
CountThreshold: &threshold,
},
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
// One blob inside - no fire
blobs := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
fired := store.Evaluate(blobs, time.Now())
if len(fired) != 0 {
t.Errorf("Expected 0 firings (count 1 < threshold 2), got %d", len(fired))
}
// Two blobs inside - should fire
blobs = []BlobPos{
{ID: 1, X: 0.5, Y: 0.5, Z: 0.5},
{ID: 2, X: 1.0, Y: 0.5, Z: 1.0},
}
fired = store.Evaluate(blobs, time.Now())
if len(fired) != 1 {
t.Errorf("Expected 1 firing (count 2 >= threshold 2), got %d", len(fired))
}
if fired[0] != id {
t.Errorf("Expected trigger %s to fire, got %s", id, fired[0])
}
// Still two blobs - should not fire again (cooldown)
fired = store.Evaluate(blobs, time.Now().Add(6*time.Second))
if len(fired) != 0 {
t.Errorf("Expected 0 firings (cooldown), got %d", len(fired))
}
}
// TestStore_EvaluateVacant tests vacant trigger evaluation.
func TestStore_EvaluateVacant(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
durationSec := 1
trigger := &Trigger{
Name: "test vacant",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0),
Y: float64Ptr(0),
Z: float64Ptr(0),
W: float64Ptr(1),
D: float64Ptr(1),
H: float64Ptr(1),
},
Condition: "vacant",
ConditionParams: ConditionParams{
DurationS: &durationSec,
},
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
// Blob inside - no fire
blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
now := time.Now()
fired := store.Evaluate(blobsInside, now)
if len(fired) != 0 {
t.Errorf("Expected 0 firings (blob inside), got %d", len(fired))
}
// Blob leaves - starts vacant timer
blobsOutside := []BlobPos{{ID: 1, X: 2, Y: 2, Z: 2}}
fired = store.Evaluate(blobsOutside, now)
if len(fired) != 0 {
t.Errorf("Expected 0 firings (just left), got %d", len(fired))
}
// Before threshold - no fire
fired = store.Evaluate(blobsOutside, now.Add(500*time.Millisecond))
if len(fired) != 0 {
t.Errorf("Expected 0 firings (before threshold), got %d", len(fired))
}
// After threshold - should fire
fired = store.Evaluate(blobsOutside, now.Add(time.Duration(durationSec)*time.Second))
if len(fired) != 1 {
t.Errorf("Expected 1 firing (after threshold), got %d", len(fired))
}
if fired[0] != id {
t.Errorf("Expected trigger %s to fire, got %s", id, fired[0])
}
// Blob returns - resets vacant timer, no fire
blobsInside = []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
fired = store.Evaluate(blobsInside, now.Add(time.Duration(durationSec)*time.Second+100*time.Millisecond))
if len(fired) != 0 {
t.Errorf("Expected 0 firings (blob returned), got %d", len(fired))
}
}
// TestStore_CRUD tests basic CRUD operations.
func TestStore_CRUD(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
// Create
trigger := &Trigger{
Name: "test trigger",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0),
Y: float64Ptr(0),
Z: float64Ptr(0),
W: float64Ptr(1),
D: float64Ptr(1),
H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
Actions: []Action{
{Type: "webhook", Params: map[string]interface{}{"url": "http://example.com"}},
},
}
id, err := store.Create(trigger)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
if id == "" {
t.Fatal("Expected non-empty ID")
}
// Get
retrieved, err := store.Get(id)
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if retrieved.Name != trigger.Name {
t.Errorf("Expected name %s, got %s", trigger.Name, retrieved.Name)
}
// Update
retrieved.Name = "updated name"
err = store.Update(retrieved)
if err != nil {
t.Fatalf("Update failed: %v", err)
}
retrieved, _ = store.Get(id)
if retrieved.Name != "updated name" {
t.Errorf("Expected updated name, got %s", retrieved.Name)
}
// GetAll
all := store.GetAll()
if len(all) != 1 {
t.Errorf("Expected 1 trigger, got %d", len(all))
}
// Delete
err = store.Delete(id)
if err != nil {
t.Fatalf("Delete failed: %v", err)
}
_, err = store.Get(id)
if err == nil {
t.Error("Expected error when getting deleted trigger")
}
all = store.GetAll()
if len(all) != 0 {
t.Errorf("Expected 0 triggers after delete, got %d", len(all))
}
}
// TestStore_TimeConstraint tests time constraint filtering.
func TestStore_TimeConstraint(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "test time constraint",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0),
Y: float64Ptr(0),
Z: float64Ptr(0),
W: float64Ptr(1),
D: float64Ptr(1),
H: float64Ptr(1),
},
Condition: "enter",
TimeConstraint: &TimeConstraint{
From: "09:00",
To: "17:00",
},
Enabled: true,
}
_, err = store.Create(trigger)
if err != nil {
t.Fatal(err)
}
blobsOutside := []BlobPos{{ID: 1, X: 2, Y: 2, Z: 2}}
blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
// Before time window - blob enters but no fire due to time constraint
beforeTime := time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC)
store.Evaluate(blobsOutside, beforeTime) // Blob outside
fired := store.Evaluate(blobsInside, beforeTime) // Blob enters
if len(fired) != 0 {
t.Errorf("Expected 0 firings (before time window), got %d", len(fired))
}
// During time window - blob enters and should fire
duringTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
// First reset by moving outside
store.Evaluate(blobsOutside, duringTime)
// Then enter during the time window
fired = store.Evaluate(blobsInside, duringTime)
if len(fired) != 1 {
t.Errorf("Expected 1 firing (during time window), got %d", len(fired))
}
// After time window - no fire
afterTime := time.Date(2024, 1, 1, 18, 0, 0, 0, time.UTC)
store.Evaluate(blobsOutside, afterTime)
fired = store.Evaluate(blobsInside, afterTime)
if len(fired) != 0 {
t.Errorf("Expected 0 firings (after time window), got %d", len(fired))
}
// Overnight window (22:00-07:00)
trigger.TimeConstraint = &TimeConstraint{
From: "22:00",
To: "07:00",
}
err = store.Update(trigger)
if err != nil {
t.Fatal(err)
}
// Should fire at 23:00
nightTime := time.Date(2024, 1, 1, 23, 0, 0, 0, time.UTC)
store.Evaluate(blobsOutside, nightTime)
fired = store.Evaluate(blobsInside, nightTime)
if len(fired) != 1 {
t.Errorf("Expected 1 firing (overnight window), got %d", len(fired))
}
// Should fire at 03:00
earlyMorning := time.Date(2024, 1, 1, 3, 0, 0, 0, time.UTC)
store.Evaluate(blobsOutside, earlyMorning)
fired = store.Evaluate(blobsInside, earlyMorning)
if len(fired) != 1 {
t.Errorf("Expected 1 firing (early morning), got %d", len(fired))
}
// Should not fire at 12:00
dayTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
store.Evaluate(blobsOutside, dayTime)
fired = store.Evaluate(blobsInside, dayTime)
if len(fired) != 0 {
t.Errorf("Expected 0 firings (outside overnight window), got %d", len(fired))
}
}
// TestStore_ErrorCountManagement tests error count increment, reset, and trigger disable on 4xx.
func TestStore_ErrorCountManagement(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "test error count",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
Actions: []Action{
{Type: "webhook", Params: map[string]interface{}{"url": "http://example.com"}},
},
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
// Initially error_count should be 0
tg, err := store.Get(id)
if err != nil {
t.Fatal(err)
}
if tg.ErrorCount != 0 {
t.Errorf("Expected initial error_count 0, got %d", tg.ErrorCount)
}
// Increment error count (simulating 5xx/timeout)
store.IncrementErrorCount(id)
store.IncrementErrorCount(id)
store.IncrementErrorCount(id)
tg, _ = store.Get(id)
if tg.ErrorCount != 3 {
t.Errorf("Expected error_count 3, got %d", tg.ErrorCount)
}
// Trigger should still be enabled after 5xx errors
if !tg.Enabled {
t.Error("Expected trigger to remain enabled after 5xx errors")
}
// Reset error count (simulating successful 2xx response)
store.ResetErrorCount(id)
tg, _ = store.Get(id)
if tg.ErrorCount != 0 {
t.Errorf("Expected error_count 0 after reset, got %d", tg.ErrorCount)
}
if !tg.Enabled {
t.Error("Expected trigger to remain enabled after error count reset")
}
}
// TestStore_DisableTriggerWithError tests that a 4xx response disables the trigger.
func TestStore_DisableTriggerWithError(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "test 4xx disable",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
// Simulate 4xx response disabling the trigger
errMsg := "Webhook returned HTTP 404 — trigger disabled. Fix the URL and re-enable."
store.DisableTriggerWithError(id, errMsg)
tg, err := store.Get(id)
if err != nil {
t.Fatal(err)
}
if tg.Enabled {
t.Error("Expected trigger to be disabled after 4xx response")
}
if tg.ErrorMessage != errMsg {
t.Errorf("Expected error_message %q, got %q", errMsg, tg.ErrorMessage)
}
// Verify the trigger is still disabled even after error count increments
store.IncrementErrorCount(id)
tg, _ = store.Get(id)
if tg.Enabled {
t.Error("Expected trigger to remain disabled")
}
}
// TestStore_EnableTriggerClearsErrorState tests re-enabling clears error_message and error_count.
func TestStore_EnableTriggerClearsErrorState(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "test re-enable",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
// Simulate 4xx disable
store.DisableTriggerWithError(id, "HTTP 400 error")
store.IncrementErrorCount(id)
// Re-enable via API
err = store.EnableTrigger(id)
if err != nil {
t.Fatal(err)
}
tg, _ := store.Get(id)
if !tg.Enabled {
t.Error("Expected trigger to be re-enabled")
}
if tg.ErrorMessage != "" {
t.Errorf("Expected error_message to be cleared, got %q", tg.ErrorMessage)
}
if tg.ErrorCount != 0 {
t.Errorf("Expected error_count to be reset to 0, got %d", tg.ErrorCount)
}
}
// TestStore_ErrorCountResetsOnFirst2xx tests that error_count resets on first 2xx.
func TestStore_ErrorCountResetsOnFirst2xx(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "test reset on 2xx",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
// Accumulate errors
for i := 0; i < 10; i++ {
store.IncrementErrorCount(id)
}
tg, _ := store.Get(id)
if tg.ErrorCount != 10 {
t.Errorf("Expected error_count 10, got %d", tg.ErrorCount)
}
// Single success resets
store.ResetErrorCount(id)
tg, _ = store.Get(id)
if tg.ErrorCount != 0 {
t.Errorf("Expected error_count 0 after reset, got %d", tg.ErrorCount)
}
}
// TestStore_WebhookLogAudit tests writing and reading webhook log entries.
func TestStore_WebhookLogAudit(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "audit trigger",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
// Write log entries
store.WriteWebhookLog(id, "http://example.com/hook", 1700000000000, 200, 45, "")
store.WriteWebhookLog(id, "http://example.com/hook", 1700000001000, 500, 120, "server error")
store.WriteWebhookLog(id, "http://example.com/hook", 1700000002000, 404, 30, "not found")
// Read back - should be in reverse chronological order
entries := store.GetWebhookLog(id, 10)
if len(entries) != 3 {
t.Fatalf("Expected 3 webhook log entries, got %d", len(entries))
}
// Most recent first
if entries[0].Status != 404 {
t.Errorf("Expected first entry status 404, got %d", entries[0].Status)
}
if entries[0].LatencyMs != 30 {
t.Errorf("Expected first entry latency 30ms, got %d", entries[0].LatencyMs)
}
if entries[0].Error != "not found" {
t.Errorf("Expected first entry error 'not found', got %q", entries[0].Error)
}
if entries[1].Status != 500 {
t.Errorf("Expected second entry status 500, got %d", entries[1].Status)
}
if entries[2].Status != 200 {
t.Errorf("Expected third entry status 200, got %d", entries[2].Status)
}
// Test limit
entries = store.GetWebhookLog(id, 2)
if len(entries) != 2 {
t.Errorf("Expected 2 entries with limit 2, got %d", len(entries))
}
}
// TestStore_5xxDoesNotDisable tests that 5xx errors increment count but don't disable.
func TestStore_5xxDoesNotDisable(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "test 5xx no disable",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
// Simulate many 5xx errors
for i := 0; i < 100; i++ {
store.IncrementErrorCount(id)
}
tg, _ := store.Get(id)
if !tg.Enabled {
t.Error("Expected trigger to remain enabled despite 100 5xx errors")
}
if tg.ErrorCount != 100 {
t.Errorf("Expected error_count 100, got %d", tg.ErrorCount)
}
}
// TestStore_DisabledTriggerSkippedInEvaluate tests that disabled triggers are not evaluated.
func TestStore_DisabledTriggerSkippedInEvaluate(t *testing.T) {
store, err := NewStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
trigger := &Trigger{
Name: "test disabled skip",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
}
id, err := store.Create(trigger)
if err != nil {
t.Fatal(err)
}
// Disable the trigger
store.DisableTriggerWithError(id, "test error")
// Blob enters volume — should NOT fire since trigger is disabled
blobsInside := []BlobPos{{ID: 1, X: 0.5, Y: 0.5, Z: 0.5}}
fired := store.Evaluate(blobsInside, time.Now())
if len(fired) != 0 {
t.Errorf("Expected 0 firings for disabled trigger, got %d", len(fired))
}
}
// TestStore_ErrorStatePersistsAcrossRestart tests error_message and error_count survive reload.
func TestStore_ErrorStatePersistsAcrossRestart(t *testing.T) {
// Use a temp file so we can reopen it
tmpDir := t.TempDir()
dbPath := tmpDir + "/test.db"
store1, err := NewStore(dbPath)
if err != nil {
t.Fatal(err)
}
trigger := &Trigger{
Name: "persist test",
Shape: ShapeJSON{
Type: ShapeBox,
X: float64Ptr(0), Y: float64Ptr(0), Z: float64Ptr(0),
W: float64Ptr(1), D: float64Ptr(1), H: float64Ptr(1),
},
Condition: "enter",
Enabled: true,
}
id, err := store1.Create(trigger)
if err != nil {
t.Fatal(err)
}
// Set error state
store1.IncrementErrorCount(id)
store1.IncrementErrorCount(id)
store1.DisableTriggerWithError(id, "HTTP 403 forbidden")
store1.Close()
// Reopen store
store2, err := NewStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer store2.Close()
tg, err := store2.Get(id)
if err != nil {
t.Fatal(err)
}
if tg.ErrorCount != 2 {
t.Errorf("Expected error_count 2 after restart, got %d", tg.ErrorCount)
}
if !tg.Enabled {
// Note: DisableTriggerWithError sets Enabled=false, so after reload this should be false
// But if columns are loaded correctly from DB, Enabled should be false
}
if tg.ErrorMessage != "HTTP 403 forbidden" {
t.Errorf("Expected error_message 'HTTP 403 forbidden', got %q", tg.ErrorMessage)
}
// The trigger should be disabled after reload (persistence of enabled=false)
if tg.Enabled {
t.Error("Expected trigger to be disabled after restart (error state persisted)")
}
}
// Helper function
func float64Ptr(f float64) *float64 {
return &f
}