Реализация rule-engine-service: RabbitMQ → правила из PostgreSQL → gRPC
Слушает telemetry.new_reading (ручной ack/nack, реквизишн только при транспортных ошибках gRPC), кэширует активные automation_rules в памяти с периодическим обновлением из PostgreSQL (JOIN с devices — резолвит внутренние ID в external_id, которым оперируют MQTT/Redis/gRPC). Условия правил (>,<,>=,<=,=,!=) оцениваются обобщённо, без привязки к конкретным типам устройств. При срабатывании вызывает device-control-service по gRPC и публикует automation.rule_triggered в RabbitMQ. Проверено сквозным тестом через docker compose на полном пайплайне: mosquitto_pub → ingest-service (ClickHouse + telemetry.new_reading) → rule-engine-service (совпадение правила) → device-control-service (gRPC → Redis desired_state + MQTT-команда) → automation.rule_triggered. Показание ниже порога проверено отдельно — правило корректно не срабатывает.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/devicecontrolclient"
|
||||
"git.cactoz.su/cacto/home_automatization/services/rule-engine-service/internal/rules"
|
||||
)
|
||||
|
||||
type fakeRuleSource struct {
|
||||
rules []rules.Rule
|
||||
}
|
||||
|
||||
func (f *fakeRuleSource) MatchingRules(_, _ string) []rules.Rule {
|
||||
return f.rules
|
||||
}
|
||||
|
||||
type dispatchCall struct {
|
||||
deviceID string
|
||||
actionType string
|
||||
params map[string]any
|
||||
}
|
||||
|
||||
type fakeDispatcher struct {
|
||||
calls []dispatchCall
|
||||
result devicecontrolclient.Result
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDispatcher) Dispatch(_ context.Context, deviceID, actionType string, params map[string]any) (devicecontrolclient.Result, error) {
|
||||
f.calls = append(f.calls, dispatchCall{deviceID: deviceID, actionType: actionType, params: params})
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
type triggeredEvent struct {
|
||||
ruleID int64
|
||||
deviceID string
|
||||
actionType string
|
||||
success bool
|
||||
errMsg string
|
||||
}
|
||||
|
||||
type fakePublisher struct {
|
||||
events []triggeredEvent
|
||||
}
|
||||
|
||||
func (f *fakePublisher) PublishRuleTriggered(_ context.Context, ruleID, _ int64, deviceID, actionType string, success bool, errMsg string) error {
|
||||
f.events = append(f.events, triggeredEvent{ruleID: ruleID, deviceID: deviceID, actionType: actionType, success: success, errMsg: errMsg})
|
||||
return nil
|
||||
}
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestHandleReading_NoMatchingRules(t *testing.T) {
|
||||
dispatcher := &fakeDispatcher{}
|
||||
publisher := &fakePublisher{}
|
||||
e := New(&fakeRuleSource{}, dispatcher, publisher, discardLogger())
|
||||
|
||||
if err := e.HandleReading(context.Background(), Reading{DeviceID: "sensor-1", SensorType: "temperature", Value: 30}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(dispatcher.calls) != 0 || len(publisher.events) != 0 {
|
||||
t.Fatalf("expected no dispatch/publish, got %+v / %+v", dispatcher.calls, publisher.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReading_ConditionNotMet(t *testing.T) {
|
||||
source := &fakeRuleSource{rules: []rules.Rule{
|
||||
{ID: 1, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
|
||||
}}
|
||||
dispatcher := &fakeDispatcher{}
|
||||
e := New(source, dispatcher, &fakePublisher{}, discardLogger())
|
||||
|
||||
if err := e.HandleReading(context.Background(), Reading{Value: 20}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(dispatcher.calls) != 0 {
|
||||
t.Fatalf("expected no dispatch when condition not met, got %+v", dispatcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReading_ConditionMet_DispatchesAndPublishes(t *testing.T) {
|
||||
source := &fakeRuleSource{rules: []rules.Rule{
|
||||
{ID: 1, ZoneID: 7, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
|
||||
}}
|
||||
dispatcher := &fakeDispatcher{result: devicecontrolclient.Result{Success: true}}
|
||||
publisher := &fakePublisher{}
|
||||
e := New(source, dispatcher, publisher, discardLogger())
|
||||
|
||||
if err := e.HandleReading(context.Background(), Reading{Value: 30}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(dispatcher.calls) != 1 || dispatcher.calls[0].deviceID != "fan-1" || dispatcher.calls[0].actionType != "turn_on" {
|
||||
t.Fatalf("got dispatch calls %+v, want one turn_on for fan-1", dispatcher.calls)
|
||||
}
|
||||
if len(publisher.events) != 1 || !publisher.events[0].success {
|
||||
t.Fatalf("got publish events %+v, want one success event", publisher.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReading_BusinessRejection_NotRetried(t *testing.T) {
|
||||
source := &fakeRuleSource{rules: []rules.Rule{
|
||||
{ID: 1, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
|
||||
}}
|
||||
dispatcher := &fakeDispatcher{result: devicecontrolclient.Result{Success: false, Error: "device offline"}}
|
||||
publisher := &fakePublisher{}
|
||||
e := New(source, dispatcher, publisher, discardLogger())
|
||||
|
||||
err := e.HandleReading(context.Background(), Reading{Value: 30})
|
||||
if err != nil {
|
||||
t.Fatalf("business rejection should not be reported as a retryable error, got %v", err)
|
||||
}
|
||||
if len(publisher.events) != 1 || publisher.events[0].success || publisher.events[0].errMsg != "device offline" {
|
||||
t.Fatalf("got publish events %+v, want one failed event with device offline", publisher.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReading_TransportError_IsRetryable(t *testing.T) {
|
||||
source := &fakeRuleSource{rules: []rules.Rule{
|
||||
{ID: 1, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
|
||||
}}
|
||||
dispatcher := &fakeDispatcher{err: errors.New("connection refused")}
|
||||
publisher := &fakePublisher{}
|
||||
e := New(source, dispatcher, publisher, discardLogger())
|
||||
|
||||
err := e.HandleReading(context.Background(), Reading{Value: 30})
|
||||
if err == nil {
|
||||
t.Fatal("expected a retryable error when dispatch fails at the transport level")
|
||||
}
|
||||
if len(publisher.events) != 1 || publisher.events[0].success {
|
||||
t.Fatalf("got publish events %+v, want one failed event recorded even on transport error", publisher.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReading_InvalidOperatorSkipsRuleButContinues(t *testing.T) {
|
||||
source := &fakeRuleSource{rules: []rules.Rule{
|
||||
{ID: 1, ConditionOperator: "~=", ConditionValue: 28, TargetDeviceID: "bad-rule"},
|
||||
{ID: 2, ConditionOperator: ">", ConditionValue: 28, TargetDeviceID: "fan-1", ActionType: "turn_on"},
|
||||
}}
|
||||
dispatcher := &fakeDispatcher{result: devicecontrolclient.Result{Success: true}}
|
||||
e := New(source, dispatcher, &fakePublisher{}, discardLogger())
|
||||
|
||||
if err := e.HandleReading(context.Background(), Reading{Value: 30}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(dispatcher.calls) != 1 || dispatcher.calls[0].deviceID != "fan-1" {
|
||||
t.Fatalf("got dispatch calls %+v, want only the valid rule dispatched", dispatcher.calls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user