package rule import ( "context" "testing" "time" . "github.com/onsi/gomega" "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 TestPeriodicExecute(t *testing.T) { RegisterTestingT(t) // --- Test types --- type givenDetail struct { config *Config feedStorageMock func(m *mock.Mock) // Function to set expectations } type whenDetail struct { now time.Time } type thenExpected struct { queryCalled bool queryOpts *block.QueryOptions // Only check relevant fields like start/end sentToOut *Result err error // Expected error (can be wrapped) isErr bool } // --- Test cases --- mockFeeds := []*block.FeedVO{ {Feed: &model.Feed{ID: 1, Labels: model.Labels{{Key: "content_hash", Value: "a"}}}}, {Feed: &model.Feed{ID: 2, Labels: model.Labels{{Key: "content_hash", Value: "b"}}}}, } baseConfig := &Config{ Name: "test-periodic", EveryDay: "09:00~18:00", // Will be parsed in Validate Threshold: 0.7, Query: "test query", } // Manually parse time for expected values startTime, _ := time.ParseInLocation(timeFmt, "09:00", time.Local) endTime, _ := time.ParseInLocation(timeFmt, "18:00", time.Local) crossDayConfig := &Config{ Name: "test-crossday", EveryDay: "-22:00~06:00", // Will be parsed in Validate Threshold: 0.7, Query: "test query", } // Manually parse time for expected values crossStartTime, _ := time.ParseInLocation(timeFmt, "22:00", time.Local) crossEndTime, _ := time.ParseInLocation(timeFmt, "06:00", time.Local) tests := []test.Case[givenDetail, whenDetail, thenExpected]{ { Scenario: "Non-crossDay, feeds found, should query and notify", Given: "a non-crossDay config and FeedStorage returns feeds", When: "execute is called within the configured day", Then: "FeedStorage should be queried with the correct daily time range and result 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{ now: time.Date(2024, 1, 15, 10, 0, 0, 0, time.Local), // 10:00 AM }, ThenExpected: thenExpected{ queryCalled: true, queryOpts: &block.QueryOptions{ Start: time.Date(2024, 1, 15, startTime.Hour(), startTime.Minute(), 0, 0, time.Local), End: time.Date(2024, 1, 15, endTime.Hour(), endTime.Minute(), 0, 0, time.Local), Query: baseConfig.Query, Limit: 500, }, sentToOut: &Result{ Rule: baseConfig.Name, Time: time.Date(2024, 1, 15, startTime.Hour(), startTime.Minute(), 0, 0, time.Local), Feeds: mockFeeds, }, }, }, { Scenario: "CrossDay, feeds found, should query and notify", Given: "a crossDay config and FeedStorage returns feeds", When: "execute is called within the configured day", Then: "FeedStorage should be queried with the correct cross-day time range and result sent to Out", GivenDetail: givenDetail{ config: crossDayConfig, feedStorageMock: func(m *mock.Mock) { m.On("Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")). Return(mockFeeds, nil) }, }, WhenDetail: whenDetail{ now: time.Date(2024, 1, 15, 03, 0, 0, 0, time.Local), // 03:00 AM }, ThenExpected: thenExpected{ queryCalled: true, queryOpts: &block.QueryOptions{ Start: time.Date(2024, 1, 14, crossStartTime.Hour(), crossStartTime.Minute(), 0, 0, time.Local), End: time.Date(2024, 1, 15, crossEndTime.Hour(), crossEndTime.Minute(), 0, 0, time.Local), Query: crossDayConfig.Query, Limit: 500, }, sentToOut: &Result{ Rule: crossDayConfig.Name, Time: time.Date(2024, 1, 14, crossStartTime.Hour(), crossStartTime.Minute(), 0, 0, time.Local), Feeds: mockFeeds, }, }, }, { Scenario: "Non-crossDay, no feeds found, should query but not notify", Given: "a non-crossDay 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{ now: time.Date(2024, 1, 15, 11, 0, 0, 0, time.Local), // 11:00 AM }, ThenExpected: thenExpected{ queryCalled: true, queryOpts: &block.QueryOptions{ Start: time.Date(2024, 1, 15, startTime.Hour(), startTime.Minute(), 0, 0, time.Local), End: time.Date(2024, 1, 15, endTime.Hour(), endTime.Minute(), 0, 0, time.Local), Query: baseConfig.Query, Limit: 500, }, sentToOut: nil, }, }, } // --- Run tests --- for _, tt := range tests { t.Run(tt.Scenario, func(t *testing.T) { // --- Given --- configCopy := *tt.GivenDetail.config err := configCopy.Validate() Expect(err).NotTo(HaveOccurred(), "Config validation failed in test setup") outCh := make(chan *Result, 1) 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 if the mock function exists // Find the Query expectation and add argument capture logic for _, call := range m.ExpectedCalls { if call.Method == "Query" { // Replace the generic matcher for options with one that captures 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 } } } }) 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, } r := &periodic{ Base: component.New(&component.BaseConfig[Config, Dependencies]{ Name: "PeriodicRuler", Instance: "test-instance", Config: &configCopy, Dependencies: dependencies, }), } // --- When --- err = r.execute(context.Background(), tt.WhenDetail.now) // --- Then --- if tt.ThenExpected.isErr { Expect(err).To(HaveOccurred()) // Use MatchError for potentially wrapped errors, providing a more precise check Expect(err).To(MatchError(tt.ThenExpected.err)) Expect(len(outCh)).To(Equal(0)) } else { Expect(err).NotTo(HaveOccurred()) if tt.ThenExpected.sentToOut != nil { Expect(len(outCh)).To(Equal(1)) receivedResult := <-outCh Expect(receivedResult.Rule).To(Equal(tt.ThenExpected.sentToOut.Rule)) Expect(receivedResult.Time.Unix()).To(Equal(tt.ThenExpected.sentToOut.Time.Unix())) Expect(receivedResult.Feeds).To(Equal(tt.ThenExpected.sentToOut.Feeds)) } else { Expect(len(outCh)).To(Equal(0)) } } // Verify FeedStorage.Query call and options using the captured mock instance if mockStorageInstance != nil { // Ensure mock instance was captured if tt.ThenExpected.queryCalled { // Assert the expectation set up in feedStorageMockFn was met mockStorageInstance.AssertCalled(t, "Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")) // Assert specific fields of the captured options 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.Query).To(Equal(tt.ThenExpected.queryOpts.Query), "Query string mismatch") Expect(capturedOpts.Threshold).To(Equal(configCopy.Threshold), "Threshold mismatch") Expect(capturedOpts.LabelFilters).To(Equal(configCopy.LabelFilters), "LabelFilters mismatch") Expect(capturedOpts.Limit).To(Equal(tt.ThenExpected.queryOpts.Limit), "Limit mismatch") } else { mockStorageInstance.AssertNotCalled(t, "Query", mock.Anything, mock.Anything) } // Optionally, assert all expectations are met // mockStorageInstance.AssertExpectations(t) // Uncomment if you want strict expectation matching } else if tt.ThenExpected.queryCalled { // Fail if query was expected but mock instance wasn't captured (indicates setup issue) t.Fatal("Expected query call but mock instance was not captured") } close(outCh) }) } }