Files
Cyrene_v2/internal/services/automations/rule_test.go
T

234 lines
7.1 KiB
Go

package automations
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
)
func TestEngine_EventMatchFiresOnlyMatchingRule(t *testing.T) {
t.Parallel()
b := bus.New()
defer b.Close()
eng, err := NewEngine(Config{}, Deps{Bus: b})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
var ranA, ranB atomic.Int32
actA, err := NewFuncAction("on_device", func(context.Context, any) (any, error) {
ranA.Add(1)
return nil, nil
})
if err != nil {
t.Fatalf("NewFuncAction A: %v", err)
}
actB, err := NewFuncAction("on_temp", func(context.Context, any) (any, error) {
ranB.Add(1)
return nil, nil
})
if err != nil {
t.Fatalf("NewFuncAction B: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := eng.AddEvent(ctx, Rule{ID: "r1", Trigger: TriggerEvent, Expression: "iot.device_update", Action: actA}); err != nil {
t.Fatalf("AddEvent r1: %v", err)
}
if err := eng.AddEvent(ctx, Rule{ID: "r2", Trigger: TriggerEvent, Expression: "iot.room_temperature", Action: actB}); err != nil {
t.Fatalf("AddEvent r2: %v", err)
}
if err := eng.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
if err := b.Publish(ctx, "iot.device_update", map[string]any{"value": 42}); err != nil {
t.Fatalf("Publish: %v", err)
}
waitFor(t, 2*time.Second, func() bool { return ranA.Load() > 0 })
if ranA.Load() != 1 {
t.Errorf("matching rule ran %d times, want 1", ranA.Load())
}
if ranB.Load() != 0 {
t.Errorf("non-matching rule ran %d times, want 0", ranB.Load())
}
if err := eng.Stop(ctx); err != nil {
t.Fatalf("Stop: %v", err)
}
}
func TestEngine_AddEventValidation(t *testing.T) {
t.Parallel()
eng, err := NewEngine(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
// Wrong trigger for an event rule.
err = eng.AddEvent(context.Background(), Rule{ID: "c", Trigger: TriggerCron, Expression: "*/5 * * * *", Action: act})
if !errors.Is(err, ErrInvalidRule) {
t.Fatalf("wrong trigger: err = %v, want ErrInvalidRule", err)
}
// Missing id.
err = eng.AddEvent(context.Background(), Rule{ID: "", Trigger: TriggerEvent, Expression: "iot.*", Action: act})
if !errors.Is(err, ErrInvalidRule) {
t.Fatalf("missing id: err = %v, want ErrInvalidRule", err)
}
// Nil action.
err = eng.AddEvent(context.Background(), Rule{ID: "ok", Trigger: TriggerEvent, Expression: "iot.*"})
if !errors.Is(err, ErrInvalidRule) {
t.Fatalf("nil action: err = %v, want ErrInvalidRule", err)
}
// Duplicate.
if err := eng.AddEvent(context.Background(), Rule{ID: "d", Trigger: TriggerEvent, Expression: "iot.*", Action: act}); err != nil {
t.Fatalf("AddEvent d: %v", err)
}
err = eng.AddEvent(context.Background(), Rule{ID: "d", Trigger: TriggerEvent, Expression: "iot.*", Action: act})
if !errors.Is(err, ErrDuplicate) {
t.Fatalf("duplicate: err = %v, want ErrDuplicate", err)
}
// Missing removal.
err = eng.RemoveRule(context.Background(), "nope")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("remove missing: err = %v, want ErrNotFound", err)
}
}
func TestEngine_RemoveRuleStopsFiring(t *testing.T) {
t.Parallel()
b := bus.New()
defer b.Close()
eng, err := NewEngine(Config{}, Deps{Bus: b})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var ran atomic.Int32
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { ran.Add(1); return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
if err := eng.AddEvent(ctx, Rule{ID: "r", Trigger: TriggerEvent, Expression: "iot.device_update", Action: act}); err != nil {
t.Fatalf("AddEvent: %v", err)
}
if err := eng.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
if err := b.Publish(ctx, "iot.device_update", nil); err != nil {
t.Fatalf("Publish: %v", err)
}
waitFor(t, 2*time.Second, func() bool { return ran.Load() > 0 })
if err := eng.RemoveRule(ctx, "r"); err != nil {
t.Fatalf("RemoveRule: %v", err)
}
// Give a removed subscription a chance to (incorrectly) receive the next event.
time.Sleep(50 * time.Millisecond)
before := ran.Load()
if err := b.Publish(ctx, "iot.device_update", nil); err != nil {
t.Fatalf("Publish 2: %v", err)
}
time.Sleep(50 * time.Millisecond)
if got := ran.Load(); got != before {
t.Fatalf("rule fired after removal: %d -> %d", before, got)
}
}
func TestEngine_RulesSnapshot(t *testing.T) {
t.Parallel()
eng, err := NewEngine(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
if err := eng.AddEvent(context.Background(), Rule{ID: "a", Trigger: TriggerEvent, Expression: "iot.*", Action: act}); err != nil {
t.Fatalf("AddEvent a: %v", err)
}
if err := eng.AddEvent(context.Background(), Rule{ID: "b", Trigger: TriggerEvent, Expression: "iot.temp", Action: act}); err != nil {
t.Fatalf("AddEvent b: %v", err)
}
rules := eng.Rules()
if len(rules) != 2 {
t.Fatalf("len(Rules) = %d, want 2", len(rules))
}
}
func TestEngine_ConcurrentAddRemoveAndPublish(t *testing.T) {
t.Parallel()
b := bus.New()
defer b.Close()
eng, err := NewEngine(Config{}, Deps{Bus: b})
if err != nil {
t.Fatalf("NewEngine: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
act, err := NewFuncAction("x", func(context.Context, any) (any, error) { return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
const n = 50
done := make(chan struct{})
for i := 0; i < n; i++ {
go func(i int) {
defer func() { done <- struct{}{} }()
id := "r"
_ = eng.AddEvent(ctx, Rule{ID: id, Trigger: TriggerEvent, Expression: "iot.*", Action: act})
_ = b.Publish(ctx, "iot.device_update", i)
_ = eng.RemoveRule(ctx, id)
}(i)
}
for i := 0; i < n; i++ {
<-done
}
}
func TestService_AddRuleRoutesCronAndRejectsInvalid(t *testing.T) {
t.Parallel()
s, err := NewService(Config{}, Deps{Bus: bus.New()})
if err != nil {
t.Fatalf("NewService: %v", err)
}
act, err := NewFuncAction("cron", func(context.Context, any) (any, error) { return nil, nil })
if err != nil {
t.Fatalf("NewFuncAction: %v", err)
}
if err := s.AddRule(context.Background(), Rule{ID: "c1", Trigger: TriggerCron, Expression: "*/5 * * * *", Action: act}); err != nil {
t.Fatalf("valid cron rule: %v", err)
}
err = s.AddRule(context.Background(), Rule{ID: "c2", Trigger: TriggerCron, Expression: "not a cron", Action: act})
if !errors.Is(err, ErrInvalidCron) {
t.Fatalf("invalid cron rule: err = %v, want ErrInvalidCron", err)
}
if err := s.RemoveRule(context.Background(), "c1"); err != nil {
t.Fatalf("RemoveRule cron: %v", err)
}
}
func waitFor(t *testing.T, timeout time.Duration, cond func() bool) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
}