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