This change adds the API endpoints, the CLI commands and the web UI elements needed to manage objects in GARMs internal storage. This storage system is meant to be used to distribute the garm-agent and as a single source of truth for provider binaries, when we will add the ability for GARM to scale out. Potentially, we can also use this in air gapped systems to distribute the runner binaries for forges that don't have their own internal storage system (like GHES). Signed-off-by: Gabriel Adrian Samfira <gsamfira@cloudbasesolutions.com>
820 lines
27 KiB
Go
820 lines
27 KiB
Go
// Copyright 2025 Cloudbase Solutions SRL
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
// not use this file except in compliance with the License. You may obtain
|
|
// a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
// License for the specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package sql
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/suite"
|
|
|
|
runnerErrors "github.com/cloudbase/garm-provider-common/errors"
|
|
dbCommon "github.com/cloudbase/garm/database/common"
|
|
"github.com/cloudbase/garm/database/watcher"
|
|
garmTesting "github.com/cloudbase/garm/internal/testing"
|
|
"github.com/cloudbase/garm/params"
|
|
)
|
|
|
|
type FileStoreTestFixtures struct {
|
|
FileObjects []params.FileObject
|
|
}
|
|
|
|
type FileStoreTestSuite struct {
|
|
suite.Suite
|
|
Store dbCommon.Store
|
|
ctx context.Context
|
|
adminCtx context.Context
|
|
Fixtures *FileStoreTestFixtures
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TearDownTest() {
|
|
watcher.CloseWatcher()
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) SetupTest() {
|
|
ctx := context.Background()
|
|
watcher.InitWatcher(ctx)
|
|
|
|
db, err := NewSQLDatabase(context.Background(), garmTesting.GetTestSqliteDBConfig(s.T()))
|
|
if err != nil {
|
|
s.FailNow(fmt.Sprintf("failed to create db connection: %s", err))
|
|
}
|
|
s.Store = db
|
|
|
|
adminCtx := garmTesting.ImpersonateAdminContext(context.Background(), db, s.T())
|
|
s.adminCtx = adminCtx
|
|
s.ctx = adminCtx
|
|
|
|
// Create test file objects
|
|
fileObjects := []params.FileObject{}
|
|
|
|
// File 1: Small text file with tags
|
|
content1 := []byte("Hello, World! This is test file 1.")
|
|
param := params.CreateFileObjectParams{
|
|
Name: "test-file-1.txt",
|
|
Size: int64(len(content1)),
|
|
Tags: []string{"test", "text"},
|
|
}
|
|
fileObj1, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content1))
|
|
if err != nil {
|
|
s.FailNow(fmt.Sprintf("failed to create test file 1: %s", err))
|
|
}
|
|
fileObjects = append(fileObjects, fileObj1)
|
|
|
|
// File 2: Binary-like content with different tags
|
|
content2 := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00} // PNG header-like
|
|
param = params.CreateFileObjectParams{
|
|
Name: "test-image.png",
|
|
Size: int64(len(content2)),
|
|
Tags: []string{"image", "binary"},
|
|
}
|
|
fileObj2, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content2))
|
|
if err != nil {
|
|
s.FailNow(fmt.Sprintf("failed to create test file 2: %s", err))
|
|
}
|
|
fileObjects = append(fileObjects, fileObj2)
|
|
|
|
// File 3: No tags
|
|
content3 := []byte("File without tags.")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "no-tags.txt",
|
|
Size: int64(len(content3)),
|
|
Tags: []string{},
|
|
}
|
|
fileObj3, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content3))
|
|
if err != nil {
|
|
s.FailNow(fmt.Sprintf("failed to create test file 3: %s", err))
|
|
}
|
|
fileObjects = append(fileObjects, fileObj3)
|
|
|
|
s.Fixtures = &FileStoreTestFixtures{
|
|
FileObjects: fileObjects,
|
|
}
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestCreateFileObject() {
|
|
content := []byte("New test file content")
|
|
tags := []string{"new", "test"}
|
|
|
|
param := params.CreateFileObjectParams{
|
|
Name: "new-file.txt",
|
|
Size: int64(len(content)),
|
|
Tags: tags,
|
|
}
|
|
fileObj, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content))
|
|
s.Require().Nil(err)
|
|
s.Require().NotZero(fileObj.ID)
|
|
s.Require().Equal("new-file.txt", fileObj.Name)
|
|
s.Require().Equal(int64(len(content)), fileObj.Size)
|
|
s.Require().ElementsMatch(tags, fileObj.Tags)
|
|
s.Require().NotEmpty(fileObj.SHA256)
|
|
s.Require().NotEmpty(fileObj.FileType)
|
|
|
|
// Verify SHA256 is correct
|
|
expectedHash := sha256.Sum256(content)
|
|
expectedHashStr := hex.EncodeToString(expectedHash[:])
|
|
s.Require().Equal(expectedHashStr, fileObj.SHA256)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestCreateFileObjectEmpty() {
|
|
content := []byte{}
|
|
param := params.CreateFileObjectParams{
|
|
Name: "empty-file.txt",
|
|
Size: 0,
|
|
Tags: []string{},
|
|
}
|
|
fileObj, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content))
|
|
s.Require().Nil(err)
|
|
s.Require().NotZero(fileObj.ID)
|
|
s.Require().Equal("empty-file.txt", fileObj.Name)
|
|
s.Require().Equal(int64(0), fileObj.Size)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestGetFileObject() {
|
|
fileObj, err := s.Store.GetFileObject(s.ctx, s.Fixtures.FileObjects[0].ID)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(s.Fixtures.FileObjects[0].ID, fileObj.ID)
|
|
s.Require().Equal(s.Fixtures.FileObjects[0].Name, fileObj.Name)
|
|
s.Require().Equal(s.Fixtures.FileObjects[0].Size, fileObj.Size)
|
|
s.Require().Equal(s.Fixtures.FileObjects[0].SHA256, fileObj.SHA256)
|
|
s.Require().ElementsMatch(s.Fixtures.FileObjects[0].Tags, fileObj.Tags)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestGetFileObjectNotFound() {
|
|
_, err := s.Store.GetFileObject(s.ctx, 99999)
|
|
s.Require().NotNil(err)
|
|
s.Require().ErrorIs(err, runnerErrors.ErrNotFound)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestOpenFileObjectContent() {
|
|
// Create a file with known content
|
|
content := []byte("Test content for reading")
|
|
param := params.CreateFileObjectParams{
|
|
Name: "read-test.txt",
|
|
Size: int64(len(content)),
|
|
Tags: []string{"read"},
|
|
}
|
|
fileObj, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content))
|
|
s.Require().Nil(err)
|
|
|
|
// Open and read the content
|
|
reader, err := s.Store.OpenFileObjectContent(s.ctx, fileObj.ID)
|
|
s.Require().Nil(err)
|
|
s.Require().NotNil(reader)
|
|
defer reader.Close()
|
|
|
|
readContent, err := io.ReadAll(reader)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(content, readContent)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestOpenFileObjectContentNotFound() {
|
|
_, err := s.Store.OpenFileObjectContent(s.ctx, 99999)
|
|
s.Require().NotNil(err)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestListFileObjects() {
|
|
result, err := s.Store.ListFileObjects(s.ctx, 1, 10)
|
|
s.Require().Nil(err)
|
|
s.Require().GreaterOrEqual(len(result.Results), len(s.Fixtures.FileObjects))
|
|
s.Require().Equal(uint64(1), result.CurrentPage)
|
|
s.Require().GreaterOrEqual(result.Pages, uint64(1))
|
|
s.Require().GreaterOrEqual(result.TotalCount, uint64(len(s.Fixtures.FileObjects)))
|
|
|
|
// First page should not have previous page
|
|
s.Require().Nil(result.PreviousPage)
|
|
|
|
// If there are more pages, next page should be set
|
|
if result.Pages > 1 {
|
|
s.Require().NotNil(result.NextPage)
|
|
s.Require().Equal(uint64(2), *result.NextPage)
|
|
}
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestListFileObjectsPagination() {
|
|
// Create more files to test pagination
|
|
for i := 0; i < 5; i++ {
|
|
content := []byte(fmt.Sprintf("File %d", i))
|
|
param := params.CreateFileObjectParams{
|
|
Name: fmt.Sprintf("page-test-%d.txt", i),
|
|
Size: int64(len(content)),
|
|
Tags: []string{"pagination"},
|
|
}
|
|
_, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content))
|
|
s.Require().Nil(err)
|
|
}
|
|
|
|
// Test first page with page size of 2
|
|
page1, err := s.Store.ListFileObjects(s.ctx, 1, 2)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(2, len(page1.Results))
|
|
s.Require().Equal(uint64(1), page1.CurrentPage)
|
|
s.Require().GreaterOrEqual(page1.TotalCount, uint64(5))
|
|
s.Require().Nil(page1.PreviousPage, "First page should not have previous page")
|
|
s.Require().NotNil(page1.NextPage, "First page should have next page")
|
|
s.Require().Equal(uint64(2), *page1.NextPage)
|
|
|
|
// Test second page
|
|
page2, err := s.Store.ListFileObjects(s.ctx, 2, 2)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(2, len(page2.Results))
|
|
s.Require().Equal(uint64(2), page2.CurrentPage)
|
|
s.Require().Equal(page1.Pages, page2.Pages)
|
|
s.Require().Equal(page1.TotalCount, page2.TotalCount)
|
|
s.Require().NotNil(page2.PreviousPage, "Second page should have previous page")
|
|
s.Require().Equal(uint64(1), *page2.PreviousPage)
|
|
s.Require().NotNil(page2.NextPage, "Second page should have next page")
|
|
s.Require().Equal(uint64(3), *page2.NextPage)
|
|
|
|
// Verify different results on different pages
|
|
if len(page1.Results) > 0 && len(page2.Results) > 0 {
|
|
page1File := page1.Results[0]
|
|
page2File := page2.Results[0]
|
|
s.Require().NotEqual(page1File.ID, page2File.ID)
|
|
}
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestListFileObjectsDefaultPagination() {
|
|
// Test default values (page 0 should become 1, pageSize 0 should become 20)
|
|
result, err := s.Store.ListFileObjects(s.ctx, 0, 0)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(uint64(1), result.CurrentPage)
|
|
s.Require().LessOrEqual(len(result.Results), 20)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestUpdateFileObjectName() {
|
|
newName := "updated-name.txt"
|
|
updated, err := s.Store.UpdateFileObject(s.ctx, s.Fixtures.FileObjects[0].ID, params.UpdateFileObjectParams{
|
|
Name: &newName,
|
|
})
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(newName, updated.Name)
|
|
s.Require().Equal(s.Fixtures.FileObjects[0].ID, updated.ID)
|
|
s.Require().Equal(s.Fixtures.FileObjects[0].Size, updated.Size) // Size should not change
|
|
s.Require().Equal(s.Fixtures.FileObjects[0].SHA256, updated.SHA256) // SHA256 should not change
|
|
|
|
// Verify the change persists
|
|
retrieved, err := s.Store.GetFileObject(s.ctx, s.Fixtures.FileObjects[0].ID)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(newName, retrieved.Name)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestUpdateFileObjectTags() {
|
|
newTags := []string{"updated", "tags", "here"}
|
|
updated, err := s.Store.UpdateFileObject(s.ctx, s.Fixtures.FileObjects[0].ID, params.UpdateFileObjectParams{
|
|
Tags: newTags,
|
|
})
|
|
s.Require().Nil(err)
|
|
s.Require().ElementsMatch(newTags, updated.Tags)
|
|
s.Require().Equal(s.Fixtures.FileObjects[0].Name, updated.Name) // Name should not change
|
|
|
|
// Verify the change persists
|
|
retrieved, err := s.Store.GetFileObject(s.ctx, s.Fixtures.FileObjects[0].ID)
|
|
s.Require().Nil(err)
|
|
s.Require().ElementsMatch(newTags, retrieved.Tags)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestUpdateFileObjectNameAndTags() {
|
|
newName := "completely-updated.txt"
|
|
newTags := []string{"both", "changed"}
|
|
|
|
updated, err := s.Store.UpdateFileObject(s.ctx, s.Fixtures.FileObjects[0].ID, params.UpdateFileObjectParams{
|
|
Name: &newName,
|
|
Tags: newTags,
|
|
})
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(newName, updated.Name)
|
|
s.Require().ElementsMatch(newTags, updated.Tags)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestUpdateFileObjectEmptyTags() {
|
|
// Test clearing all tags
|
|
emptyTags := []string{}
|
|
updated, err := s.Store.UpdateFileObject(s.ctx, s.Fixtures.FileObjects[0].ID, params.UpdateFileObjectParams{
|
|
Tags: emptyTags,
|
|
})
|
|
s.Require().Nil(err)
|
|
s.Require().Empty(updated.Tags)
|
|
|
|
// Verify the change persists
|
|
retrieved, err := s.Store.GetFileObject(s.ctx, s.Fixtures.FileObjects[0].ID)
|
|
s.Require().Nil(err)
|
|
s.Require().Empty(retrieved.Tags)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestUpdateFileObjectNoChanges() {
|
|
// Update with no changes
|
|
updated, err := s.Store.UpdateFileObject(s.ctx, s.Fixtures.FileObjects[0].ID, params.UpdateFileObjectParams{})
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(s.Fixtures.FileObjects[0].Name, updated.Name)
|
|
s.Require().ElementsMatch(s.Fixtures.FileObjects[0].Tags, updated.Tags)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestUpdateFileObjectNotFound() {
|
|
newName := "does-not-exist.txt"
|
|
_, err := s.Store.UpdateFileObject(s.ctx, 99999, params.UpdateFileObjectParams{
|
|
Name: &newName,
|
|
})
|
|
s.Require().NotNil(err)
|
|
s.Require().ErrorIs(err, runnerErrors.ErrNotFound)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestUpdateFileObjectEmptyName() {
|
|
emptyName := ""
|
|
_, err := s.Store.UpdateFileObject(s.ctx, s.Fixtures.FileObjects[0].ID, params.UpdateFileObjectParams{
|
|
Name: &emptyName,
|
|
})
|
|
s.Require().NotNil(err)
|
|
s.Require().Contains(err.Error(), "name cannot be empty")
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestDeleteFileObject() {
|
|
// Create a file to delete
|
|
content := []byte("To be deleted")
|
|
param := params.CreateFileObjectParams{
|
|
Name: "delete-me.txt",
|
|
Size: int64(len(content)),
|
|
Tags: []string{"delete"},
|
|
}
|
|
fileObj, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content))
|
|
s.Require().Nil(err)
|
|
|
|
// Delete the file
|
|
err = s.Store.DeleteFileObject(s.ctx, fileObj.ID)
|
|
s.Require().Nil(err)
|
|
|
|
// Verify it's deleted
|
|
_, err = s.Store.GetFileObject(s.ctx, fileObj.ID)
|
|
s.Require().NotNil(err)
|
|
s.Require().ErrorIs(err, runnerErrors.ErrNotFound)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestDeleteFileObjectNotFound() {
|
|
// Deleting non-existent file should not error
|
|
err := s.Store.DeleteFileObject(s.ctx, 99999)
|
|
s.Require().Nil(err)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestCreateFileObjectLargeContent() {
|
|
// Test with larger content (1MB)
|
|
size := 1024 * 1024
|
|
content := make([]byte, size)
|
|
for i := range content {
|
|
content[i] = byte(i % 256)
|
|
}
|
|
param := params.CreateFileObjectParams{
|
|
Name: "large-file.bin",
|
|
Size: int64(size),
|
|
Tags: []string{"large", "binary"},
|
|
}
|
|
fileObj, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content))
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(int64(size), fileObj.Size)
|
|
|
|
// Verify we can read it back
|
|
reader, err := s.Store.OpenFileObjectContent(s.ctx, fileObj.ID)
|
|
s.Require().Nil(err)
|
|
defer reader.Close()
|
|
|
|
readContent, err := io.ReadAll(reader)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(content, readContent)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestFileObjectImmutableFields() {
|
|
// Create a file
|
|
content := []byte("Immutable test content")
|
|
param := params.CreateFileObjectParams{
|
|
Name: "immutable-test.txt",
|
|
Size: int64(len(content)),
|
|
Tags: []string{"original"},
|
|
}
|
|
fileObj, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content))
|
|
s.Require().Nil(err)
|
|
|
|
originalSize := fileObj.Size
|
|
originalSHA256 := fileObj.SHA256
|
|
originalFileType := fileObj.FileType
|
|
|
|
// Update name and tags
|
|
newName := "updated-immutable-test.txt"
|
|
updated, err := s.Store.UpdateFileObject(s.ctx, fileObj.ID, params.UpdateFileObjectParams{
|
|
Name: &newName,
|
|
Tags: []string{"updated"},
|
|
})
|
|
s.Require().Nil(err)
|
|
|
|
// Verify immutable fields haven't changed
|
|
s.Require().Equal(originalSize, updated.Size)
|
|
s.Require().Equal(originalSHA256, updated.SHA256)
|
|
s.Require().Equal(originalFileType, updated.FileType)
|
|
|
|
// Verify content hasn't changed
|
|
reader, err := s.Store.OpenFileObjectContent(s.ctx, fileObj.ID)
|
|
s.Require().Nil(err)
|
|
defer reader.Close()
|
|
|
|
readContent, err := io.ReadAll(reader)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(content, readContent)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestSearchFileObjectByTags() {
|
|
// Create files with specific tags for searching
|
|
content1 := []byte("File with tag1 and tag2")
|
|
param := params.CreateFileObjectParams{
|
|
Name: "search-file-1.txt",
|
|
Size: int64(len(content1)),
|
|
Tags: []string{"tag1", "tag2"},
|
|
}
|
|
file1, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content1))
|
|
s.Require().Nil(err)
|
|
|
|
content2 := []byte("File with tag1, tag2, and tag3")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "search-file-2.txt",
|
|
Size: int64(len(content2)),
|
|
Tags: []string{"tag1", "tag2", "tag3"},
|
|
}
|
|
file2, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content2))
|
|
s.Require().Nil(err)
|
|
|
|
content3 := []byte("File with only tag1")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "search-file-3.txt",
|
|
Size: int64(len(content3)),
|
|
Tags: []string{"tag1"},
|
|
}
|
|
file3, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content3))
|
|
s.Require().Nil(err)
|
|
|
|
content4 := []byte("File with tag3 only")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "search-file-4.txt",
|
|
Size: int64(len(content4)),
|
|
Tags: []string{"tag3"},
|
|
}
|
|
_, err = s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content4))
|
|
s.Require().Nil(err)
|
|
|
|
// Search for files with tag1 - should return 3 files
|
|
result, err := s.Store.SearchFileObjectByTags(s.ctx, []string{"tag1"}, 1, 10)
|
|
s.Require().Nil(err)
|
|
s.Require().GreaterOrEqual(len(result.Results), 3)
|
|
|
|
// Verify the expected files are in the results
|
|
foundIDs := make(map[uint]bool)
|
|
for _, fileObj := range result.Results {
|
|
foundIDs[fileObj.ID] = true
|
|
}
|
|
s.Require().True(foundIDs[file1.ID], "file1 should be in results")
|
|
s.Require().True(foundIDs[file2.ID], "file2 should be in results")
|
|
s.Require().True(foundIDs[file3.ID], "file3 should be in results")
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestSearchFileObjectByTagsMultipleTags() {
|
|
// Create files with various tag combinations
|
|
content1 := []byte("File with search1 and search2")
|
|
param := params.CreateFileObjectParams{
|
|
Name: "multi-search-1.txt",
|
|
Size: int64(len(content1)),
|
|
Tags: []string{"search1", "search2"},
|
|
}
|
|
file1, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content1))
|
|
s.Require().Nil(err)
|
|
|
|
content2 := []byte("File with search1, search2, and search3")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "multi-search-2.txt",
|
|
Size: int64(len(content2)),
|
|
Tags: []string{"search1", "search2", "search3"},
|
|
}
|
|
file2, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content2))
|
|
s.Require().Nil(err)
|
|
|
|
content3 := []byte("File with only search1")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "multi-search-3.txt",
|
|
Size: int64(len(content3)),
|
|
Tags: []string{"search1"},
|
|
}
|
|
_, err = s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content3))
|
|
s.Require().Nil(err)
|
|
|
|
// Search for files with both search1 AND search2 - should return only 2 files
|
|
result, err := s.Store.SearchFileObjectByTags(s.ctx, []string{"search1", "search2"}, 1, 10)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(2, len(result.Results))
|
|
|
|
// Verify the correct files are returned
|
|
foundIDs := make(map[uint]bool)
|
|
for _, fileObj := range result.Results {
|
|
foundIDs[fileObj.ID] = true
|
|
}
|
|
s.Require().True(foundIDs[file1.ID], "file1 should be in results")
|
|
s.Require().True(foundIDs[file2.ID], "file2 should be in results")
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestSearchFileObjectByTagsNoResults() {
|
|
// Search for a tag that doesn't exist
|
|
result, err := s.Store.SearchFileObjectByTags(s.ctx, []string{"nonexistent-tag"}, 1, 10)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(0, len(result.Results))
|
|
s.Require().Equal(uint64(0), result.Pages)
|
|
s.Require().Equal(uint64(1), result.CurrentPage)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestSearchFileObjectByTagsEmptyTags() {
|
|
// Search with empty tag list - should return all files
|
|
result, err := s.Store.SearchFileObjectByTags(s.ctx, []string{}, 1, 100)
|
|
s.Require().Nil(err)
|
|
// Should return all files (fixtures + any created in other tests)
|
|
s.Require().GreaterOrEqual(len(result.Results), len(s.Fixtures.FileObjects))
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestSearchFileObjectByTagsPagination() {
|
|
// Create multiple files with the same tag
|
|
for i := 0; i < 5; i++ {
|
|
content := []byte(fmt.Sprintf("Pagination test file %d", i))
|
|
param := params.CreateFileObjectParams{
|
|
Name: fmt.Sprintf("page-search-%d.txt", i),
|
|
Size: int64(len(content)),
|
|
Tags: []string{"pagination-test"},
|
|
}
|
|
_, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content))
|
|
s.Require().Nil(err)
|
|
}
|
|
|
|
// Test first page with page size of 2
|
|
page1, err := s.Store.SearchFileObjectByTags(s.ctx, []string{"pagination-test"}, 1, 2)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(2, len(page1.Results))
|
|
s.Require().Equal(uint64(1), page1.CurrentPage)
|
|
s.Require().GreaterOrEqual(page1.Pages, uint64(3)) // At least 3 pages for 5 files
|
|
|
|
// Test second page
|
|
page2, err := s.Store.SearchFileObjectByTags(s.ctx, []string{"pagination-test"}, 2, 2)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(2, len(page2.Results))
|
|
s.Require().Equal(uint64(2), page2.CurrentPage)
|
|
|
|
// Verify different results on different pages
|
|
if len(page1.Results) > 0 && len(page2.Results) > 0 {
|
|
page1File := page1.Results[0]
|
|
page2File := page2.Results[0]
|
|
s.Require().NotEqual(page1File.ID, page2File.ID)
|
|
}
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestSearchFileObjectByTagsDefaultPagination() {
|
|
// Create a file with a unique tag
|
|
content := []byte("Default pagination test")
|
|
param := params.CreateFileObjectParams{
|
|
Name: "default-page-search.txt",
|
|
Size: int64(len(content)),
|
|
Tags: []string{"default-pagination"},
|
|
}
|
|
_, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content))
|
|
s.Require().Nil(err)
|
|
|
|
// Test default values (page 0 should become 1, pageSize 0 should become 20)
|
|
result, err := s.Store.SearchFileObjectByTags(s.ctx, []string{"default-pagination"}, 0, 0)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(uint64(1), result.CurrentPage)
|
|
s.Require().LessOrEqual(len(result.Results), 20)
|
|
s.Require().GreaterOrEqual(len(result.Results), 1)
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestSearchFileObjectByTagsAllTagsRequired() {
|
|
// Test that search requires ALL specified tags (AND logic, not OR)
|
|
content1 := []byte("Has A and B")
|
|
param := params.CreateFileObjectParams{
|
|
Name: "and-test-1.txt",
|
|
Size: int64(len(content1)),
|
|
Tags: []string{"tagA", "tagB"},
|
|
}
|
|
file1, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content1))
|
|
s.Require().Nil(err)
|
|
|
|
content2 := []byte("Has A, B, and C")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "and-test-2.txt",
|
|
Size: int64(len(content2)),
|
|
Tags: []string{"tagA", "tagB", "tagC"},
|
|
}
|
|
file2, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content2))
|
|
s.Require().Nil(err)
|
|
|
|
content3 := []byte("Has only A")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "and-test-3.txt",
|
|
Size: int64(len(content3)),
|
|
Tags: []string{"tagA"},
|
|
}
|
|
_, err = s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content3))
|
|
s.Require().Nil(err)
|
|
|
|
content4 := []byte("Has only B")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "and-test-4.txt",
|
|
Size: int64(len(content4)),
|
|
Tags: []string{"tagB"},
|
|
}
|
|
_, err = s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content4))
|
|
s.Require().Nil(err)
|
|
|
|
// Search for files with BOTH tagA AND tagB
|
|
result, err := s.Store.SearchFileObjectByTags(s.ctx, []string{"tagA", "tagB"}, 1, 10)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(2, len(result.Results), "Should only return files with BOTH tags")
|
|
|
|
// Verify the correct files are returned
|
|
foundIDs := make(map[uint]bool)
|
|
for _, fileObj := range result.Results {
|
|
foundIDs[fileObj.ID] = true
|
|
// Verify each result has both tags
|
|
s.Require().Contains(fileObj.Tags, "tagA")
|
|
s.Require().Contains(fileObj.Tags, "tagB")
|
|
}
|
|
s.Require().True(foundIDs[file1.ID])
|
|
s.Require().True(foundIDs[file2.ID])
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestSearchFileObjectByTagsCaseInsensitive() {
|
|
// Test case insensitivity of tag search (COLLATE NOCASE)
|
|
content1 := []byte("File with lowercase tag")
|
|
param := params.CreateFileObjectParams{
|
|
Name: "case-test-1.txt",
|
|
Size: int64(len(content1)),
|
|
Tags: []string{"TestTag"},
|
|
}
|
|
file1, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content1))
|
|
s.Require().Nil(err)
|
|
|
|
content2 := []byte("File with UPPERCASE tag")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "case-test-2.txt",
|
|
Size: int64(len(content2)),
|
|
Tags: []string{"TESTTAG"},
|
|
}
|
|
file2, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content2))
|
|
s.Require().Nil(err)
|
|
|
|
content3 := []byte("File with MixedCase tag")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "case-test-3.txt",
|
|
Size: int64(len(content3)),
|
|
Tags: []string{"testTAG"},
|
|
}
|
|
file3, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content3))
|
|
s.Require().Nil(err)
|
|
|
|
// Search for lowercase - should return all files (case insensitive)
|
|
result, err := s.Store.SearchFileObjectByTags(s.ctx, []string{"testtag"}, 1, 10)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(3, len(result.Results), "Should match all case variations")
|
|
|
|
foundIDs := make(map[uint]bool)
|
|
for _, fileObj := range result.Results {
|
|
foundIDs[fileObj.ID] = true
|
|
}
|
|
s.Require().True(foundIDs[file1.ID], "Should find file with 'TestTag'")
|
|
s.Require().True(foundIDs[file2.ID], "Should find file with 'TESTTAG'")
|
|
s.Require().True(foundIDs[file3.ID], "Should find file with 'testTAG'")
|
|
|
|
// Search for UPPERCASE - should also return all files
|
|
result, err = s.Store.SearchFileObjectByTags(s.ctx, []string{"TESTTAG"}, 1, 10)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(3, len(result.Results), "Should match all case variations")
|
|
|
|
// Search for MixedCase - should also return all files
|
|
result, err = s.Store.SearchFileObjectByTags(s.ctx, []string{"TeStTaG"}, 1, 10)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(3, len(result.Results), "Should match all case variations")
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestSearchFileObjectByTagsOrderByCreatedAt() {
|
|
// Create files with same tag at different times to test ordering
|
|
tag := "order-test"
|
|
|
|
content1 := []byte("First file")
|
|
param := params.CreateFileObjectParams{
|
|
Name: "order-1.txt",
|
|
Size: int64(len(content1)),
|
|
Tags: []string{tag},
|
|
}
|
|
file1, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content1))
|
|
s.Require().Nil(err)
|
|
|
|
content2 := []byte("Second file")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "order-2.txt",
|
|
Size: int64(len(content2)),
|
|
Tags: []string{tag},
|
|
}
|
|
file2, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content2))
|
|
s.Require().Nil(err)
|
|
|
|
content3 := []byte("Third file")
|
|
param = params.CreateFileObjectParams{
|
|
Name: "order-3.txt",
|
|
Size: int64(len(content3)),
|
|
Tags: []string{tag},
|
|
}
|
|
file3, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content3))
|
|
s.Require().Nil(err)
|
|
|
|
// Search and verify order (should be DESC by created_at, so newest first)
|
|
result, err := s.Store.SearchFileObjectByTags(s.ctx, []string{tag}, 1, 10)
|
|
s.Require().Nil(err)
|
|
s.Require().GreaterOrEqual(len(result.Results), 3)
|
|
|
|
// The most recently created files should be first
|
|
// We can at least verify that file3 comes before file1 in the results
|
|
var file1Idx, file3Idx int
|
|
for i, fileObj := range result.Results {
|
|
if fileObj.ID == file1.ID {
|
|
file1Idx = i
|
|
}
|
|
if fileObj.ID == file3.ID {
|
|
file3Idx = i
|
|
}
|
|
}
|
|
s.Require().Less(file3Idx, file1Idx, "Newer file (file3) should appear before older file (file1)")
|
|
|
|
// Also verify file2 comes before file1
|
|
var file2Idx int
|
|
for i, fileObj := range result.Results {
|
|
if fileObj.ID == file2.ID {
|
|
file2Idx = i
|
|
}
|
|
}
|
|
s.Require().Less(file2Idx, file1Idx, "Newer file (file2) should appear before older file (file1)")
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestPaginationFieldsLastPage() {
|
|
// Create exactly 5 files
|
|
for i := 0; i < 5; i++ {
|
|
content := []byte(fmt.Sprintf("Last page test %d", i))
|
|
param := params.CreateFileObjectParams{
|
|
Name: fmt.Sprintf("last-page-test-%d.txt", i),
|
|
Size: int64(len(content)),
|
|
Tags: []string{"last-page"},
|
|
}
|
|
_, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content))
|
|
s.Require().Nil(err)
|
|
}
|
|
|
|
// Get the last page (should have 1 item with pageSize=2)
|
|
// Total: 5 items, pageSize: 2, so pages: 3 (2, 2, 1)
|
|
lastPage, err := s.Store.SearchFileObjectByTags(s.ctx, []string{"last-page"}, 3, 2)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(uint64(3), lastPage.CurrentPage)
|
|
s.Require().Equal(uint64(3), lastPage.Pages)
|
|
s.Require().Equal(uint64(5), lastPage.TotalCount)
|
|
s.Require().Equal(1, len(lastPage.Results), "Last page should have 1 item")
|
|
s.Require().NotNil(lastPage.PreviousPage, "Last page should have previous page")
|
|
s.Require().Equal(uint64(2), *lastPage.PreviousPage)
|
|
s.Require().Nil(lastPage.NextPage, "Last page should not have next page")
|
|
}
|
|
|
|
func (s *FileStoreTestSuite) TestPaginationFieldsSinglePage() {
|
|
// Test when all results fit in a single page
|
|
content := []byte("Single page test")
|
|
param := params.CreateFileObjectParams{
|
|
Name: "single-page-test.txt",
|
|
Size: int64(len(content)),
|
|
Tags: []string{"single-page-unique-tag"},
|
|
}
|
|
_, err := s.Store.CreateFileObject(s.ctx, param, bytes.NewReader(content))
|
|
s.Require().Nil(err)
|
|
|
|
result, err := s.Store.SearchFileObjectByTags(s.ctx, []string{"single-page-unique-tag"}, 1, 20)
|
|
s.Require().Nil(err)
|
|
s.Require().Equal(uint64(1), result.TotalCount)
|
|
s.Require().Equal(uint64(1), result.Pages)
|
|
s.Require().Equal(uint64(1), result.CurrentPage)
|
|
s.Require().Nil(result.PreviousPage, "Single page should not have previous")
|
|
s.Require().Nil(result.NextPage, "Single page should not have next")
|
|
}
|
|
|
|
func TestFileStoreTestSuite(t *testing.T) {
|
|
suite.Run(t, new(FileStoreTestSuite))
|
|
}
|