package rule import ( "context" "testing" "time" . "github.com/onsi/gomega" "github.com/pkg/errors" "github.com/stretchr/testify/mock" "github.com/glidea/zenfeed/pkg/component" "github.com/glidea/zenfeed/pkg/model" "github.com/glidea/zenfeed/pkg/storage/feed" "github.com/glidea/zenfeed/pkg/storage/feed/block" "github.com/glidea/zenfeed/pkg/test" ) func TestWatchExecute(t *testing.T) { RegisterTestingT(t) // --- Test types --- type givenDetail struct { config *Config feedStorageMock func(m *mock.Mock) // Function to set expectations } type whenDetail struct { start time.Time end time.Time } type thenExpected struct { queryCalled bool queryOpts *block.QueryOptions // Expected query options sentToOut map[time.Time]*Result // Expected results sent to Out, keyed by interval start time err error // Expected error (can be wrapped) isErr bool } // --- Test cases --- watchInterval := 10 * time.Minute baseConfig := &Config{ Name: "test-watch", WatchInterval: watchInterval, Threshold: 0.7, Query: "test query", LabelFilters: []string{"source:test"}, } now := time.Date(2024, 1, 15, 10, 35, 0, 0, time.Local) // Example time: 10:35 // The execute function calculates start/end based on its input 'end' time and interval // Let's define the input range for the 'execute' call execEnd := now execStart := execEnd.Add(-3 * watchInterval) // Matches the logic in watch.go iter() // Define feed times relative to the interval interval1Start := time.Unix(now.Unix(), 0).Truncate(watchInterval) // 10:30 interval2Start := interval1Start.Add(-watchInterval) // 10:20 // interval3Start := interval2Start.Add(-watchInterval) // 10:10, covered by execStart feedTime1 := interval1Start.Add(1 * time.Minute) // 10:31 (belongs to 10:30 interval) feedTime2 := interval2Start.Add(2 * time.Minute) // 10:22 (belongs to 10:20 interval) feedTime3 := interval2Start.Add(5 * time.Minute) // 10:25 (belongs to 10:20 interval) mockFeeds := []*block.FeedVO{ {Feed: &model.Feed{ID: 1, Time: feedTime1, Labels: model.Labels{{Key: "content_hash", Value: "a"}}}}, {Feed: &model.Feed{ID: 2, Time: feedTime2, Labels: model.Labels{{Key: "content_hash", Value: "b"}}}}, {Feed: &model.Feed{ID: 3, Time: feedTime3, Labels: model.Labels{{Key: "content_hash", Value: "c"}}}}, } queryError := errors.New("database error") tests := []test.Case[givenDetail, whenDetail, thenExpected]{ { Scenario: "Feeds found, should query and notify grouped by interval", Given: "a watch config and FeedStorage returns feeds across intervals", When: "execute is called with a time range", Then: "FeedStorage should be queried, and results grouped by WatchInterval sent to Out", GivenDetail: givenDetail{ config: baseConfig, feedStorageMock: func(m *mock.Mock) { m.On("Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")). Return(mockFeeds, nil) }, }, WhenDetail: whenDetail{ start: execStart, end: execEnd, }, ThenExpected: thenExpected{ queryCalled: true, queryOpts: &block.QueryOptions{ Query: baseConfig.Query, Threshold: baseConfig.Threshold, LabelFilters: baseConfig.LabelFilters, Start: execStart, End: execEnd, Limit: 500, }, sentToOut: map[time.Time]*Result{ interval1Start: { // 10:30 interval Rule: baseConfig.Name, Time: interval1Start, Feeds: []*block.FeedVO{ mockFeeds[0], // ID 1 at 10:31 }, }, interval2Start: { // 10:20 interval Rule: baseConfig.Name, Time: interval2Start, Feeds: []*block.FeedVO{ mockFeeds[1], // ID 2 at 10:22 mockFeeds[2], // ID 3 at 10:25 }, }, }, }, }, { Scenario: "No feeds found, should query but not notify", Given: "a watch config and FeedStorage returns no feeds", When: "execute is called", Then: "FeedStorage should be queried but nothing sent to Out", GivenDetail: givenDetail{ config: baseConfig, feedStorageMock: func(m *mock.Mock) { m.On("Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")). Return([]*block.FeedVO{}, nil) // Empty result }, }, WhenDetail: whenDetail{ start: execStart, end: execEnd, }, ThenExpected: thenExpected{ queryCalled: true, queryOpts: &block.QueryOptions{ Query: baseConfig.Query, Threshold: baseConfig.Threshold, LabelFilters: baseConfig.LabelFilters, Start: execStart, End: execEnd, Limit: 500, }, sentToOut: map[time.Time]*Result{}, // Expect empty map or nil }, }, { Scenario: "FeedStorage query error, should return error", Given: "a watch config and FeedStorage returns an error", When: "execute is called", Then: "FeedStorage should be queried and an error returned", GivenDetail: givenDetail{ config: baseConfig, feedStorageMock: func(m *mock.Mock) { m.On("Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")). Return([]*block.FeedVO{}, queryError) }, }, WhenDetail: whenDetail{ start: execStart, end: execEnd, }, ThenExpected: thenExpected{ queryCalled: true, queryOpts: &block.QueryOptions{ // Still expect query options to be set Query: baseConfig.Query, Threshold: baseConfig.Threshold, LabelFilters: baseConfig.LabelFilters, Start: execStart, End: execEnd, Limit: 500, }, sentToOut: nil, // Nothing sent on error err: errors.Wrap(queryError, "query"), isErr: true, }, }, } // --- Run tests --- for _, tt := range tests { t.Run(tt.Scenario, func(t *testing.T) { // --- Given --- configCopy := *tt.GivenDetail.config // Use a copy for safety outCh := make(chan *Result, 5) // Buffer size accommodates potential multiple sends var capturedOpts block.QueryOptions var mockStorageInstance *mock.Mock // Create mock factory using feed.NewFactory and capture the mock instance mockOption := component.MockOption(func(m *mock.Mock) { mockStorageInstance = m // Capture the mock instance // Setup mock expectation for FeedStorage.Query, including option capture if tt.GivenDetail.feedStorageMock != nil { tt.GivenDetail.feedStorageMock(m) // Enhance the mock setup to capture arguments for _, call := range m.ExpectedCalls { if call.Method == "Query" { for i, arg := range call.Arguments { if _, ok := arg.(mock.AnythingOfTypeArgument); ok && i == 1 { // Assuming options is the second argument (index 1) call.Arguments[i] = mock.MatchedBy(func(opts block.QueryOptions) bool { capturedOpts = opts // Capture the options return true }) break } } break // Assume only one Query expectation per test case here } } } }) // NOTE: feed.NewFactory needs *config.App, we pass nil as it's not used by the mock mockFeedFactory := feed.NewFactory(mockOption) mockFeedStorage, factoryErr := mockFeedFactory.New(component.Global, nil, feed.Dependencies{}) // Use factory to create mock Expect(factoryErr).NotTo(HaveOccurred()) dependencies := Dependencies{ FeedStorage: mockFeedStorage, // Use the created mock storage Out: outCh, } // Use the specific type `watch` for testing its method r := &watch{ Base: component.New(&component.BaseConfig[Config, Dependencies]{ Name: "WatchRuler", Instance: "test-instance", Config: &configCopy, Dependencies: dependencies, }), } // --- When --- err := r.execute(context.Background(), tt.WhenDetail.start, tt.WhenDetail.end) // --- Then --- close(outCh) // Close channel to range over received results if tt.ThenExpected.isErr { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.err.Error())) // Check if the error contains the expected wrapped message Expect(len(outCh)).To(Equal(0)) // No results sent on error } else { Expect(err).NotTo(HaveOccurred()) receivedResults := make(map[time.Time]*Result) for res := range outCh { receivedResults[res.Time] = res } Expect(len(receivedResults)).To(Equal(len(tt.ThenExpected.sentToOut)), "Mismatch in number of results sent") for expectedTime, expectedResult := range tt.ThenExpected.sentToOut { receivedResult, ok := receivedResults[expectedTime] Expect(ok).To(BeTrue(), "Expected result for time %v not found", expectedTime) Expect(receivedResult.Rule).To(Equal(expectedResult.Rule)) Expect(receivedResult.Time.Unix()).To(Equal(expectedResult.Time.Unix())) Expect(receivedResult.Feeds).To(ConsistOf(expectedResult.Feeds)) // Use ConsistOf for order-independent comparison } } // Verify FeedStorage.Query call and options using the captured mock instance if mockStorageInstance != nil { if tt.ThenExpected.queryCalled { mockStorageInstance.AssertCalled(t, "Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")) // Assert specific fields of the captured options Expect(capturedOpts.Query).To(Equal(tt.ThenExpected.queryOpts.Query), "Query string mismatch") Expect(capturedOpts.Threshold).To(Equal(tt.ThenExpected.queryOpts.Threshold), "Threshold mismatch") Expect(capturedOpts.LabelFilters).To(Equal(tt.ThenExpected.queryOpts.LabelFilters), "LabelFilters mismatch") Expect(capturedOpts.Start.Unix()).To(Equal(tt.ThenExpected.queryOpts.Start.Unix()), "Start time mismatch") Expect(capturedOpts.End.Unix()).To(Equal(tt.ThenExpected.queryOpts.End.Unix()), "End time mismatch") Expect(capturedOpts.Limit).To(Equal(tt.ThenExpected.queryOpts.Limit), "Limit mismatch") } else { mockStorageInstance.AssertNotCalled(t, "Query", mock.Anything, mock.Anything) } // mockStorageInstance.AssertExpectations(t) // Uncomment for strict expectation matching if needed } else if tt.ThenExpected.queryCalled { t.Fatal("Expected query call but mock instance was not captured") } }) } }