feat(service): add upload, download, file, and folder services

Add UploadService (dedup, chunk lifecycle, ComposeObject), DownloadService (Range support), FileService (ref counting), FolderService (path validation).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
dailz
2026-04-15 09:23:09 +08:00
parent a114821615
commit f0847d3978
8 changed files with 2511 additions and 0 deletions

View File

@@ -0,0 +1,260 @@
package service
import (
"bytes"
"context"
"io"
"testing"
"time"
"gcy_hpc_server/internal/model"
"gcy_hpc_server/internal/storage"
"gcy_hpc_server/internal/store"
"go.uber.org/zap"
gormlogger "gorm.io/gorm/logger"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
type mockDownloadStorage struct {
getObjectFn func(ctx context.Context, bucket, key string, opts storage.GetOptions) (io.ReadCloser, storage.ObjectInfo, error)
}
func (m *mockDownloadStorage) PutObject(_ context.Context, _ string, _ string, _ io.Reader, _ int64, _ storage.PutObjectOptions) (storage.UploadInfo, error) {
return storage.UploadInfo{}, nil
}
func (m *mockDownloadStorage) GetObject(ctx context.Context, bucket, key string, opts storage.GetOptions) (io.ReadCloser, storage.ObjectInfo, error) {
if m.getObjectFn != nil {
return m.getObjectFn(ctx, bucket, key, opts)
}
return nil, storage.ObjectInfo{}, nil
}
func (m *mockDownloadStorage) ComposeObject(_ context.Context, _ string, _ string, _ []string) (storage.UploadInfo, error) {
return storage.UploadInfo{}, nil
}
func (m *mockDownloadStorage) AbortMultipartUpload(_ context.Context, _ string, _ string, _ string) error {
return nil
}
func (m *mockDownloadStorage) RemoveIncompleteUpload(_ context.Context, _ string, _ string) error {
return nil
}
func (m *mockDownloadStorage) RemoveObject(_ context.Context, _ string, _ string, _ storage.RemoveObjectOptions) error {
return nil
}
func (m *mockDownloadStorage) ListObjects(_ context.Context, _ string, _ string, _ bool) ([]storage.ObjectInfo, error) {
return nil, nil
}
func (m *mockDownloadStorage) RemoveObjects(_ context.Context, _ string, _ []string, _ storage.RemoveObjectsOptions) error {
return nil
}
func (m *mockDownloadStorage) BucketExists(_ context.Context, _ string) (bool, error) {
return true, nil
}
func (m *mockDownloadStorage) MakeBucket(_ context.Context, _ string, _ storage.MakeBucketOptions) error {
return nil
}
func (m *mockDownloadStorage) StatObject(_ context.Context, _ string, _ string, _ storage.StatObjectOptions) (storage.ObjectInfo, error) {
return storage.ObjectInfo{}, nil
}
func setupDownloadService(t *testing.T) (*DownloadService, *store.FileStore, *store.BlobStore, *mockDownloadStorage) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: gormlogger.Default.LogMode(gormlogger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.File{}, &model.FileBlob{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
mockStorage := &mockDownloadStorage{}
blobStore := store.NewBlobStore(db)
fileStore := store.NewFileStore(db)
svc := NewDownloadService(mockStorage, blobStore, fileStore, "test-bucket", zap.NewNop())
return svc, fileStore, blobStore, mockStorage
}
func createTestFileAndBlob(t *testing.T, fileStore *store.FileStore, blobStore *store.BlobStore) (*model.File, *model.FileBlob) {
t.Helper()
blob := &model.FileBlob{
SHA256: "abc123def456abc123def456abc123def456abc123def456abc123def456abcd",
MinioKey: "chunks/session1/part-0",
FileSize: 5000,
MimeType: "application/octet-stream",
RefCount: 1,
}
if err := blobStore.Create(context.Background(), blob); err != nil {
t.Fatalf("create blob: %v", err)
}
file := &model.File{
Name: "test.dat",
BlobSHA256: blob.SHA256,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := fileStore.Create(context.Background(), file); err != nil {
t.Fatalf("create file: %v", err)
}
return file, blob
}
func TestDownload_FullFile(t *testing.T) {
svc, fileStore, blobStore, mockStorage := setupDownloadService(t)
file, blob := createTestFileAndBlob(t, fileStore, blobStore)
content := make([]byte, blob.FileSize)
for i := range content {
content[i] = byte(i % 256)
}
mockStorage.getObjectFn = func(_ context.Context, _, _ string, opts storage.GetOptions) (io.ReadCloser, storage.ObjectInfo, error) {
if opts.Start == nil || opts.End == nil {
t.Fatal("expected Start and End to be set")
}
if *opts.Start != 0 {
t.Fatalf("expected start=0, got %d", *opts.Start)
}
if *opts.End != blob.FileSize-1 {
t.Fatalf("expected end=%d, got %d", blob.FileSize-1, *opts.End)
}
return io.NopCloser(bytes.NewReader(content)), storage.ObjectInfo{Size: blob.FileSize}, nil
}
reader, gotFile, gotBlob, start, end, err := svc.Download(context.Background(), file.ID, "")
if err != nil {
t.Fatalf("Download: %v", err)
}
defer reader.Close()
if gotFile.ID != file.ID {
t.Fatalf("expected file ID %d, got %d", file.ID, gotFile.ID)
}
if gotBlob.SHA256 != blob.SHA256 {
t.Fatalf("expected blob SHA256 %s, got %s", blob.SHA256, gotBlob.SHA256)
}
if start != 0 {
t.Fatalf("expected start=0, got %d", start)
}
if end != blob.FileSize-1 {
t.Fatalf("expected end=%d, got %d", blob.FileSize-1, end)
}
read, _ := io.ReadAll(reader)
if int64(len(read)) != blob.FileSize {
t.Fatalf("expected %d bytes, got %d", blob.FileSize, len(read))
}
}
func TestDownload_WithRange(t *testing.T) {
svc, fileStore, blobStore, mockStorage := setupDownloadService(t)
file, _ := createTestFileAndBlob(t, fileStore, blobStore)
content := make([]byte, 1024)
for i := range content {
content[i] = byte(i % 256)
}
mockStorage.getObjectFn = func(_ context.Context, _, _ string, opts storage.GetOptions) (io.ReadCloser, storage.ObjectInfo, error) {
if opts.Start == nil || opts.End == nil {
t.Fatal("expected Start and End to be set")
}
if *opts.Start != 0 {
t.Fatalf("expected start=0, got %d", *opts.Start)
}
if *opts.End != 1023 {
t.Fatalf("expected end=1023, got %d", *opts.End)
}
return io.NopCloser(bytes.NewReader(content[:1024])), storage.ObjectInfo{Size: 1024}, nil
}
reader, _, _, start, end, err := svc.Download(context.Background(), file.ID, "bytes=0-1023")
if err != nil {
t.Fatalf("Download: %v", err)
}
defer reader.Close()
if start != 0 {
t.Fatalf("expected start=0, got %d", start)
}
if end != 1023 {
t.Fatalf("expected end=1023, got %d", end)
}
read, _ := io.ReadAll(reader)
if len(read) != 1024 {
t.Fatalf("expected 1024 bytes, got %d", len(read))
}
}
func TestDownload_FileNotFound(t *testing.T) {
svc, _, _, _ := setupDownloadService(t)
_, _, _, _, _, err := svc.Download(context.Background(), 99999, "")
if err == nil {
t.Fatal("expected error for missing file")
}
if err.Error() != "file not found" {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDownload_BlobNotFound(t *testing.T) {
svc, fileStore, _, _ := setupDownloadService(t)
file := &model.File{
Name: "orphan.dat",
BlobSHA256: "nonexistent_hash_0000000000000000000000000000000000000000",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := fileStore.Create(context.Background(), file); err != nil {
t.Fatalf("create file: %v", err)
}
_, _, _, _, _, err := svc.Download(context.Background(), file.ID, "")
if err == nil {
t.Fatal("expected error for missing blob")
}
if err.Error() != "blob not found" {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGetFileMetadata(t *testing.T) {
svc, fileStore, blobStore, _ := setupDownloadService(t)
file, _ := createTestFileAndBlob(t, fileStore, blobStore)
gotFile, gotBlob, err := svc.GetFileMetadata(context.Background(), file.ID)
if err != nil {
t.Fatalf("GetFileMetadata: %v", err)
}
if gotFile.ID != file.ID {
t.Fatalf("expected file ID %d, got %d", file.ID, gotFile.ID)
}
if gotBlob.FileSize != 5000 {
t.Fatalf("expected file size 5000, got %d", gotBlob.FileSize)
}
if gotBlob.MimeType != "application/octet-stream" {
t.Fatalf("expected mime type application/octet-stream, got %s", gotBlob.MimeType)
}
}