Files
geo/server/internal/ops/app/object_storage_test.go
root 7d8e82c69f feat(ops): add object storage management and site-mapping CSV import/export
Ship the Ops backstage 对象存储 page (browse / upload / move / delete /
folder ops) backed by a new object-storage service + handler, with the
shared storage client extended with List/Stat/Copy/Usage/DownloadURL so
both MinIO and Aliyun providers cover the new surface. Add
ForcePathStyle to the object-storage config and treat r2/s3/custom_s3
as external providers so Cloudflare R2 and other S3-compatibles can
share the MinIO client path without spinning up the bundled MinIO.

Also add CSV import/export + template download to the site-domain
mappings page, raise gin MaxMultipartMemory to 8 MiB for the new
multipart endpoints, register Ops swagger routes, and extend the
descriptions-parity test to cover the ops router.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 01:19:01 +08:00

268 lines
7.4 KiB
Go

package app
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
"github.com/stretchr/testify/require"
)
func TestNormalizeOSSCommonPrefixesDerivesNextLevelFolders(t *testing.T) {
t.Parallel()
objects := []objectstorage.ObjectInfo{
{Key: "reports/2026/report.txt"},
{Key: "reports/2026/monthly/january.txt"},
{Key: "reports/2025/summary.txt"},
{Key: "reports/readme.txt"},
{Key: "images/logo.png"},
}
require.Equal(t,
[]string{"reports/2026/", "reports/2025/"},
normalizeOSSCommonPrefixes("reports/", nil, objects, false),
)
require.Equal(t,
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}},
normalizeOSSListedObjects("reports/", objects, false),
)
}
func TestNormalizeOSSListedObjectsFiltersFolderPlaceholders(t *testing.T) {
t.Parallel()
objects := []objectstorage.ObjectInfo{
{Key: "reports/"},
{Key: "reports/readme.txt"},
{Key: "reports/2026/"},
{Key: "reports/2026/report.txt"},
}
require.Equal(t,
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}},
normalizeOSSListedObjects("reports/", objects, false),
)
require.Equal(t,
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}, {Key: "reports/2026/report.txt"}},
normalizeOSSListedObjects("reports/", objects, true),
)
}
func TestNormalizeOSSCommonPrefixesKeepsProviderPrefixes(t *testing.T) {
t.Parallel()
got := normalizeOSSCommonPrefixes(
"reports/",
[]string{"reports/2026/", "reports/2025/"},
[]objectstorage.ObjectInfo{
{Key: "reports/2026/report.txt"},
{Key: "reports/2025/summary.txt"},
},
false,
)
require.Equal(t, []string{"reports/2026/", "reports/2025/"}, got)
}
func TestNormalizeOSSCommonPrefixesOmitsFoldersInRecursiveMode(t *testing.T) {
t.Parallel()
got := normalizeOSSCommonPrefixes(
"reports/",
[]string{"reports/2026/"},
[]objectstorage.ObjectInfo{{Key: "reports/2026/report.txt"}},
true,
)
require.Empty(t, got)
}
func TestShouldFallbackOSSDirectoryList(t *testing.T) {
t.Parallel()
require.True(t, shouldFallbackOSSDirectoryList(
ObjectStorageListInput{Prefix: "desktop-client/"},
"",
nil,
nil,
))
require.False(t, shouldFallbackOSSDirectoryList(
ObjectStorageListInput{Prefix: "desktop-client/", Recursive: true},
"",
nil,
nil,
))
require.False(t, shouldFallbackOSSDirectoryList(
ObjectStorageListInput{Prefix: "desktop-client/"},
"release",
nil,
nil,
))
require.False(t, shouldFallbackOSSDirectoryList(
ObjectStorageListInput{Prefix: "desktop-client/"},
"",
nil,
[]string{"desktop-client/releases/"},
))
}
func TestCreateFolderWritesPlaceholder(t *testing.T) {
t.Parallel()
storage := newFakeObjectStorage()
svc := &ObjectStorageService{storage: storage}
result, err := svc.CreateFolder(context.Background(), nil, ObjectStorageFolderCreateInput{Prefix: "reports/2026"})
require.NoError(t, err)
require.Equal(t, "reports/2026/", result.Prefix)
require.Contains(t, storage.objects, "reports/2026/")
require.Equal(t, []byte(nil), storage.objects["reports/2026/"])
require.Equal(t, "application/octet-stream", storage.contentTypes["reports/2026/"])
}
func TestCreateFolderRejectsExistingPrefix(t *testing.T) {
t.Parallel()
storage := newFakeObjectStorage()
storage.objects["reports/2026/report.txt"] = []byte("content")
svc := &ObjectStorageService{storage: storage}
_, err := svc.CreateFolder(context.Background(), nil, ObjectStorageFolderCreateInput{Prefix: "reports/2026/"})
require.Error(t, err)
require.Contains(t, err.Error(), "object_storage_folder_exists")
}
func TestDeleteFolderDeletesAllObjectsUnderPrefix(t *testing.T) {
t.Parallel()
storage := newFakeObjectStorage()
storage.objects["reports/2026/"] = nil
storage.objects["reports/2026/a.txt"] = []byte("a")
storage.objects["reports/2026/nested/b.txt"] = []byte("b")
storage.objects["reports/2025/a.txt"] = []byte("old")
svc := &ObjectStorageService{storage: storage}
result, err := svc.DeleteFolder(context.Background(), nil, "reports/2026/")
require.NoError(t, err)
require.Equal(t, "reports/2026/", result.Prefix)
require.Equal(t, 3, result.DeletedCount)
require.NotContains(t, storage.objects, "reports/2026/")
require.NotContains(t, storage.objects, "reports/2026/a.txt")
require.NotContains(t, storage.objects, "reports/2026/nested/b.txt")
require.Contains(t, storage.objects, "reports/2025/a.txt")
}
func TestGetReturnsPreviewURLAndOmitsNonPublicURL(t *testing.T) {
t.Parallel()
storage := newFakeObjectStorage()
storage.objects["reports/secret.png"] = []byte("image")
storage.publicReadable = false
svc := &ObjectStorageService{storage: storage}
result, err := svc.Get(context.Background(), "reports/secret.png")
require.NoError(t, err)
require.Nil(t, result.PublicURL)
require.NotNil(t, result.PreviewURL)
require.Equal(t, "https://example.test/download/reports/secret.png", *result.PreviewURL)
}
type fakeObjectStorage struct {
objects map[string][]byte
contentTypes map[string]string
publicReadable bool
}
func newFakeObjectStorage() *fakeObjectStorage {
return &fakeObjectStorage{
objects: make(map[string][]byte),
contentTypes: make(map[string]string),
publicReadable: true,
}
}
func (s *fakeObjectStorage) Validate() error {
return nil
}
func (s *fakeObjectStorage) PutBytes(_ context.Context, objectKey string, content []byte, contentType string) error {
s.objects[objectKey] = content
s.contentTypes[objectKey] = contentType
return nil
}
func (s *fakeObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
content, ok := s.objects[objectKey]
if !ok {
return nil, objectstorage.ErrObjectNotFound
}
return content, nil
}
func (s *fakeObjectStorage) Exists(_ context.Context, objectKey string) (bool, error) {
_, ok := s.objects[objectKey]
return ok, nil
}
func (s *fakeObjectStorage) Delete(_ context.Context, objectKey string) error {
delete(s.objects, objectKey)
return nil
}
func (s *fakeObjectStorage) PublicURL(objectKey string) (string, error) {
return "https://example.test/" + objectKey, nil
}
func (s *fakeObjectStorage) PublicReadableURL(objectKey string) (string, error) {
if !s.publicReadable {
return "", objectstorage.ErrObjectNotPublic
}
return "https://example.test/" + objectKey, nil
}
func (s *fakeObjectStorage) DownloadURL(objectKey string) (string, error) {
return "https://example.test/download/" + objectKey, nil
}
func (s *fakeObjectStorage) List(_ context.Context, in objectstorage.ListInput) (*objectstorage.ListResult, error) {
if in.Limit == 0 {
return nil, errors.New("test list limit must be set")
}
result := &objectstorage.ListResult{}
for key, content := range s.objects {
if strings.HasPrefix(key, in.Prefix) {
result.Objects = append(result.Objects, objectstorage.ObjectInfo{
Key: key,
Size: int64(len(content)),
LastModified: time.Now(),
})
}
}
return result, nil
}
func (s *fakeObjectStorage) Stat(_ context.Context, objectKey string) (*objectstorage.ObjectInfo, error) {
content, ok := s.objects[objectKey]
if !ok {
return nil, objectstorage.ErrObjectNotFound
}
return &objectstorage.ObjectInfo{Key: objectKey, Size: int64(len(content)), LastModified: time.Now()}, nil
}
func (s *fakeObjectStorage) Copy(_ context.Context, sourceKey, destinationKey string) error {
content, ok := s.objects[sourceKey]
if !ok {
return objectstorage.ErrObjectNotFound
}
s.objects[destinationKey] = append([]byte(nil), content...)
return nil
}