234 lines
7.1 KiB
Go
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)
|
|
}
|
|
}
|