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) } }