diff --git a/internal/condition/evaluator.go b/internal/condition/evaluator.go new file mode 100644 index 0000000..41957c2 --- /dev/null +++ b/internal/condition/evaluator.go @@ -0,0 +1,98 @@ +package condition + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "aiaa-notification-service/internal/model" +) + +// Evaluate checks all conditions against data. Returns true if ALL conditions pass. +// An empty conditions slice always returns true. +func Evaluate(conditions []model.Condition, data map[string]interface{}) bool { + if len(conditions) == 0 { + return true + } + for _, c := range conditions { + if !evaluateOne(c, data) { + return false + } + } + return true +} + +func evaluateOne(c model.Condition, data map[string]interface{}) bool { + fieldVal, fieldExists := data[c.Field] + + switch c.Op { + case "exists": + return fieldExists + case "not_exists": + return !fieldExists + case "eq": + if !fieldExists { + return false + } + return fmt.Sprintf("%v", fieldVal) == c.Value + case "ne": + if !fieldExists { + return false + } + return fmt.Sprintf("%v", fieldVal) != c.Value + case "contains": + if !fieldExists { + return false + } + return strings.Contains(fmt.Sprintf("%v", fieldVal), c.Value) + case "gt", "gte", "lt", "lte": + if !fieldExists { + return false + } + return compareNumeric(fieldVal, c.Value, c.Op) + default: + return false + } +} + +func compareNumeric(fieldVal interface{}, value string, op string) bool { + fv, err := toFloat64(fieldVal) + if err != nil { + return false + } + cv, err := strconv.ParseFloat(value, 64) + if err != nil { + return false + } + switch op { + case "gt": + return fv > cv + case "gte": + return fv >= cv + case "lt": + return fv < cv + case "lte": + return fv <= cv + } + return false +} + +func toFloat64(v interface{}) (float64, error) { + switch val := v.(type) { + case float64: + return val, nil + case float32: + return float64(val), nil + case int: + return float64(val), nil + case int64: + return float64(val), nil + case string: + return strconv.ParseFloat(val, 64) + case json.Number: + return val.Float64() + default: + return 0, fmt.Errorf("cannot convert %T to float64", v) + } +} diff --git a/internal/condition/evaluator_test.go b/internal/condition/evaluator_test.go new file mode 100644 index 0000000..310815b --- /dev/null +++ b/internal/condition/evaluator_test.go @@ -0,0 +1,56 @@ +package condition + +import ( + "testing" + + "aiaa-notification-service/internal/model" +) + +func TestEvaluate_Empty(t *testing.T) { + if !Evaluate(nil, nil) { + t.Error("empty conditions should pass") + } +} + +func TestEvaluate_Exists(t *testing.T) { + conds := []model.Condition{{Field: "symbol", Op: "exists"}} + data := map[string]interface{}{"symbol": "BTC"} + if !Evaluate(conds, data) { + t.Error("symbol exists, should pass") + } +} + +func TestEvaluate_NotExists(t *testing.T) { + conds := []model.Condition{{Field: "symbol", Op: "not_exists"}} + data := map[string]interface{}{"price": 100} + if !Evaluate(conds, data) { + t.Error("symbol not exists, should pass") + } +} + +func TestEvaluate_Gt(t *testing.T) { + conds := []model.Condition{{Field: "price", Op: "gt", Value: "100"}} + data := map[string]interface{}{"price": float64(200)} + if !Evaluate(conds, data) { + t.Error("200 > 100, should pass") + } +} + +func TestEvaluate_Fail(t *testing.T) { + conds := []model.Condition{ + {Field: "symbol", Op: "exists"}, + {Field: "price", Op: "lt", Value: "100"}, + } + data := map[string]interface{}{"symbol": "BTC", "price": float64(200)} + if Evaluate(conds, data) { + t.Error("200 < 100 is false, should fail") + } +} + +func TestEvaluate_Contains(t *testing.T) { + conds := []model.Condition{{Field: "msg", Op: "contains", Value: "error"}} + data := map[string]interface{}{"msg": "connection error occurred"} + if !Evaluate(conds, data) { + t.Error("msg contains 'error', should pass") + } +}