init
This commit is contained in:
358
pkg/notify/route/route.go
Normal file
358
pkg/notify/route/route.go
Normal file
@@ -0,0 +1,358 @@
|
||||
// Copyright (C) 2025 wangyusong
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"k8s.io/utils/ptr"
|
||||
|
||||
"github.com/glidea/zenfeed/pkg/component"
|
||||
"github.com/glidea/zenfeed/pkg/model"
|
||||
"github.com/glidea/zenfeed/pkg/schedule/rule"
|
||||
"github.com/glidea/zenfeed/pkg/storage/feed/block"
|
||||
timeutil "github.com/glidea/zenfeed/pkg/util/time"
|
||||
)
|
||||
|
||||
// --- Interface code block ---
|
||||
type Router interface {
|
||||
component.Component
|
||||
Route(result *rule.Result) (groups []*Group, err error)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Route
|
||||
}
|
||||
|
||||
type Route struct {
|
||||
GroupBy []string
|
||||
CompressByRelatedThreshold *float32
|
||||
Receivers []string
|
||||
SubRoutes SubRoutes
|
||||
}
|
||||
|
||||
type SubRoutes []*SubRoute
|
||||
|
||||
func (s SubRoutes) Match(feed *block.FeedVO) *SubRoute {
|
||||
for _, sub := range s {
|
||||
if matched := sub.Match(feed); matched != nil {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type SubRoute struct {
|
||||
Route
|
||||
Matchers []string
|
||||
matchers []matcher
|
||||
}
|
||||
|
||||
func (r *SubRoute) Match(feed *block.FeedVO) *SubRoute {
|
||||
for _, subRoute := range r.SubRoutes {
|
||||
if matched := subRoute.Match(feed); matched != nil {
|
||||
return matched
|
||||
}
|
||||
}
|
||||
for _, m := range r.matchers {
|
||||
fv := feed.Labels.Get(m.key)
|
||||
switch m.equal {
|
||||
case true:
|
||||
if fv != m.value {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
if fv == m.value {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type matcher struct {
|
||||
key string
|
||||
value string
|
||||
equal bool
|
||||
}
|
||||
|
||||
var (
|
||||
matcherEqual = "="
|
||||
matcherNotEqual = "!="
|
||||
parseMatcher = func(filter string) (matcher, error) {
|
||||
eq := false
|
||||
parts := strings.Split(filter, matcherNotEqual)
|
||||
if len(parts) != 2 {
|
||||
parts = strings.Split(filter, matcherEqual)
|
||||
eq = true
|
||||
}
|
||||
if len(parts) != 2 {
|
||||
return matcher{}, errors.New("invalid matcher")
|
||||
}
|
||||
|
||||
return matcher{key: parts[0], value: parts[1], equal: eq}, nil
|
||||
}
|
||||
)
|
||||
|
||||
func (r *SubRoute) Validate() error {
|
||||
if len(r.GroupBy) == 0 {
|
||||
r.GroupBy = []string{model.LabelSource}
|
||||
}
|
||||
if r.CompressByRelatedThreshold == nil {
|
||||
r.CompressByRelatedThreshold = ptr.To(float32(0.85))
|
||||
}
|
||||
if len(r.Matchers) == 0 {
|
||||
return errors.New("matchers is required")
|
||||
}
|
||||
r.matchers = make([]matcher, len(r.Matchers))
|
||||
for i, matcher := range r.Matchers {
|
||||
m, err := parseMatcher(matcher)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "invalid matcher")
|
||||
}
|
||||
r.matchers[i] = m
|
||||
}
|
||||
for _, subRoute := range r.SubRoutes {
|
||||
if err := subRoute.Validate(); err != nil {
|
||||
return errors.Wrap(err, "invalid sub_route")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
if len(c.GroupBy) == 0 {
|
||||
c.GroupBy = []string{model.LabelSource}
|
||||
}
|
||||
if c.CompressByRelatedThreshold == nil {
|
||||
c.CompressByRelatedThreshold = ptr.To(float32(0.85))
|
||||
}
|
||||
for _, subRoute := range c.SubRoutes {
|
||||
if err := subRoute.Validate(); err != nil {
|
||||
return errors.Wrap(err, "invalid sub_route")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
RelatedScore func(a, b [][]float32) (float32, error) // MUST same with vector index.
|
||||
}
|
||||
|
||||
type Group struct {
|
||||
FeedGroup
|
||||
Receivers []string
|
||||
}
|
||||
|
||||
type FeedGroup struct {
|
||||
Name string
|
||||
Time time.Time
|
||||
Labels model.Labels
|
||||
Feeds []*Feed
|
||||
}
|
||||
|
||||
func (g *FeedGroup) ID() string {
|
||||
return fmt.Sprintf("%s-%s", g.Name, timeutil.Format(g.Time))
|
||||
}
|
||||
|
||||
type Feed struct {
|
||||
*model.Feed
|
||||
Related []*Feed `json:"related"`
|
||||
Vectors [][]float32 `json:"-"`
|
||||
}
|
||||
|
||||
// --- Factory code block ---
|
||||
type Factory component.Factory[Router, Config, Dependencies]
|
||||
|
||||
func NewFactory(mockOn ...component.MockOption) Factory {
|
||||
if len(mockOn) > 0 {
|
||||
return component.FactoryFunc[Router, Config, Dependencies](
|
||||
func(instance string, config *Config, dependencies Dependencies) (Router, error) {
|
||||
m := &mockRouter{}
|
||||
component.MockOptions(mockOn).Apply(&m.Mock)
|
||||
|
||||
return m, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return component.FactoryFunc[Router, Config, Dependencies](new)
|
||||
}
|
||||
|
||||
func new(instance string, config *Config, dependencies Dependencies) (Router, error) {
|
||||
return &router{
|
||||
Base: component.New(&component.BaseConfig[Config, Dependencies]{
|
||||
Name: "NotifyRouter",
|
||||
Instance: instance,
|
||||
Config: config,
|
||||
Dependencies: dependencies,
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- Implementation code block ---
|
||||
type router struct {
|
||||
*component.Base[Config, Dependencies]
|
||||
}
|
||||
|
||||
func (r *router) Route(result *rule.Result) (groups []*Group, err error) {
|
||||
// Find route for each feed.
|
||||
feedsByRoute := r.routeFeeds(result.Feeds)
|
||||
|
||||
// Process each route and its feeds.
|
||||
for route, feeds := range feedsByRoute {
|
||||
// Group feeds by labels.
|
||||
groupedFeeds := r.groupFeedsByLabels(route, feeds)
|
||||
|
||||
// Compress related feeds.
|
||||
relatedGroups, err := r.compressRelatedFeeds(route, groupedFeeds)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "compress related feeds")
|
||||
}
|
||||
|
||||
// Build final groups.
|
||||
for ls, feeds := range relatedGroups {
|
||||
groups = append(groups, &Group{
|
||||
FeedGroup: FeedGroup{
|
||||
Name: fmt.Sprintf("%s %s", result.Rule, ls.String()),
|
||||
Time: result.Time,
|
||||
Labels: *ls,
|
||||
Feeds: feeds,
|
||||
},
|
||||
Receivers: route.Receivers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(groups, func(i, j int) bool {
|
||||
return groups[i].Name < groups[j].Name
|
||||
})
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (r *router) routeFeeds(feeds []*block.FeedVO) map[*Route][]*block.FeedVO {
|
||||
config := r.Config()
|
||||
feedsByRoute := make(map[*Route][]*block.FeedVO)
|
||||
for _, feed := range feeds {
|
||||
var targetRoute *Route
|
||||
if matched := config.SubRoutes.Match(feed); matched != nil {
|
||||
targetRoute = &matched.Route
|
||||
} else {
|
||||
// Fallback to default route.
|
||||
targetRoute = &config.Route
|
||||
}
|
||||
feedsByRoute[targetRoute] = append(feedsByRoute[targetRoute], feed)
|
||||
}
|
||||
|
||||
return feedsByRoute
|
||||
}
|
||||
|
||||
func (r *router) groupFeedsByLabels(route *Route, feeds []*block.FeedVO) map[*model.Labels][]*block.FeedVO {
|
||||
groupedFeeds := make(map[*model.Labels][]*block.FeedVO)
|
||||
|
||||
labelGroups := make(map[string]*model.Labels)
|
||||
for _, feed := range feeds {
|
||||
var group model.Labels
|
||||
for _, key := range route.GroupBy {
|
||||
value := feed.Labels.Get(key)
|
||||
group.Put(key, value, true)
|
||||
}
|
||||
|
||||
groupKey := group.String()
|
||||
labelGroup, exists := labelGroups[groupKey]
|
||||
if !exists {
|
||||
labelGroups[groupKey] = &group
|
||||
labelGroup = &group
|
||||
}
|
||||
|
||||
groupedFeeds[labelGroup] = append(groupedFeeds[labelGroup], feed)
|
||||
}
|
||||
|
||||
return groupedFeeds
|
||||
}
|
||||
|
||||
func (r *router) compressRelatedFeeds(
|
||||
route *Route, // config
|
||||
groupedFeeds map[*model.Labels][]*block.FeedVO, // group id -> feeds
|
||||
) (map[*model.Labels][]*Feed, error) { // group id -> feeds with related feeds
|
||||
result := make(map[*model.Labels][]*Feed)
|
||||
|
||||
for ls, feeds := range groupedFeeds { // per group
|
||||
fs, err := r.compressRelatedFeedsForGroup(route, feeds)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "compress related feeds")
|
||||
}
|
||||
result[ls] = fs
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *router) compressRelatedFeedsForGroup(
|
||||
route *Route, // config
|
||||
feeds []*block.FeedVO, // feeds
|
||||
) ([]*Feed, error) {
|
||||
feedsWithRelated := make([]*Feed, 0, len(feeds)/2)
|
||||
for _, feed := range feeds {
|
||||
|
||||
foundRelated := false
|
||||
for i := range feedsWithRelated {
|
||||
// Try join with previous feeds.
|
||||
score, err := r.Dependencies().RelatedScore(feedsWithRelated[i].Vectors, feed.Vectors)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "related score")
|
||||
}
|
||||
|
||||
if score >= *route.CompressByRelatedThreshold {
|
||||
foundRelated = true
|
||||
feedsWithRelated[i].Related = append(feedsWithRelated[i].Related, &Feed{
|
||||
Feed: feed.Feed,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If not found related, create a group by itself.
|
||||
if !foundRelated {
|
||||
feedsWithRelated = append(feedsWithRelated, &Feed{
|
||||
Feed: feed.Feed,
|
||||
Vectors: feed.Vectors,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return feedsWithRelated, nil
|
||||
}
|
||||
|
||||
type mockRouter struct {
|
||||
component.Mock
|
||||
}
|
||||
|
||||
func (m *mockRouter) Route(result *rule.Result) (groups []*Group, err error) {
|
||||
m.Called(result)
|
||||
|
||||
return groups, err
|
||||
}
|
||||
770
pkg/notify/route/route_test.go
Normal file
770
pkg/notify/route/route_test.go
Normal file
@@ -0,0 +1,770 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"k8s.io/utils/ptr"
|
||||
|
||||
"github.com/glidea/zenfeed/pkg/component"
|
||||
"github.com/glidea/zenfeed/pkg/model"
|
||||
"github.com/glidea/zenfeed/pkg/schedule/rule"
|
||||
"github.com/glidea/zenfeed/pkg/storage/feed/block"
|
||||
"github.com/glidea/zenfeed/pkg/test"
|
||||
timeutil "github.com/glidea/zenfeed/pkg/util/time"
|
||||
)
|
||||
|
||||
func TestRoute(t *testing.T) {
|
||||
RegisterTestingT(t)
|
||||
|
||||
type givenDetail struct {
|
||||
config *Config
|
||||
relatedScore func(m *mock.Mock) // Mock setup for RelatedScore.
|
||||
}
|
||||
type whenDetail struct {
|
||||
ruleResult *rule.Result
|
||||
}
|
||||
type thenExpected struct {
|
||||
groups []*Group
|
||||
isErr bool
|
||||
errMsg string
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
testFeeds := []*block.FeedVO{
|
||||
{
|
||||
Feed: &model.Feed{
|
||||
ID: 1,
|
||||
Labels: model.Labels{
|
||||
{Key: model.LabelSource, Value: "TechCrunch"},
|
||||
{Key: "category", Value: "AI"},
|
||||
{Key: model.LabelTitle, Value: "Tech News 1"},
|
||||
{Key: model.LabelLink, Value: "http://example.com/tech1"},
|
||||
},
|
||||
Time: now,
|
||||
},
|
||||
Vectors: [][]float32{{0.1, 0.2}},
|
||||
},
|
||||
{
|
||||
Feed: &model.Feed{
|
||||
ID: 2,
|
||||
Labels: model.Labels{
|
||||
{Key: model.LabelSource, Value: "TechCrunch"},
|
||||
{Key: "category", Value: "AI"},
|
||||
{Key: model.LabelTitle, Value: "Tech News 2"},
|
||||
{Key: model.LabelLink, Value: "http://example.com/tech2"},
|
||||
},
|
||||
Time: now,
|
||||
},
|
||||
Vectors: [][]float32{{0.11, 0.21}},
|
||||
},
|
||||
{
|
||||
Feed: &model.Feed{
|
||||
ID: 3,
|
||||
Labels: model.Labels{
|
||||
{Key: model.LabelSource, Value: "Bloomberg"},
|
||||
{Key: "category", Value: "Markets"},
|
||||
{Key: model.LabelTitle, Value: "Finance News 1"},
|
||||
{Key: model.LabelLink, Value: "http://example.com/finance1"},
|
||||
},
|
||||
Time: now,
|
||||
},
|
||||
Vectors: [][]float32{{0.8, 0.9}},
|
||||
},
|
||||
{
|
||||
Feed: &model.Feed{
|
||||
ID: 4,
|
||||
Labels: model.Labels{
|
||||
{Key: model.LabelSource, Value: "TechCrunch"},
|
||||
{Key: "category", Value: "Hardware"},
|
||||
{Key: model.LabelTitle, Value: "Specific Tech News"},
|
||||
{Key: model.LabelLink, Value: "http://example.com/tech_specific"},
|
||||
},
|
||||
Time: now,
|
||||
},
|
||||
Vectors: [][]float32{{0.5, 0.5}},
|
||||
},
|
||||
{
|
||||
Feed: &model.Feed{
|
||||
ID: 5,
|
||||
Labels: model.Labels{
|
||||
{Key: model.LabelSource, Value: "OtherSource"},
|
||||
{Key: "category", Value: "Sports"},
|
||||
{Key: model.LabelTitle, Value: "Non-Matching Category News"},
|
||||
{Key: model.LabelLink, Value: "http://example.com/other"},
|
||||
},
|
||||
Time: now,
|
||||
},
|
||||
Vectors: [][]float32{{0.9, 0.1}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tf := range testFeeds {
|
||||
tf.Labels.EnsureSorted()
|
||||
}
|
||||
|
||||
tests := []test.Case[givenDetail, whenDetail, thenExpected]{
|
||||
{
|
||||
Scenario: "Basic routing and grouping by source",
|
||||
Given: "a default router config grouping by source and high related threshold",
|
||||
When: "routing feeds from different sources",
|
||||
Then: "should group feeds by source into separate groups without compression",
|
||||
GivenDetail: givenDetail{
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
GroupBy: []string{model.LabelSource},
|
||||
CompressByRelatedThreshold: ptr.To(float32(0.99)),
|
||||
Receivers: []string{"default-receiver"},
|
||||
},
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
ruleResult: &rule.Result{
|
||||
Rule: "TestRule",
|
||||
Time: now,
|
||||
Feeds: []*block.FeedVO{testFeeds[0], testFeeds[2], testFeeds[4]},
|
||||
},
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
groups: []*Group{
|
||||
{
|
||||
FeedGroup: FeedGroup{
|
||||
Name: fmt.Sprintf("TestRule %s", model.Labels{{Key: model.LabelSource, Value: "Bloomberg"}}.String()),
|
||||
Time: now,
|
||||
Labels: model.Labels{{Key: model.LabelSource, Value: "Bloomberg"}},
|
||||
Feeds: []*Feed{
|
||||
{Feed: testFeeds[2].Feed, Vectors: testFeeds[2].Vectors},
|
||||
},
|
||||
},
|
||||
Receivers: []string{"default-receiver"},
|
||||
},
|
||||
{
|
||||
FeedGroup: FeedGroup{
|
||||
Name: fmt.Sprintf("TestRule %s", model.Labels{{Key: model.LabelSource, Value: "OtherSource"}}.String()),
|
||||
Time: now,
|
||||
Labels: model.Labels{{Key: model.LabelSource, Value: "OtherSource"}},
|
||||
Feeds: []*Feed{
|
||||
{Feed: testFeeds[4].Feed, Vectors: testFeeds[4].Vectors},
|
||||
},
|
||||
},
|
||||
Receivers: []string{"default-receiver"},
|
||||
},
|
||||
{
|
||||
FeedGroup: FeedGroup{
|
||||
Name: fmt.Sprintf("TestRule %s", model.Labels{{Key: model.LabelSource, Value: "TechCrunch"}}.String()),
|
||||
Time: now,
|
||||
Labels: model.Labels{{Key: model.LabelSource, Value: "TechCrunch"}},
|
||||
Feeds: []*Feed{
|
||||
{Feed: testFeeds[0].Feed, Vectors: testFeeds[0].Vectors},
|
||||
},
|
||||
},
|
||||
Receivers: []string{"default-receiver"},
|
||||
},
|
||||
},
|
||||
isErr: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "Routing with sub-route matching",
|
||||
Given: "a router config with a sub-route for AI category",
|
||||
When: "routing feeds including AI category",
|
||||
Then: "should apply the sub-route's receivers and settings to matching feeds",
|
||||
GivenDetail: givenDetail{
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
GroupBy: []string{model.LabelSource},
|
||||
CompressByRelatedThreshold: ptr.To(float32(0.99)),
|
||||
Receivers: []string{"default-receiver"},
|
||||
SubRoutes: SubRoutes{
|
||||
{
|
||||
Route: Route{
|
||||
GroupBy: []string{model.LabelSource, "category"},
|
||||
CompressByRelatedThreshold: ptr.To(float32(0.99)),
|
||||
Receivers: []string{"ai-receiver"},
|
||||
},
|
||||
Matchers: []string{"category=AI"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
relatedScore: func(m *mock.Mock) {
|
||||
m.On("RelatedScore", mock.Anything, mock.Anything).Return(float32(0.1), nil)
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
ruleResult: &rule.Result{
|
||||
Rule: "SubRouteRule",
|
||||
Time: now,
|
||||
Feeds: []*block.FeedVO{testFeeds[0], testFeeds[1], testFeeds[4]},
|
||||
},
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
groups: []*Group{
|
||||
{
|
||||
FeedGroup: FeedGroup{
|
||||
Name: fmt.Sprintf("SubRouteRule %s", model.Labels{
|
||||
{Key: model.LabelSource, Value: "TechCrunch"},
|
||||
{Key: "category", Value: "AI"},
|
||||
}.String()),
|
||||
Time: now,
|
||||
Labels: model.Labels{
|
||||
{Key: "category", Value: "AI"},
|
||||
{Key: model.LabelSource, Value: "TechCrunch"},
|
||||
},
|
||||
Feeds: []*Feed{
|
||||
{Feed: testFeeds[0].Feed, Vectors: testFeeds[0].Vectors},
|
||||
{Feed: testFeeds[1].Feed, Vectors: testFeeds[1].Vectors},
|
||||
},
|
||||
},
|
||||
Receivers: []string{"ai-receiver"},
|
||||
},
|
||||
{
|
||||
FeedGroup: FeedGroup{
|
||||
Name: fmt.Sprintf("SubRouteRule %s", model.Labels{{Key: model.LabelSource, Value: "OtherSource"}}.String()),
|
||||
Time: now,
|
||||
Labels: model.Labels{{Key: model.LabelSource, Value: "OtherSource"}},
|
||||
Feeds: []*Feed{
|
||||
{Feed: testFeeds[4].Feed, Vectors: testFeeds[4].Vectors},
|
||||
},
|
||||
},
|
||||
Receivers: []string{"default-receiver"},
|
||||
},
|
||||
},
|
||||
isErr: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "Compressing related feeds",
|
||||
Given: "a router config with a low related threshold",
|
||||
When: "routing feeds with similar vectors",
|
||||
Then: "should compress related feeds into a single group entry",
|
||||
GivenDetail: givenDetail{
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
GroupBy: []string{model.LabelSource, "category"},
|
||||
CompressByRelatedThreshold: ptr.To(float32(0.8)),
|
||||
Receivers: []string{"compress-receiver"},
|
||||
},
|
||||
},
|
||||
relatedScore: func(m *mock.Mock) {
|
||||
m.On("RelatedScore", testFeeds[0].Vectors, testFeeds[1].Vectors).Return(float32(0.9), nil)
|
||||
m.On("RelatedScore", mock.Anything, mock.Anything).Maybe().Return(float32(0.1), nil)
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
ruleResult: &rule.Result{
|
||||
Rule: "CompressRule",
|
||||
Time: now,
|
||||
Feeds: []*block.FeedVO{testFeeds[0], testFeeds[1], testFeeds[3]},
|
||||
},
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
groups: []*Group{
|
||||
{
|
||||
FeedGroup: FeedGroup{
|
||||
Name: fmt.Sprintf("CompressRule %s", model.Labels{
|
||||
{Key: model.LabelSource, Value: "TechCrunch"},
|
||||
{Key: "category", Value: "AI"},
|
||||
}.String()),
|
||||
Time: now,
|
||||
Labels: model.Labels{
|
||||
{Key: "category", Value: "AI"},
|
||||
{Key: model.LabelSource, Value: "TechCrunch"},
|
||||
},
|
||||
Feeds: []*Feed{
|
||||
{
|
||||
Feed: testFeeds[0].Feed,
|
||||
Vectors: testFeeds[0].Vectors,
|
||||
Related: []*Feed{
|
||||
{Feed: testFeeds[1].Feed},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Receivers: []string{"compress-receiver"},
|
||||
},
|
||||
{
|
||||
FeedGroup: FeedGroup{
|
||||
Name: fmt.Sprintf("CompressRule %s", model.Labels{
|
||||
{Key: model.LabelSource, Value: "TechCrunch"},
|
||||
{Key: "category", Value: "Hardware"},
|
||||
}.String()),
|
||||
Time: now,
|
||||
Labels: model.Labels{
|
||||
{Key: "category", Value: "Hardware"},
|
||||
{Key: model.LabelSource, Value: "TechCrunch"},
|
||||
},
|
||||
Feeds: []*Feed{
|
||||
{Feed: testFeeds[3].Feed, Vectors: testFeeds[3].Vectors},
|
||||
},
|
||||
},
|
||||
Receivers: []string{"compress-receiver"},
|
||||
},
|
||||
},
|
||||
isErr: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "Error during related score calculation",
|
||||
Given: "a router config and RelatedScore dependency returns an error",
|
||||
When: "routing feeds requiring related score check",
|
||||
Then: "should return an error originating from RelatedScore",
|
||||
GivenDetail: givenDetail{
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
GroupBy: []string{model.LabelSource},
|
||||
CompressByRelatedThreshold: ptr.To(float32(0.8)),
|
||||
Receivers: []string{"error-receiver"},
|
||||
},
|
||||
},
|
||||
relatedScore: func(m *mock.Mock) {
|
||||
m.On("RelatedScore", testFeeds[0].Vectors, testFeeds[1].Vectors).Return(float32(0), errors.New("related score calculation failed"))
|
||||
m.On("RelatedScore", mock.Anything, mock.Anything).Maybe().Return(float32(0.1), nil)
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
ruleResult: &rule.Result{
|
||||
Rule: "ErrorRule",
|
||||
Time: now,
|
||||
Feeds: []*block.FeedVO{testFeeds[0], testFeeds[1]},
|
||||
},
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
groups: nil,
|
||||
isErr: true,
|
||||
errMsg: "compress related feeds: compress related feeds: related score: related score calculation failed",
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "No feeds to route",
|
||||
Given: "a standard router config",
|
||||
When: "routing an empty list of feeds",
|
||||
Then: "should return an empty list of groups without error",
|
||||
GivenDetail: givenDetail{
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
GroupBy: []string{model.LabelSource},
|
||||
CompressByRelatedThreshold: ptr.To(float32(0.85)),
|
||||
Receivers: []string{"default-receiver"},
|
||||
},
|
||||
},
|
||||
relatedScore: func(m *mock.Mock) {
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
ruleResult: &rule.Result{
|
||||
Rule: "EmptyRule",
|
||||
Time: now,
|
||||
Feeds: []*block.FeedVO{},
|
||||
},
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
groups: []*Group{},
|
||||
isErr: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.Scenario, func(t *testing.T) {
|
||||
for _, group := range tt.ThenExpected.groups {
|
||||
group.Labels.EnsureSorted()
|
||||
}
|
||||
err := tt.GivenDetail.config.Validate()
|
||||
Expect(err).NotTo(HaveOccurred(), "Config validation failed during test setup")
|
||||
|
||||
mockDep := mockDependencies{}
|
||||
if tt.GivenDetail.relatedScore != nil {
|
||||
tt.GivenDetail.relatedScore(&mockDep.Mock)
|
||||
}
|
||||
|
||||
routerInstance := &router{
|
||||
Base: component.New(&component.BaseConfig[Config, Dependencies]{
|
||||
Name: "TestRouter",
|
||||
Instance: "test",
|
||||
Config: tt.GivenDetail.config,
|
||||
Dependencies: Dependencies{
|
||||
RelatedScore: mockDep.RelatedScore,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
groups, err := routerInstance.Route(tt.WhenDetail.ruleResult)
|
||||
|
||||
if tt.ThenExpected.isErr {
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.errMsg))
|
||||
Expect(groups).To(BeNil())
|
||||
} else {
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
compareGroups(groups, tt.ThenExpected.groups)
|
||||
}
|
||||
|
||||
mockDep.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockDependencies struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *mockDependencies) RelatedScore(a, b [][]float32) (float32, error) {
|
||||
args := m.Called(a, b)
|
||||
return args.Get(0).(float32), args.Error(1)
|
||||
}
|
||||
|
||||
func compareGroups(actual, expected []*Group) {
|
||||
Expect(actual).To(HaveLen(len(expected)), "Number of groups mismatch")
|
||||
|
||||
for i := range expected {
|
||||
actualGroup := actual[i]
|
||||
expectedGroup := expected[i]
|
||||
|
||||
Expect(actualGroup.Name).To(Equal(expectedGroup.Name), fmt.Sprintf("Group %d Name mismatch", i))
|
||||
Expect(timeutil.Format(actualGroup.Time)).To(Equal(timeutil.Format(expectedGroup.Time)), fmt.Sprintf("Group %d Time mismatch", i))
|
||||
Expect(actualGroup.Labels).To(Equal(expectedGroup.Labels), fmt.Sprintf("Group %d Labels mismatch", i))
|
||||
Expect(actualGroup.Receivers).To(Equal(expectedGroup.Receivers), fmt.Sprintf("Group %d Receivers mismatch", i))
|
||||
|
||||
compareFeedsWithRelated(actualGroup.Feeds, expectedGroup.Feeds, i)
|
||||
}
|
||||
}
|
||||
|
||||
func compareFeedsWithRelated(actual, expected []*Feed, groupIndex int) {
|
||||
Expect(actual).To(HaveLen(len(expected)), fmt.Sprintf("Group %d: Number of primary feeds mismatch", groupIndex))
|
||||
|
||||
for i := range expected {
|
||||
actualFeed := actual[i]
|
||||
expectedFeed := expected[i]
|
||||
|
||||
Expect(actualFeed.Feed).To(Equal(expectedFeed.Feed), fmt.Sprintf("Group %d, Feed %d: Primary feed mismatch", groupIndex, i))
|
||||
|
||||
Expect(actualFeed.Related).To(HaveLen(len(expectedFeed.Related)), fmt.Sprintf("Group %d, Feed %d: Number of related feeds mismatch", groupIndex, i))
|
||||
for j := range expectedFeed.Related {
|
||||
Expect(actualFeed.Related[j].Feed).To(Equal(expectedFeed.Related[j].Feed), fmt.Sprintf("Group %d, Feed %d, Related %d: Related feed mismatch", groupIndex, i, j))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
RegisterTestingT(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "Valid default config",
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec1"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid config with explicit defaults",
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
GroupBy: []string{model.LabelSource},
|
||||
CompressByRelatedThreshold: ptr.To(float32(0.85)),
|
||||
Receivers: []string{"rec1"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Valid config with sub-route",
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec1"},
|
||||
SubRoutes: SubRoutes{
|
||||
{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec2"},
|
||||
},
|
||||
Matchers: []string{"label=value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid sub-route missing matchers",
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec1"},
|
||||
SubRoutes: SubRoutes{
|
||||
{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid sub_route: matchers is required",
|
||||
},
|
||||
{
|
||||
name: "Invalid sub-route matcher format",
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec1"},
|
||||
SubRoutes: SubRoutes{
|
||||
{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec2"},
|
||||
},
|
||||
Matchers: []string{"invalid-matcher"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid sub_route: invalid matcher: invalid matcher",
|
||||
},
|
||||
{
|
||||
name: "Valid nested sub-route",
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec1"},
|
||||
SubRoutes: SubRoutes{
|
||||
{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec2"},
|
||||
SubRoutes: SubRoutes{
|
||||
{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec3"},
|
||||
},
|
||||
Matchers: []string{"nested=true"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Matchers: []string{"label=value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Invalid nested sub-route",
|
||||
config: &Config{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec1"},
|
||||
SubRoutes: SubRoutes{
|
||||
{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec2"},
|
||||
SubRoutes: SubRoutes{
|
||||
{
|
||||
Route: Route{
|
||||
Receivers: []string{"rec3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Matchers: []string{"label=value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "invalid sub_route: invalid sub_route: matchers is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.Validate()
|
||||
if tt.wantErr {
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring(tt.errMsg))
|
||||
} else {
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(tt.config.GroupBy).NotTo(BeEmpty())
|
||||
Expect(tt.config.CompressByRelatedThreshold).NotTo(BeNil())
|
||||
for _, sr := range tt.config.SubRoutes {
|
||||
Expect(sr.GroupBy).NotTo(BeEmpty())
|
||||
Expect(sr.CompressByRelatedThreshold).NotTo(BeNil())
|
||||
for _, nestedSr := range sr.SubRoutes {
|
||||
Expect(nestedSr.GroupBy).NotTo(BeEmpty())
|
||||
Expect(nestedSr.CompressByRelatedThreshold).NotTo(BeNil())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubRoutes_Match(t *testing.T) {
|
||||
RegisterTestingT(t)
|
||||
|
||||
now := time.Now()
|
||||
feedAI := &block.FeedVO{
|
||||
Feed: &model.Feed{
|
||||
ID: 10,
|
||||
Labels: model.Labels{
|
||||
{Key: "category", Value: "AI"},
|
||||
{Key: model.LabelSource, Value: "TechCrunch"},
|
||||
{Key: model.LabelTitle, Value: "AI Feed"},
|
||||
{Key: model.LabelLink, Value: "http://example.com/ai"},
|
||||
},
|
||||
Time: now,
|
||||
},
|
||||
}
|
||||
feedHardware := &block.FeedVO{
|
||||
Feed: &model.Feed{
|
||||
ID: 11,
|
||||
Labels: model.Labels{
|
||||
{Key: "category", Value: "Hardware"},
|
||||
{Key: model.LabelSource, Value: "TechCrunch"},
|
||||
{Key: model.LabelTitle, Value: "Hardware Feed"},
|
||||
{Key: model.LabelLink, Value: "http://example.com/hw"},
|
||||
},
|
||||
Time: now,
|
||||
},
|
||||
}
|
||||
feedSports := &block.FeedVO{
|
||||
Feed: &model.Feed{
|
||||
ID: 12,
|
||||
Labels: model.Labels{
|
||||
{Key: "category", Value: "Sports"},
|
||||
{Key: model.LabelSource, Value: "OtherSource"},
|
||||
{Key: model.LabelTitle, Value: "Sports Feed"},
|
||||
{Key: model.LabelLink, Value: "http://example.com/sports"},
|
||||
},
|
||||
Time: now,
|
||||
},
|
||||
}
|
||||
feedNestedLow := &block.FeedVO{
|
||||
Feed: &model.Feed{
|
||||
ID: 13,
|
||||
Labels: model.Labels{
|
||||
{Key: "category", Value: "Nested"},
|
||||
{Key: "priority", Value: "low"},
|
||||
{Key: model.LabelTitle, Value: "Nested Low Prio"},
|
||||
{Key: model.LabelLink, Value: "http://example.com/nested_low"},
|
||||
},
|
||||
Time: now,
|
||||
},
|
||||
}
|
||||
feedNestedHigh := &block.FeedVO{
|
||||
Feed: &model.Feed{
|
||||
ID: 14,
|
||||
Labels: model.Labels{
|
||||
{Key: "category", Value: "Nested"},
|
||||
{Key: "priority", Value: "high"},
|
||||
{Key: model.LabelTitle, Value: "Nested High Prio"},
|
||||
{Key: model.LabelLink, Value: "http://example.com/nested_high"},
|
||||
},
|
||||
Time: now,
|
||||
},
|
||||
}
|
||||
|
||||
feedAI.Labels.EnsureSorted()
|
||||
feedHardware.Labels.EnsureSorted()
|
||||
feedSports.Labels.EnsureSorted()
|
||||
feedNestedLow.Labels.EnsureSorted()
|
||||
feedNestedHigh.Labels.EnsureSorted()
|
||||
|
||||
subRouteAI := &SubRoute{
|
||||
Route: Route{Receivers: []string{"ai"}},
|
||||
Matchers: []string{"category=AI"},
|
||||
}
|
||||
err := subRouteAI.Validate()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
subRouteHardware := &SubRoute{
|
||||
Route: Route{Receivers: []string{"hardware"}},
|
||||
Matchers: []string{"category=Hardware"},
|
||||
}
|
||||
err = subRouteHardware.Validate()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
subRouteTechCrunchNotAI := &SubRoute{
|
||||
Route: Route{Receivers: []string{"tc-not-ai"}},
|
||||
Matchers: []string{model.LabelSource + "=TechCrunch", "category!=AI"},
|
||||
}
|
||||
err = subRouteTechCrunchNotAI.Validate()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
subRouteNested := &SubRoute{
|
||||
Route: Route{
|
||||
Receivers: []string{"nested-route"},
|
||||
SubRoutes: SubRoutes{
|
||||
{
|
||||
Route: Route{Receivers: []string{"deep-nested"}},
|
||||
Matchers: []string{"priority=high"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Matchers: []string{"category=Nested"},
|
||||
}
|
||||
err = subRouteNested.Validate()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
nestedDeepSubRoute := subRouteNested.SubRoutes[0]
|
||||
err = nestedDeepSubRoute.Validate()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
routes := SubRoutes{subRouteAI, subRouteHardware, subRouteTechCrunchNotAI, subRouteNested}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
feed *block.FeedVO
|
||||
expectedRoute *SubRoute
|
||||
}{
|
||||
{
|
||||
name: "Match AI category",
|
||||
feed: feedAI,
|
||||
expectedRoute: subRouteAI,
|
||||
},
|
||||
{
|
||||
name: "Match Hardware category",
|
||||
feed: feedHardware,
|
||||
expectedRoute: subRouteHardware,
|
||||
},
|
||||
{
|
||||
name: "Match TechCrunch but not AI",
|
||||
feed: feedHardware,
|
||||
expectedRoute: subRouteHardware,
|
||||
},
|
||||
{
|
||||
name: "No matching category",
|
||||
feed: feedSports,
|
||||
expectedRoute: nil,
|
||||
},
|
||||
{
|
||||
name: "Match nested route (top level)",
|
||||
feed: feedNestedLow,
|
||||
expectedRoute: subRouteNested,
|
||||
},
|
||||
{
|
||||
name: "Match nested route (deep level)",
|
||||
feed: feedNestedHigh,
|
||||
expectedRoute: nestedDeepSubRoute,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matchedRoute := routes.Match(tt.feed)
|
||||
if tt.expectedRoute == nil {
|
||||
Expect(matchedRoute).To(BeNil())
|
||||
} else {
|
||||
Expect(matchedRoute).NotTo(BeNil())
|
||||
Expect(matchedRoute.Receivers).To(Equal(tt.expectedRoute.Receivers))
|
||||
Expect(matchedRoute.Matchers).To(Equal(tt.expectedRoute.Matchers))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user