521 lines
14 KiB
Go
521 lines
14 KiB
Go
// 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 feed
|
|
|
|
// import (
|
|
// "context"
|
|
// "os"
|
|
// "testing"
|
|
// "time"
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
//
|
|
|
|
// "github.com/benbjohnson/clock"
|
|
// . "github.com/onsi/gomega"
|
|
// "github.com/stretchr/testify/mock"
|
|
|
|
// "github.com/glidea/zenfeed/pkg/config"
|
|
// "github.com/glidea/zenfeed/pkg/storage/feed/block"
|
|
// "github.com/glidea/zenfeed/pkg/storage/feed/block/chunk"
|
|
// "github.com/glidea/zenfeed/pkg/test"
|
|
// timeutil "github.com/glidea/zenfeed/pkg/util/time"
|
|
// )
|
|
|
|
// func TestNew(t *testing.T) {
|
|
// RegisterTestingT(t)
|
|
|
|
// type givenDetail struct {
|
|
// now time.Time
|
|
// blocksOnDisk []string // Block directory names in format "2006-01-02T15:04:05Z-2006-01-02T15:04:05Z"
|
|
// }
|
|
// type whenDetail struct {
|
|
// app *config.App
|
|
// }
|
|
// type thenExpected struct {
|
|
// storage storage
|
|
// storageHotLen int
|
|
// storageColdLen int
|
|
// blockCalls []func(obj *mock.Mock)
|
|
// }
|
|
// tests := []test.Case[givenDetail, whenDetail, thenExpected]{
|
|
// {
|
|
// Scenario: "Create a new storage from an empty directory",
|
|
// Given: "just mock a time",
|
|
// When: "call New with a config with a data directory",
|
|
// Then: "should return a new storage and a hot block created",
|
|
// GivenDetail: givenDetail{
|
|
// now: timeutil.MustParse("2025-03-03T10:00:00Z"),
|
|
// },
|
|
// WhenDetail: whenDetail{
|
|
// app: &config.App{
|
|
// DB: config.DB{
|
|
// Dir: "/tmp/TestNew",
|
|
// },
|
|
// },
|
|
// },
|
|
// ThenExpected: thenExpected{
|
|
// storage: storage{
|
|
// config: &Config{
|
|
// Dir: "/tmp/TestNew",
|
|
// },
|
|
// },
|
|
// storageHotLen: 1,
|
|
// storageColdLen: 0,
|
|
// },
|
|
// },
|
|
// {
|
|
// Scenario: "Create a storage from existing directory with blocks",
|
|
// Given: "existing blocks on disk",
|
|
// GivenDetail: givenDetail{
|
|
// now: timeutil.MustParse("2025-03-03T10:00:00Z"),
|
|
// blocksOnDisk: []string{
|
|
// "2025-03-02T10:00:00Z ~ 2025-03-03T10:00:00Z", // Hot block
|
|
// "2025-03-01T10:00:00Z ~ 2025-03-02T10:00:00Z", // Cold block
|
|
// "2025-02-28T10:00:00Z ~ 2025-03-01T10:00:00Z", // Cold block
|
|
// },
|
|
// },
|
|
// When: "call New with a config with existing data directory",
|
|
// WhenDetail: whenDetail{
|
|
// app: &config.App{
|
|
// DB: config.DB{
|
|
// Dir: "/tmp/TestNew",
|
|
// WriteableWindow: 49 * time.Hour,
|
|
// },
|
|
// },
|
|
// },
|
|
// Then: "should return a storage with existing blocks loaded",
|
|
// ThenExpected: thenExpected{
|
|
// storage: storage{
|
|
// config: &Config{
|
|
// Dir: "/tmp/TestNew",
|
|
// Block: BlockConfig{
|
|
// WriteableWindow: 49 * time.Hour,
|
|
// },
|
|
// },
|
|
// },
|
|
// storageHotLen: 1,
|
|
// storageColdLen: 2,
|
|
// blockCalls: []func(obj *mock.Mock){
|
|
// func(m *mock.Mock) {
|
|
// m.On("State").Return(block.StateHot).Once()
|
|
// },
|
|
// func(m *mock.Mock) {
|
|
// m.On("State").Return(block.StateCold).Once()
|
|
// },
|
|
// func(m *mock.Mock) {
|
|
// m.On("State").Return(block.StateCold).Once()
|
|
// },
|
|
// },
|
|
// },
|
|
// },
|
|
// }
|
|
|
|
// for _, tt := range tests {
|
|
// t.Run(tt.Scenario, func(t *testing.T) {
|
|
// // Given.
|
|
// c := clock.NewMock()
|
|
// c.Set(tt.GivenDetail.now)
|
|
// clk = c // Set global clock.
|
|
// defer func() { clk = clock.New() }()
|
|
|
|
// // Create test directories if needed
|
|
// if len(tt.GivenDetail.blocksOnDisk) > 0 {
|
|
// for _, blockDir := range tt.GivenDetail.blocksOnDisk {
|
|
// err := os.MkdirAll(tt.WhenDetail.app.DB.Dir+"/"+blockDir, 0755)
|
|
// Expect(err).To(BeNil())
|
|
// }
|
|
// }
|
|
|
|
// // When.
|
|
// var calls int
|
|
// var blockCalls []*mock.Mock
|
|
// blockFactory := block.NewFactory(func(obj *mock.Mock) {
|
|
// if calls < len(tt.ThenExpected.blockCalls) {
|
|
// tt.ThenExpected.blockCalls[calls](obj)
|
|
// calls++
|
|
// blockCalls = append(blockCalls, obj)
|
|
// }
|
|
// })
|
|
// s, err := new(tt.WhenDetail.app, blockFactory)
|
|
// defer os.RemoveAll(tt.WhenDetail.app.DB.Dir)
|
|
|
|
// // Then.
|
|
// Expect(err).To(BeNil())
|
|
// Expect(s).NotTo(BeNil())
|
|
// storage := s.(*storage)
|
|
// Expect(storage.config).To(Equal(tt.ThenExpected.storage.config))
|
|
// Expect(len(storage.hot.blocks)).To(Equal(tt.ThenExpected.storageHotLen))
|
|
// Expect(len(storage.cold.blocks)).To(Equal(tt.ThenExpected.storageColdLen))
|
|
// for _, call := range blockCalls {
|
|
// call.AssertExpectations(t)
|
|
// }
|
|
// })
|
|
// }
|
|
// }
|
|
|
|
// func TestAppend(t *testing.T) {
|
|
// RegisterTestingT(t)
|
|
|
|
// type givenDetail struct {
|
|
// hotBlocks []func(m *mock.Mock)
|
|
// coldBlocks []func(m *mock.Mock)
|
|
// }
|
|
// type whenDetail struct {
|
|
// feeds []*chunk.Feed
|
|
// }
|
|
// type thenExpected struct {
|
|
// err string
|
|
// }
|
|
|
|
// tests := []test.Case[givenDetail, whenDetail, thenExpected]{
|
|
// {
|
|
// Scenario: "Append feeds to hot block",
|
|
// Given: "a storage with one hot block",
|
|
// When: "append feeds within hot block time range",
|
|
// Then: "should append feeds to hot block successfully",
|
|
// GivenDetail: givenDetail{
|
|
// hotBlocks: []func(m *mock.Mock){
|
|
// func(m *mock.Mock) {
|
|
// m.On("Start").Return(timeutil.MustParse("2025-03-02T10:00:00Z")).Twice()
|
|
// m.On("End").Return(timeutil.MustParse("2025-03-03T10:00:00Z")).Twice()
|
|
// m.On("State").Return(block.StateHot).Twice()
|
|
// m.On("Append", mock.Anything, []*chunk.Feed{
|
|
// {ID: 1, Time: timeutil.MustParse("2025-03-02T11:00:00Z")},
|
|
// {ID: 2, Time: timeutil.MustParse("2025-03-02T12:00:00Z")},
|
|
// }).Return(nil)
|
|
// },
|
|
// },
|
|
// },
|
|
// WhenDetail: whenDetail{
|
|
// feeds: []*chunk.Feed{
|
|
// {ID: 1, Time: timeutil.MustParse("2025-03-02T11:00:00Z")},
|
|
// {ID: 2, Time: timeutil.MustParse("2025-03-02T12:00:00Z")},
|
|
// },
|
|
// },
|
|
// ThenExpected: thenExpected{
|
|
// err: "",
|
|
// },
|
|
// },
|
|
// {
|
|
// Scenario: "Append feeds to non-hot block",
|
|
// Given: "a storage with hot and cold blocks",
|
|
// When: "append feeds with time in cold block range",
|
|
// Then: "should return error",
|
|
// GivenDetail: givenDetail{
|
|
// coldBlocks: []func(m *mock.Mock){
|
|
// func(m *mock.Mock) {},
|
|
// },
|
|
// },
|
|
// WhenDetail: whenDetail{
|
|
// feeds: []*chunk.Feed{
|
|
// {ID: 1, Time: timeutil.MustParse("2025-03-01T11:00:00Z")},
|
|
// },
|
|
// },
|
|
// ThenExpected: thenExpected{
|
|
// err: "cannot find hot block",
|
|
// },
|
|
// },
|
|
// }
|
|
|
|
// for _, tt := range tests {
|
|
// t.Run(tt.Scenario, func(t *testing.T) {
|
|
// // Given.
|
|
// calls := 0
|
|
// var blockMocks []*mock.Mock
|
|
// blockFactory := block.NewFactory(func(obj *mock.Mock) {
|
|
// if calls < len(tt.GivenDetail.hotBlocks) {
|
|
// tt.GivenDetail.hotBlocks[calls](obj)
|
|
// calls++
|
|
// blockMocks = append(blockMocks, obj)
|
|
// }
|
|
// })
|
|
// var hotBlocks blockChain
|
|
// for range tt.GivenDetail.hotBlocks {
|
|
// block, err := blockFactory.New(nil, nil, nil, nil, nil)
|
|
// Expect(err).To(BeNil())
|
|
// hotBlocks.add(block)
|
|
// }
|
|
// blockFactory = block.NewFactory(func(obj *mock.Mock) {
|
|
// if calls < len(tt.GivenDetail.coldBlocks) {
|
|
// tt.GivenDetail.coldBlocks[calls](obj)
|
|
// calls++
|
|
// blockMocks = append(blockMocks, obj)
|
|
// }
|
|
// })
|
|
// var coldBlocks blockChain
|
|
// for range tt.GivenDetail.coldBlocks {
|
|
// block, err := blockFactory.New(nil, nil, nil, nil, nil)
|
|
// Expect(err).To(BeNil())
|
|
// coldBlocks.add(block)
|
|
// }
|
|
// s := storage{
|
|
// hot: &hotBlocks,
|
|
// cold: &coldBlocks,
|
|
// }
|
|
|
|
// // When.
|
|
// err := s.Append(context.Background(), tt.WhenDetail.feeds...)
|
|
|
|
// // Then.
|
|
// if tt.ThenExpected.err != "" {
|
|
// Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.err))
|
|
// } else {
|
|
// Expect(err).To(BeNil())
|
|
// }
|
|
// for _, m := range blockMocks {
|
|
// m.AssertExpectations(t)
|
|
// }
|
|
// })
|
|
// }
|
|
// }
|
|
|
|
// func TestQuery(t *testing.T) {
|
|
// RegisterTestingT(t)
|
|
|
|
// type givenDetail struct {
|
|
// hotBlocks []func(m *mock.Mock)
|
|
// coldBlocks []func(m *mock.Mock)
|
|
// }
|
|
// type whenDetail struct {
|
|
// query block.QueryOptions
|
|
// }
|
|
// type thenExpected struct {
|
|
// feeds []*block.FeedVO
|
|
// err string
|
|
// }
|
|
|
|
// tests := []test.Case[givenDetail, whenDetail, thenExpected]{
|
|
// {
|
|
// Scenario: "Query feeds from hot blocks",
|
|
// Given: "a storage with one hot block containing feeds",
|
|
// When: "querying with time range within hot block",
|
|
// Then: "should return matching feeds from hot block",
|
|
// GivenDetail: givenDetail{
|
|
// hotBlocks: []func(m *mock.Mock){
|
|
// func(m *mock.Mock) {
|
|
// m.On("Start").Return(timeutil.MustParse("2025-03-02T10:00:00Z")).Once()
|
|
// m.On("End").Return(timeutil.MustParse("2025-03-03T10:00:00Z")).Once()
|
|
// m.On("Query", mock.Anything, mock.MatchedBy(func(q block.QueryOptions) bool {
|
|
// return q.Start.Equal(timeutil.MustParse("2025-03-02T12:00:00Z")) &&
|
|
// q.End.Equal(timeutil.MustParse("2025-03-02T14:00:00Z"))
|
|
// })).Return([]*block.FeedVO{
|
|
// {ID: 1, Time: timeutil.MustParse("2025-03-02T12:30:00Z")},
|
|
// {ID: 2, Time: timeutil.MustParse("2025-03-02T13:00:00Z")},
|
|
// }, nil)
|
|
// },
|
|
// },
|
|
// },
|
|
// WhenDetail: whenDetail{
|
|
// query: block.QueryOptions{
|
|
// Start: timeutil.MustParse("2025-03-02T12:00:00Z"),
|
|
// End: timeutil.MustParse("2025-03-02T14:00:00Z"),
|
|
// Limit: 10,
|
|
// },
|
|
// },
|
|
// ThenExpected: thenExpected{
|
|
// feeds: []*block.FeedVO{
|
|
// {ID: 2, Time: timeutil.MustParse("2025-03-02T13:00:00Z")},
|
|
// {ID: 1, Time: timeutil.MustParse("2025-03-02T12:30:00Z")},
|
|
// },
|
|
// err: "",
|
|
// },
|
|
// },
|
|
// {
|
|
// Scenario: "Query feeds from multiple blocks",
|
|
// Given: "a storage with hot and cold blocks containing feeds",
|
|
// When: "querying with time range spanning multiple blocks",
|
|
// Then: "should return combined and sorted feeds from all matching blocks",
|
|
// GivenDetail: givenDetail{
|
|
// hotBlocks: []func(m *mock.Mock){
|
|
// func(m *mock.Mock) {
|
|
// m.On("Start").Return(timeutil.MustParse("2025-03-02T10:00:00Z"))
|
|
// m.On("End").Return(timeutil.MustParse("2025-03-03T10:00:00Z"))
|
|
// m.On("Query", mock.Anything, mock.MatchedBy(func(q block.QueryOptions) bool {
|
|
// return !q.Start.IsZero() && q.End.IsZero()
|
|
// })).Return([]*block.FeedVO{
|
|
// {ID: 3, Time: timeutil.MustParse("2025-03-02T15:00:00Z")},
|
|
// {ID: 4, Time: timeutil.MustParse("2025-03-02T16:00:00Z")},
|
|
// }, nil)
|
|
// },
|
|
// },
|
|
// coldBlocks: []func(m *mock.Mock){
|
|
// func(m *mock.Mock) {
|
|
// m.On("Start").Return(timeutil.MustParse("2025-03-01T10:00:00Z"))
|
|
// m.On("End").Return(timeutil.MustParse("2025-03-02T10:00:00Z"))
|
|
// m.On("Query", mock.Anything, mock.MatchedBy(func(q block.QueryOptions) bool {
|
|
// return !q.Start.IsZero() && q.End.IsZero()
|
|
// })).Return([]*block.FeedVO{
|
|
// {ID: 1, Time: timeutil.MustParse("2025-03-01T15:00:00Z")},
|
|
// {ID: 2, Time: timeutil.MustParse("2025-03-01T16:00:00Z")},
|
|
// }, nil)
|
|
// },
|
|
// },
|
|
// },
|
|
// WhenDetail: whenDetail{
|
|
// query: block.QueryOptions{
|
|
// Start: timeutil.MustParse("2025-03-01T12:00:00Z"),
|
|
// Limit: 3,
|
|
// },
|
|
// },
|
|
// ThenExpected: thenExpected{
|
|
// feeds: []*block.FeedVO{
|
|
// {ID: 4, Time: timeutil.MustParse("2025-03-02T16:00:00Z")},
|
|
// {ID: 3, Time: timeutil.MustParse("2025-03-02T15:00:00Z")},
|
|
// {ID: 2, Time: timeutil.MustParse("2025-03-01T16:00:00Z")},
|
|
// },
|
|
// err: "",
|
|
// },
|
|
// },
|
|
// }
|
|
|
|
// for _, tt := range tests {
|
|
// t.Run(tt.Scenario, func(t *testing.T) {
|
|
// // Given.
|
|
// calls := 0
|
|
// var blockMocks []*mock.Mock
|
|
// blockFactory := block.NewFactory(func(obj *mock.Mock) {
|
|
// if calls < len(tt.GivenDetail.hotBlocks) {
|
|
// tt.GivenDetail.hotBlocks[calls](obj)
|
|
// calls++
|
|
// blockMocks = append(blockMocks, obj)
|
|
// }
|
|
// })
|
|
// var hotBlocks blockChain
|
|
// for range tt.GivenDetail.hotBlocks {
|
|
// block, err := blockFactory.New(nil, nil, nil, nil, nil)
|
|
// Expect(err).To(BeNil())
|
|
// hotBlocks.add(block)
|
|
// }
|
|
|
|
// blockFactory = block.NewFactory(func(obj *mock.Mock) {
|
|
// if calls < len(tt.GivenDetail.hotBlocks)+len(tt.GivenDetail.coldBlocks) {
|
|
// tt.GivenDetail.coldBlocks[calls-len(tt.GivenDetail.hotBlocks)](obj)
|
|
// calls++
|
|
// blockMocks = append(blockMocks, obj)
|
|
// }
|
|
// })
|
|
// var coldBlocks blockChain
|
|
// for range tt.GivenDetail.coldBlocks {
|
|
// block, err := blockFactory.New(nil, nil, nil, nil, nil)
|
|
// Expect(err).To(BeNil())
|
|
// coldBlocks.add(block)
|
|
// }
|
|
|
|
// s := storage{
|
|
// hot: &hotBlocks,
|
|
// cold: &coldBlocks,
|
|
// }
|
|
|
|
// // When.
|
|
// feeds, err := s.Query(context.Background(), tt.WhenDetail.query)
|
|
|
|
// // Then.
|
|
// if tt.ThenExpected.err != "" {
|
|
// Expect(err).NotTo(BeNil())
|
|
// Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.err))
|
|
// } else {
|
|
// Expect(err).To(BeNil())
|
|
// Expect(feeds).To(HaveLen(len(tt.ThenExpected.feeds)))
|
|
|
|
// // Check feeds match expected
|
|
// for i, feed := range feeds {
|
|
// Expect(feed.ID).To(Equal(tt.ThenExpected.feeds[i].ID))
|
|
// Expect(feed.Time).To(Equal(tt.ThenExpected.feeds[i].Time))
|
|
// Expect(feed.Labels).To(Equal(tt.ThenExpected.feeds[i].Labels))
|
|
// }
|
|
// }
|
|
|
|
// for _, m := range blockMocks {
|
|
// m.AssertExpectations(t)
|
|
// }
|
|
// })
|
|
// }
|
|
// }
|