// 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 . package chunk import ( "context" "os" "path/filepath" "testing" "time" . "github.com/onsi/gomega" "github.com/pkg/errors" "github.com/glidea/zenfeed/pkg/model" "github.com/glidea/zenfeed/pkg/test" ) func TestNew(t *testing.T) { RegisterTestingT(t) type givenDetail struct { path string readonlyAtFirst bool setupFeeds []*Feed } type whenDetail struct{} type thenExpected struct { count uint32 err string } tests := []test.Case[givenDetail, whenDetail, thenExpected]{ { Scenario: "Create New Chunk File", Given: "A valid non-existing file path", When: "Creating a new chunk file", Then: "Should return a valid File instance with count 0", GivenDetail: givenDetail{ readonlyAtFirst: false, }, WhenDetail: whenDetail{}, ThenExpected: thenExpected{ count: 0, }, }, { Scenario: "Open Existing Chunk File", Given: "A valid existing chunk file with data", When: "Opening the file in readonly mode", Then: "Should return a valid File instance with correct count", GivenDetail: givenDetail{ readonlyAtFirst: true, setupFeeds: []*Feed{ createTestFeed(1), createTestFeed(2), createTestFeed(3), }, }, WhenDetail: whenDetail{}, ThenExpected: thenExpected{ count: 3, }, }, { Scenario: "Invalid Configuration", Given: "An invalid configuration with empty path", When: "Creating a new chunk file", Then: "Should return an error", GivenDetail: givenDetail{ path: "", // Empty path }, WhenDetail: whenDetail{}, ThenExpected: thenExpected{ err: "validate config: path is required", }, }, } for _, tt := range tests { t.Run(tt.Scenario, func(t *testing.T) { // Given. if tt.GivenDetail.path == "" && tt.ThenExpected.err == "" { tt.GivenDetail.path = createTempFile(t) defer cleanupTempFile(tt.GivenDetail.path) } if len(tt.GivenDetail.setupFeeds) > 0 { initialFile, err := new("test", &Config{ Path: tt.GivenDetail.path, ReadonlyAtFirst: false, }, Dependencies{}) Expect(err).NotTo(HaveOccurred()) err = initialFile.Append(context.Background(), tt.GivenDetail.setupFeeds, nil) Expect(err).NotTo(HaveOccurred()) initialFile.Close() } // When. file, err := new("test", &Config{ Path: tt.GivenDetail.path, ReadonlyAtFirst: tt.GivenDetail.readonlyAtFirst, }, Dependencies{}) // Then. if tt.ThenExpected.err != "" { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.err)) } else { Expect(err).NotTo(HaveOccurred()) Expect(file).NotTo(BeNil()) Expect(file.Count(context.Background())).To(Equal(tt.ThenExpected.count)) file.Close() } }) } } func TestFileModeSwitching(t *testing.T) { RegisterTestingT(t) tests := []struct { scenario string given string when string then string initialMode bool // true for readonly expectedError string }{ { scenario: "ReadWrite to ReadOnly Switch", given: "a read-write mode chunk file", when: "calling EnsureReadonly()", then: "file should switch to read-only mode", initialMode: false, expectedError: "", }, { scenario: "Already ReadOnly", given: "a read-only mode chunk file", when: "calling EnsureReadonly()", then: "operation should return quickly", initialMode: true, expectedError: "", }, } for _, tt := range tests { t.Run(tt.scenario, func(t *testing.T) { // Setup path := createTempFile(t) defer cleanupTempFile(path) // Create initial file initialConfig := Config{ Path: path, ReadonlyAtFirst: false, } initialFile, err := new("test", &initialConfig, Dependencies{}) Expect(err).NotTo(HaveOccurred()) initialFile.Close() // Open file with specified mode config := Config{ Path: path, ReadonlyAtFirst: tt.initialMode, } f, err := new("test", &config, Dependencies{}) Expect(err).NotTo(HaveOccurred()) defer f.Close() // Execute err = f.EnsureReadonly(context.Background()) // Verify if tt.expectedError != "" { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring(tt.expectedError)) } else { Expect(err).NotTo(HaveOccurred()) // Verify it's now in readonly mode by attempting an append appendErr := f.Append(context.Background(), []*Feed{createTestFeed(1)}, nil) Expect(appendErr).To(HaveOccurred()) Expect(appendErr.Error()).To(ContainSubstring("file is readonly")) } }) } } func TestAppend(t *testing.T) { RegisterTestingT(t) type givenDetail struct { readonly bool } type whenDetail struct { appendFeeds []*Feed } type thenExpected struct { count uint32 err string } tests := []test.Case[givenDetail, whenDetail, thenExpected]{ { Scenario: "Append Single Feed", Given: "A read-write mode chunk file", When: "Adding a single feed", Then: "Should successfully write the feed", GivenDetail: givenDetail{ readonly: false, }, WhenDetail: whenDetail{ appendFeeds: []*Feed{createTestFeed(1)}, }, ThenExpected: thenExpected{ count: 1, }, }, { Scenario: "Batch Append Multiple Feeds", Given: "A read-write mode chunk file", When: "Adding multiple feeds at once", Then: "Should write all feeds as a single transaction", GivenDetail: givenDetail{ readonly: false, }, WhenDetail: whenDetail{ appendFeeds: []*Feed{ createTestFeed(1), createTestFeed(2), createTestFeed(3), }, }, ThenExpected: thenExpected{ count: 3, }, }, { Scenario: "Append in ReadOnly Mode", Given: "A read-only mode chunk file", When: "Attempting to add a feed", Then: "Should fail with readonly error", GivenDetail: givenDetail{ readonly: true, }, WhenDetail: whenDetail{ appendFeeds: []*Feed{createTestFeed(1)}, }, ThenExpected: thenExpected{ err: "file is readonly", }, }, } for _, tt := range tests { t.Run(tt.Scenario, func(t *testing.T) { // Given. path := createTempFile(t) defer cleanupTempFile(path) if tt.GivenDetail.readonly { // Create and close initial file for readonly test. rwFile, err := new("test", &Config{Path: path}, Dependencies{}) Expect(err).NotTo(HaveOccurred()) rwFile.Close() } f, err := new("test", &Config{ Path: path, ReadonlyAtFirst: tt.GivenDetail.readonly, }, Dependencies{}) Expect(err).NotTo(HaveOccurred()) defer f.Close() // When. var offsets []uint64 err = f.Append(context.Background(), tt.WhenDetail.appendFeeds, func(_ *Feed, offset uint64) error { offsets = append(offsets, offset) return nil }) // Then. if tt.ThenExpected.err != "" { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.err)) } else { Expect(err).NotTo(HaveOccurred()) Expect(f.Count(context.Background())).To(Equal(tt.ThenExpected.count)) // Verify each feed can be read back. for i, offset := range offsets { feed, readErr := f.Read(context.Background(), offset) Expect(readErr).NotTo(HaveOccurred()) Expect(feed.ID).To(Equal(tt.WhenDetail.appendFeeds[i].ID)) } } }) } } func TestRead(t *testing.T) { RegisterTestingT(t) tests := []struct { scenario string given string when string then string readonly bool setupFeeds []*Feed readOffset uint64 expectedErr string }{ { scenario: "Read from Valid Offset", given: "a chunk file with feeds", when: "reading with a valid offset", then: "should return the correct feed", readonly: false, setupFeeds: []*Feed{createTestFeed(1)}, readOffset: uint64(dataStart), // Will be adjusted in the test expectedErr: "", }, { scenario: "Read from ReadOnly Mode", given: "a read-only chunk file with feeds", when: "reading with a valid offset", then: "should return the correct feed using mmap", readonly: true, setupFeeds: []*Feed{createTestFeed(2)}, readOffset: uint64(dataStart), // Will be adjusted in the test expectedErr: "", }, { scenario: "Read with Small Offset", given: "a chunk file with feeds", when: "reading with an offset smaller than dataStart", then: "should return 'offset too small' error", readonly: false, setupFeeds: []*Feed{createTestFeed(3)}, readOffset: uint64(dataStart - 1), expectedErr: "offset too small", }, { scenario: "Read with Large Offset", given: "a chunk file with feeds", when: "reading with an offset larger than appendOffset", then: "should return 'offset too large' error", readonly: false, setupFeeds: []*Feed{createTestFeed(4)}, readOffset: 999999, // Definitely beyond appendOffset expectedErr: "offset too large", }, } for _, tt := range tests { t.Run(tt.scenario, func(t *testing.T) { // Setup path := createTempFile(t) defer cleanupTempFile(path) // Create and populate initial file initialConfig := Config{ Path: path, ReadonlyAtFirst: false, } initialFile, err := new("test", &initialConfig, Dependencies{}) Expect(err).NotTo(HaveOccurred()) var validOffset uint64 if len(tt.setupFeeds) > 0 { // Track the first offset for later reading var firstOffset uint64 err = initialFile.Append(context.Background(), tt.setupFeeds, func(_ *Feed, offset uint64) error { if firstOffset == 0 { firstOffset = offset } return nil }) Expect(err).NotTo(HaveOccurred()) validOffset = firstOffset } initialFile.Close() // Reopen with specified mode config := Config{ Path: path, ReadonlyAtFirst: tt.readonly, } f, err := new("test", &config, Dependencies{}) Expect(err).NotTo(HaveOccurred()) defer f.Close() // Use valid offset if needed readOffset := tt.readOffset if readOffset == uint64(dataStart) && validOffset > 0 { readOffset = validOffset } // Execute feed, err := f.Read(context.Background(), readOffset) // Verify if tt.expectedErr != "" { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring(tt.expectedErr)) } else { Expect(err).NotTo(HaveOccurred()) Expect(feed).NotTo(BeNil()) Expect(feed.ID).To(Equal(tt.setupFeeds[0].ID)) } }) } } func TestRange(t *testing.T) { RegisterTestingT(t) tests := []struct { scenario string given string when string then string readonly bool setupFeeds []*Feed earlyExit bool expectedCount int expectedErr string }{ { scenario: "Range All Feeds", given: "a chunk file with multiple feeds", when: "calling Range()", then: "iterator should visit each feed in sequence", readonly: false, setupFeeds: []*Feed{ createTestFeed(1), createTestFeed(2), createTestFeed(3), }, earlyExit: false, expectedCount: 3, expectedErr: "", }, { scenario: "Range with Early Exit", given: "a chunk file with multiple feeds", when: "calling Range() and returning an error from iterator", then: "range should stop and return that error", readonly: false, setupFeeds: []*Feed{ createTestFeed(4), createTestFeed(5), createTestFeed(6), }, earlyExit: true, expectedCount: 1, // Should stop after first feed expectedErr: "early exit", }, { scenario: "Range in ReadOnly Mode", given: "a read-only chunk file with feeds", when: "calling Range()", then: "should use mmap and correctly visit all feeds", readonly: true, setupFeeds: []*Feed{ createTestFeed(7), createTestFeed(8), }, earlyExit: false, expectedCount: 2, expectedErr: "", }, } for _, tt := range tests { t.Run(tt.scenario, func(t *testing.T) { // Setup path := createTempFile(t) defer cleanupTempFile(path) // Create and populate initial file initialConfig := Config{ Path: path, ReadonlyAtFirst: false, } initialFile, err := new("test", &initialConfig, Dependencies{}) Expect(err).NotTo(HaveOccurred()) if len(tt.setupFeeds) > 0 { err = initialFile.Append(context.Background(), tt.setupFeeds, nil) Expect(err).NotTo(HaveOccurred()) } initialFile.Close() // Reopen with specified mode config := Config{ Path: path, ReadonlyAtFirst: tt.readonly, } f, err := new("test", &config, Dependencies{}) Expect(err).NotTo(HaveOccurred()) defer f.Close() // Execute visitCount := 0 err = f.Range(context.Background(), func(feed *Feed, offset uint64) (err error) { visitCount++ if tt.earlyExit && visitCount == 1 { return errors.New("early exit") } return nil }) // Verify if tt.expectedErr != "" { Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring(tt.expectedErr)) } else { Expect(err).NotTo(HaveOccurred()) } Expect(visitCount).To(Equal(tt.expectedCount)) }) } } func createTempFile(t *testing.T) string { dir, err := os.MkdirTemp("", "chunk-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } return filepath.Join(dir, "test.chunk") } func cleanupTempFile(path string) { os.RemoveAll(filepath.Dir(path)) } func createTestFeed(id uint64) *Feed { return &Feed{ Feed: &model.Feed{ ID: id, Labels: model.Labels{model.Label{Key: "test", Value: "value"}}, Time: time.Now(), }, Vectors: [][]float32{ {1.0, 2.0, 3.0}, {4.0, 5.0, 6.0}, }, } }