feat(handler): add upload, file, and folder handlers with routes

Add UploadHandler (5 endpoints), FileHandler (4 endpoints), FolderHandler (4 endpoints) with Gin route registration in server.go.

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:17 +08:00
parent f0847d3978
commit 2298e92516
7 changed files with 1376 additions and 1 deletions

View File

@@ -0,0 +1,138 @@
package handler
import (
"context"
"io"
"strconv"
"gcy_hpc_server/internal/model"
"gcy_hpc_server/internal/server"
"gcy_hpc_server/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type fileServiceProvider interface {
ListFiles(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error)
GetFileMetadata(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error)
DownloadFile(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error)
DeleteFile(ctx context.Context, fileID int64) error
}
type FileHandler struct {
svc fileServiceProvider
logger *zap.Logger
}
func NewFileHandler(svc *service.FileService, logger *zap.Logger) *FileHandler {
return &FileHandler{svc: svc, logger: logger}
}
func (h *FileHandler) ListFiles(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 10
}
var folderID *int64
if v := c.Query("folder_id"); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
h.logger.Warn("invalid folder_id", zap.String("folder_id", v))
server.BadRequest(c, "invalid folder_id")
return
}
folderID = &id
}
search := c.Query("search")
files, total, err := h.svc.ListFiles(c.Request.Context(), folderID, page, pageSize, search)
if err != nil {
h.logger.Error("failed to list files", zap.Error(err))
server.InternalError(c, err.Error())
return
}
server.OK(c, model.ListFilesResponse{
Files: files,
Total: total,
Page: page,
PageSize: pageSize,
})
}
func (h *FileHandler) GetFile(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
h.logger.Warn("invalid file id", zap.String("id", c.Param("id")))
server.BadRequest(c, "invalid id")
return
}
file, blob, err := h.svc.GetFileMetadata(c.Request.Context(), id)
if err != nil {
h.logger.Error("failed to get file", zap.Int64("id", id), zap.Error(err))
server.InternalError(c, err.Error())
return
}
resp := model.FileResponse{
ID: file.ID,
Name: file.Name,
FolderID: file.FolderID,
Size: blob.FileSize,
MimeType: blob.MimeType,
SHA256: file.BlobSHA256,
CreatedAt: file.CreatedAt,
UpdatedAt: file.UpdatedAt,
}
server.OK(c, resp)
}
func (h *FileHandler) DownloadFile(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
h.logger.Warn("invalid file id for download", zap.String("id", c.Param("id")))
server.BadRequest(c, "invalid id")
return
}
rangeHeader := c.GetHeader("Range")
reader, file, blob, start, end, err := h.svc.DownloadFile(c.Request.Context(), id, rangeHeader)
if err != nil {
h.logger.Error("failed to download file", zap.Int64("id", id), zap.Error(err))
server.InternalError(c, err.Error())
return
}
if rangeHeader != "" {
server.StreamRange(c, reader, start, end, blob.FileSize, blob.MimeType)
} else {
server.StreamFile(c, reader, file.Name, blob.FileSize, blob.MimeType)
}
}
func (h *FileHandler) DeleteFile(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
h.logger.Warn("invalid file id for delete", zap.String("id", c.Param("id")))
server.BadRequest(c, "invalid id")
return
}
if err := h.svc.DeleteFile(c.Request.Context(), id); err != nil {
h.logger.Error("failed to delete file", zap.Int64("id", id), zap.Error(err))
server.InternalError(c, err.Error())
return
}
h.logger.Info("file deleted", zap.Int64("id", id))
server.OK(c, gin.H{"message": "file deleted"})
}

View File

@@ -0,0 +1,369 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gcy_hpc_server/internal/model"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type mockFileService struct {
listFilesFn func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error)
getFileMetadataFn func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error)
downloadFileFn func(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error)
deleteFileFn func(ctx context.Context, fileID int64) error
}
func (m *mockFileService) ListFiles(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
return m.listFilesFn(ctx, folderID, page, pageSize, search)
}
func (m *mockFileService) GetFileMetadata(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) {
return m.getFileMetadataFn(ctx, fileID)
}
func (m *mockFileService) DownloadFile(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) {
return m.downloadFileFn(ctx, fileID, rangeHeader)
}
func (m *mockFileService) DeleteFile(ctx context.Context, fileID int64) error {
return m.deleteFileFn(ctx, fileID)
}
type fileHandlerSetup struct {
handler *FileHandler
mock *mockFileService
router *gin.Engine
}
func newFileHandlerSetup() *fileHandlerSetup {
gin.SetMode(gin.TestMode)
mock := &mockFileService{}
h := &FileHandler{
svc: mock,
logger: zap.NewNop(),
}
r := gin.New()
v1 := r.Group("/api/v1")
files := v1.Group("/files")
files.GET("", h.ListFiles)
files.GET("/:id", h.GetFile)
files.GET("/:id/download", h.DownloadFile)
files.DELETE("/:id", h.DeleteFile)
return &fileHandlerSetup{handler: h, mock: mock, router: r}
}
// ---- ListFiles Tests ----
func TestListFiles_Empty(t *testing.T) {
s := newFileHandlerSetup()
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
return []model.FileResponse{}, 0, nil
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp["success"].(bool) {
t.Fatal("expected success=true")
}
data := resp["data"].(map[string]interface{})
files := data["files"].([]interface{})
if len(files) != 0 {
t.Fatalf("expected empty files list, got %d", len(files))
}
if data["total"].(float64) != 0 {
t.Fatalf("expected total=0, got %v", data["total"])
}
}
func TestListFiles_WithFiles(t *testing.T) {
s := newFileHandlerSetup()
now := time.Now()
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
return []model.FileResponse{
{ID: 1, Name: "a.txt", Size: 100, MimeType: "text/plain", SHA256: "abc123", CreatedAt: now, UpdatedAt: now},
{ID: 2, Name: "b.pdf", Size: 200, MimeType: "application/pdf", SHA256: "def456", CreatedAt: now, UpdatedAt: now},
}, 2, nil
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files?page=1&page_size=10", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
data := resp["data"].(map[string]interface{})
files := data["files"].([]interface{})
if len(files) != 2 {
t.Fatalf("expected 2 files, got %d", len(files))
}
if data["total"].(float64) != 2 {
t.Fatalf("expected total=2, got %v", data["total"])
}
if data["page"].(float64) != 1 {
t.Fatalf("expected page=1, got %v", data["page"])
}
if data["page_size"].(float64) != 10 {
t.Fatalf("expected page_size=10, got %v", data["page_size"])
}
}
func TestListFiles_WithFolderID(t *testing.T) {
s := newFileHandlerSetup()
var capturedFolderID *int64
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
capturedFolderID = folderID
return []model.FileResponse{}, 0, nil
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files?folder_id=5", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if capturedFolderID == nil || *capturedFolderID != 5 {
t.Fatalf("expected folder_id=5, got %v", capturedFolderID)
}
}
func TestListFiles_ServiceError(t *testing.T) {
s := newFileHandlerSetup()
s.mock.listFilesFn = func(ctx context.Context, folderID *int64, page, pageSize int, search string) ([]model.FileResponse, int64, error) {
return nil, 0, fmt.Errorf("db error")
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", w.Code)
}
}
// ---- GetFile Tests ----
func TestGetFile_Found(t *testing.T) {
s := newFileHandlerSetup()
now := time.Now()
s.mock.getFileMetadataFn = func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) {
return &model.File{
ID: 1, Name: "test.txt", BlobSHA256: "abc123", CreatedAt: now, UpdatedAt: now,
}, &model.FileBlob{
ID: 1, SHA256: "abc123", FileSize: 1024, MimeType: "text/plain", CreatedAt: now,
}, nil
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/1", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp["success"].(bool) {
t.Fatal("expected success=true")
}
}
func TestGetFile_NotFound(t *testing.T) {
s := newFileHandlerSetup()
s.mock.getFileMetadataFn = func(ctx context.Context, fileID int64) (*model.File, *model.FileBlob, error) {
return nil, nil, fmt.Errorf("file not found: 999")
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/999", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetFile_InvalidID(t *testing.T) {
s := newFileHandlerSetup()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/abc", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
// ---- DownloadFile Tests ----
func TestDownloadFile_Full(t *testing.T) {
s := newFileHandlerSetup()
content := "hello world file content"
now := time.Now()
s.mock.downloadFileFn = func(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) {
reader := io.NopCloser(strings.NewReader(content))
return reader,
&model.File{ID: 1, Name: "test.txt", CreatedAt: now},
&model.FileBlob{FileSize: int64(len(content)), MimeType: "text/plain"},
0, int64(len(content)) - 1, nil
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/1/download", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Check streaming headers
if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, "test.txt") {
t.Fatalf("expected Content-Disposition to contain 'test.txt', got %s", got)
}
if got := w.Header().Get("Content-Type"); got != "text/plain" {
t.Fatalf("expected Content-Type=text/plain, got %s", got)
}
if got := w.Header().Get("Accept-Ranges"); got != "bytes" {
t.Fatalf("expected Accept-Ranges=bytes, got %s", got)
}
if w.Body.String() != content {
t.Fatalf("expected body %q, got %q", content, w.Body.String())
}
}
func TestDownloadFile_WithRange(t *testing.T) {
s := newFileHandlerSetup()
content := "hello world"
now := time.Now()
s.mock.downloadFileFn = func(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) {
reader := io.NopCloser(strings.NewReader(content[0:5]))
return reader,
&model.File{ID: 1, Name: "test.txt", CreatedAt: now},
&model.FileBlob{FileSize: int64(len(content)), MimeType: "text/plain"},
0, 4, nil
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/1/download", nil)
req.Header.Set("Range", "bytes=0-4")
s.router.ServeHTTP(w, req)
if w.Code != http.StatusPartialContent {
t.Fatalf("expected 206, got %d: %s", w.Code, w.Body.String())
}
// Check Content-Range header
if got := w.Header().Get("Content-Range"); got != "bytes 0-4/11" {
t.Fatalf("expected Content-Range 'bytes 0-4/11', got %s", got)
}
if got := w.Header().Get("Content-Type"); got != "text/plain" {
t.Fatalf("expected Content-Type=text/plain, got %s", got)
}
if got := w.Header().Get("Content-Length"); got != "5" {
t.Fatalf("expected Content-Length=5, got %s", got)
}
}
func TestDownloadFile_InvalidID(t *testing.T) {
s := newFileHandlerSetup()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/abc/download", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestDownloadFile_ServiceError(t *testing.T) {
s := newFileHandlerSetup()
s.mock.downloadFileFn = func(ctx context.Context, fileID int64, rangeHeader string) (io.ReadCloser, *model.File, *model.FileBlob, int64, int64, error) {
return nil, nil, nil, 0, 0, fmt.Errorf("file not found: 999")
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/999/download", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
// ---- DeleteFile Tests ----
func TestDeleteFile_Success(t *testing.T) {
s := newFileHandlerSetup()
s.mock.deleteFileFn = func(ctx context.Context, fileID int64) error {
return nil
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/1", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp["success"].(bool) {
t.Fatal("expected success=true")
}
data := resp["data"].(map[string]interface{})
if data["message"] != "file deleted" {
t.Fatalf("expected message 'file deleted', got %v", data["message"])
}
}
func TestDeleteFile_InvalidID(t *testing.T) {
s := newFileHandlerSetup()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/abc", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestDeleteFile_ServiceError(t *testing.T) {
s := newFileHandlerSetup()
s.mock.deleteFileFn = func(ctx context.Context, fileID int64) error {
return fmt.Errorf("file not found: 999")
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/999", nil)
s.router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}

View File

@@ -0,0 +1,133 @@
package handler
import (
"context"
"strconv"
"strings"
"gcy_hpc_server/internal/model"
"gcy_hpc_server/internal/server"
"gcy_hpc_server/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type folderServiceProvider interface {
CreateFolder(ctx context.Context, name string, parentID *int64) (*model.FolderResponse, error)
GetFolder(ctx context.Context, id int64) (*model.FolderResponse, error)
ListFolders(ctx context.Context, parentID *int64) ([]model.FolderResponse, error)
DeleteFolder(ctx context.Context, id int64) error
}
type FolderHandler struct {
svc folderServiceProvider
logger *zap.Logger
}
func NewFolderHandler(svc *service.FolderService, logger *zap.Logger) *FolderHandler {
return &FolderHandler{svc: svc, logger: logger}
}
func (h *FolderHandler) CreateFolder(c *gin.Context) {
var req model.CreateFolderRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Warn("invalid request body for create folder", zap.Error(err))
server.BadRequest(c, "invalid request body")
return
}
if req.Name == "" {
h.logger.Warn("missing folder name")
server.BadRequest(c, "name is required")
return
}
resp, err := h.svc.CreateFolder(c.Request.Context(), req.Name, req.ParentID)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "invalid folder name") || strings.Contains(errStr, "cannot be") {
h.logger.Warn("invalid folder name", zap.String("name", req.Name), zap.Error(err))
server.BadRequest(c, errStr)
return
}
h.logger.Error("failed to create folder", zap.Error(err))
server.InternalError(c, errStr)
return
}
h.logger.Info("folder created", zap.Int64("id", resp.ID))
server.Created(c, resp)
}
func (h *FolderHandler) GetFolder(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
h.logger.Warn("invalid folder id", zap.String("id", c.Param("id")))
server.BadRequest(c, "invalid id")
return
}
resp, err := h.svc.GetFolder(c.Request.Context(), id)
if err != nil {
if strings.Contains(err.Error(), "not found") {
h.logger.Warn("folder not found", zap.Int64("id", id))
server.NotFound(c, "folder not found")
return
}
h.logger.Error("failed to get folder", zap.Int64("id", id), zap.Error(err))
server.InternalError(c, err.Error())
return
}
server.OK(c, resp)
}
func (h *FolderHandler) ListFolders(c *gin.Context) {
var parentID *int64
if q := c.Query("parent_id"); q != "" {
pid, err := strconv.ParseInt(q, 10, 64)
if err != nil {
h.logger.Warn("invalid parent_id query param", zap.String("parent_id", q))
server.BadRequest(c, "invalid parent_id")
return
}
parentID = &pid
}
folders, err := h.svc.ListFolders(c.Request.Context(), parentID)
if err != nil {
h.logger.Error("failed to list folders", zap.Error(err))
server.InternalError(c, err.Error())
return
}
if folders == nil {
folders = []model.FolderResponse{}
}
server.OK(c, folders)
}
func (h *FolderHandler) DeleteFolder(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
h.logger.Warn("invalid folder id for delete", zap.String("id", c.Param("id")))
server.BadRequest(c, "invalid id")
return
}
if err := h.svc.DeleteFolder(c.Request.Context(), id); err != nil {
errStr := err.Error()
if strings.Contains(errStr, "not empty") {
h.logger.Warn("cannot delete non-empty folder", zap.Int64("id", id))
server.BadRequest(c, "folder is not empty")
return
}
if strings.Contains(errStr, "not found") {
h.logger.Warn("folder not found for delete", zap.Int64("id", id))
server.NotFound(c, "folder not found")
return
}
h.logger.Error("failed to delete folder", zap.Int64("id", id), zap.Error(err))
server.InternalError(c, errStr)
return
}
h.logger.Info("folder deleted", zap.Int64("id", id))
server.OK(c, gin.H{"message": "folder deleted"})
}

View File

@@ -0,0 +1,206 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gcy_hpc_server/internal/model"
"gcy_hpc_server/internal/service"
"gcy_hpc_server/internal/store"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
)
func setupFolderHandler() (*FolderHandler, *gorm.DB) {
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
db.AutoMigrate(&model.Folder{}, &model.File{})
folderStore := store.NewFolderStore(db)
fileStore := store.NewFileStore(db)
svc := service.NewFolderService(folderStore, fileStore, zap.NewNop())
h := NewFolderHandler(svc, zap.NewNop())
return h, db
}
func setupFolderRouter(h *FolderHandler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
v1 := r.Group("/api/v1")
folders := v1.Group("/files/folders")
folders.POST("", h.CreateFolder)
folders.GET("/:id", h.GetFolder)
folders.GET("", h.ListFolders)
folders.DELETE("/:id", h.DeleteFolder)
return r
}
func createTestFolder(h *FolderHandler, r *gin.Engine) int64 {
body, _ := json.Marshal(model.CreateFolderRequest{Name: "test-folder"})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/folders", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
data := resp["data"].(map[string]interface{})
return int64(data["id"].(float64))
}
func TestCreateFolder_Success(t *testing.T) {
h, _ := setupFolderHandler()
r := setupFolderRouter(h)
body, _ := json.Marshal(model.CreateFolderRequest{Name: "my-folder"})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/folders", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp["success"].(bool) {
t.Fatal("expected success=true")
}
data := resp["data"].(map[string]interface{})
if data["name"] != "my-folder" {
t.Fatalf("expected name=my-folder, got %v", data["name"])
}
if data["path"] != "/my-folder/" {
t.Fatalf("expected path=/my-folder/, got %v", data["path"])
}
}
func TestCreateFolder_PathTraversal(t *testing.T) {
h, _ := setupFolderHandler()
r := setupFolderRouter(h)
body, _ := json.Marshal(model.CreateFolderRequest{Name: ".."})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/folders", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateFolder_MissingName(t *testing.T) {
h, _ := setupFolderHandler()
r := setupFolderRouter(h)
body, _ := json.Marshal(map[string]interface{}{})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/folders", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetFolder_Success(t *testing.T) {
h, _ := setupFolderHandler()
r := setupFolderRouter(h)
id := createTestFolder(h, r)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/folders/"+itoa(id), nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
data := resp["data"].(map[string]interface{})
if data["name"] != "test-folder" {
t.Fatalf("expected name=test-folder, got %v", data["name"])
}
}
func TestGetFolder_NotFound(t *testing.T) {
h, _ := setupFolderHandler()
r := setupFolderRouter(h)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/folders/999", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestListFolders_Success(t *testing.T) {
h, _ := setupFolderHandler()
r := setupFolderRouter(h)
createTestFolder(h, r)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/folders", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp["success"].(bool) {
t.Fatal("expected success=true")
}
data := resp["data"].([]interface{})
if len(data) < 1 {
t.Fatal("expected at least 1 folder")
}
}
func TestDeleteFolder_Success(t *testing.T) {
h, _ := setupFolderHandler()
r := setupFolderRouter(h)
id := createTestFolder(h, r)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/folders/"+itoa(id), nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestDeleteFolder_NonEmpty(t *testing.T) {
h, db := setupFolderHandler()
r := setupFolderRouter(h)
parentID := createTestFolder(h, r)
child := &model.Folder{
Name: "child-folder",
ParentID: &parentID,
Path: "/test-folder/child-folder/",
}
db.Create(child)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/folders/"+itoa(parentID), nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}

View File

@@ -0,0 +1,154 @@
package handler
import (
"context"
"io"
"strconv"
"gcy_hpc_server/internal/model"
"gcy_hpc_server/internal/server"
"gcy_hpc_server/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// uploadServiceProvider defines the interface for upload operations.
type uploadServiceProvider interface {
InitUpload(ctx context.Context, req model.InitUploadRequest) (interface{}, error)
UploadChunk(ctx context.Context, sessionID int64, chunkIndex int, reader io.Reader, size int64) error
CompleteUpload(ctx context.Context, sessionID int64) (*model.FileResponse, error)
GetUploadStatus(ctx context.Context, sessionID int64) (*model.UploadSessionResponse, error)
CancelUpload(ctx context.Context, sessionID int64) error
}
// UploadHandler handles HTTP requests for chunked file uploads.
type UploadHandler struct {
svc uploadServiceProvider
logger *zap.Logger
}
// NewUploadHandler creates a new UploadHandler.
func NewUploadHandler(svc *service.UploadService, logger *zap.Logger) *UploadHandler {
return &UploadHandler{svc: svc, logger: logger}
}
// InitUpload initiates a new chunked upload session.
// POST /api/v1/files/uploads
func (h *UploadHandler) InitUpload(c *gin.Context) {
var req model.InitUploadRequest
if err := c.ShouldBindJSON(&req); err != nil {
h.logger.Warn("invalid request body for init upload", zap.Error(err))
server.BadRequest(c, err.Error())
return
}
result, err := h.svc.InitUpload(c.Request.Context(), req)
if err != nil {
h.logger.Error("failed to init upload", zap.Error(err))
server.BadRequest(c, err.Error())
return
}
switch resp := result.(type) {
case model.FileResponse:
server.OK(c, resp)
case model.UploadSessionResponse:
server.Created(c, resp)
default:
server.Created(c, resp)
}
}
// UploadChunk uploads a single chunk of an upload session.
// PUT /api/v1/files/uploads/:id/chunks/:index
func (h *UploadHandler) UploadChunk(c *gin.Context) {
sessionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
h.logger.Warn("invalid session id", zap.String("id", c.Param("id")))
server.BadRequest(c, "invalid session id")
return
}
chunkIndex, err := strconv.Atoi(c.Param("index"))
if err != nil {
h.logger.Warn("invalid chunk index", zap.String("index", c.Param("index")))
server.BadRequest(c, "invalid chunk index")
return
}
file, header, err := c.Request.FormFile("chunk")
if err != nil {
h.logger.Warn("missing chunk file in request", zap.Error(err))
server.BadRequest(c, "missing chunk file")
return
}
defer file.Close()
if err := h.svc.UploadChunk(c.Request.Context(), sessionID, chunkIndex, file, header.Size); err != nil {
h.logger.Error("failed to upload chunk", zap.Int64("session_id", sessionID), zap.Int("chunk_index", chunkIndex), zap.Error(err))
server.BadRequest(c, err.Error())
return
}
server.OK(c, gin.H{"message": "chunk uploaded"})
}
// CompleteUpload finalizes an upload session and assembles the file.
// POST /api/v1/files/uploads/:id/complete
func (h *UploadHandler) CompleteUpload(c *gin.Context) {
sessionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
h.logger.Warn("invalid session id for complete", zap.String("id", c.Param("id")))
server.BadRequest(c, "invalid session id")
return
}
resp, err := h.svc.CompleteUpload(c.Request.Context(), sessionID)
if err != nil {
h.logger.Error("failed to complete upload", zap.Int64("session_id", sessionID), zap.Error(err))
server.InternalError(c, err.Error())
return
}
server.Created(c, resp)
}
// GetUploadStatus returns the current status of an upload session.
// GET /api/v1/files/uploads/:id
func (h *UploadHandler) GetUploadStatus(c *gin.Context) {
sessionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
h.logger.Warn("invalid session id for status", zap.String("id", c.Param("id")))
server.BadRequest(c, "invalid session id")
return
}
resp, err := h.svc.GetUploadStatus(c.Request.Context(), sessionID)
if err != nil {
h.logger.Error("failed to get upload status", zap.Int64("session_id", sessionID), zap.Error(err))
server.InternalError(c, err.Error())
return
}
server.OK(c, resp)
}
// CancelUpload cancels and cleans up an upload session.
// DELETE /api/v1/files/uploads/:id
func (h *UploadHandler) CancelUpload(c *gin.Context) {
sessionID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
h.logger.Warn("invalid session id for cancel", zap.String("id", c.Param("id")))
server.BadRequest(c, "invalid session id")
return
}
if err := h.svc.CancelUpload(c.Request.Context(), sessionID); err != nil {
h.logger.Error("failed to cancel upload", zap.Int64("session_id", sessionID), zap.Error(err))
server.InternalError(c, err.Error())
return
}
server.OK(c, gin.H{"message": "upload cancelled"})
}

View File

@@ -0,0 +1,307 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
"gcy_hpc_server/internal/model"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
type mockUploadService struct {
initUploadFn func(ctx context.Context, req model.InitUploadRequest) (interface{}, error)
uploadChunkFn func(ctx context.Context, sessionID int64, chunkIndex int, reader io.Reader, size int64) error
completeUploadFn func(ctx context.Context, sessionID int64) (*model.FileResponse, error)
getUploadStatusFn func(ctx context.Context, sessionID int64) (*model.UploadSessionResponse, error)
cancelUploadFn func(ctx context.Context, sessionID int64) error
}
func (m *mockUploadService) InitUpload(ctx context.Context, req model.InitUploadRequest) (interface{}, error) {
return m.initUploadFn(ctx, req)
}
func (m *mockUploadService) UploadChunk(ctx context.Context, sessionID int64, chunkIndex int, reader io.Reader, size int64) error {
return m.uploadChunkFn(ctx, sessionID, chunkIndex, reader, size)
}
func (m *mockUploadService) CompleteUpload(ctx context.Context, sessionID int64) (*model.FileResponse, error) {
return m.completeUploadFn(ctx, sessionID)
}
func (m *mockUploadService) GetUploadStatus(ctx context.Context, sessionID int64) (*model.UploadSessionResponse, error) {
return m.getUploadStatusFn(ctx, sessionID)
}
func (m *mockUploadService) CancelUpload(ctx context.Context, sessionID int64) error {
return m.cancelUploadFn(ctx, sessionID)
}
func setupUploadHandler(mock uploadServiceProvider) (*UploadHandler, *gin.Engine) {
gin.SetMode(gin.TestMode)
h := &UploadHandler{svc: mock, logger: zap.NewNop()}
r := gin.New()
v1 := r.Group("/api/v1")
uploads := v1.Group("/files/uploads")
uploads.POST("", h.InitUpload)
uploads.PUT("/:id/chunks/:index", h.UploadChunk)
uploads.POST("/:id/complete", h.CompleteUpload)
uploads.GET("/:id", h.GetUploadStatus)
uploads.DELETE("/:id", h.CancelUpload)
return h, r
}
func TestInitUpload_NewSession(t *testing.T) {
mock := &mockUploadService{
initUploadFn: func(ctx context.Context, req model.InitUploadRequest) (interface{}, error) {
return model.UploadSessionResponse{
ID: 1,
FileName: req.FileName,
FileSize: req.FileSize,
ChunkSize: 5 * 1024 * 1024,
TotalChunks: 2,
SHA256: req.SHA256,
Status: "pending",
UploadedChunks: []int{},
ExpiresAt: time.Now().Add(24 * time.Hour),
CreatedAt: time.Now(),
}, nil
},
}
_, r := setupUploadHandler(mock)
body, _ := json.Marshal(model.InitUploadRequest{
FileName: "test.bin",
FileSize: 10 * 1024 * 1024,
SHA256: "abc123",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/uploads", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp["success"].(bool) {
t.Fatal("expected success=true")
}
data := resp["data"].(map[string]interface{})
if data["status"] != "pending" {
t.Fatalf("expected status=pending, got %v", data["status"])
}
}
func TestInitUpload_DedupHit(t *testing.T) {
mock := &mockUploadService{
initUploadFn: func(ctx context.Context, req model.InitUploadRequest) (interface{}, error) {
return model.FileResponse{
ID: 42,
Name: req.FileName,
Size: req.FileSize,
SHA256: req.SHA256,
MimeType: "application/octet-stream",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
},
}
_, r := setupUploadHandler(mock)
body, _ := json.Marshal(model.InitUploadRequest{
FileName: "existing.bin",
FileSize: 1024,
SHA256: "existinghash",
})
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/uploads", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp["success"].(bool) {
t.Fatal("expected success=true")
}
data := resp["data"].(map[string]interface{})
if data["sha256"] != "existinghash" {
t.Fatalf("expected sha256=existinghash, got %v", data["sha256"])
}
}
func TestInitUpload_MissingFields(t *testing.T) {
mock := &mockUploadService{
initUploadFn: func(ctx context.Context, req model.InitUploadRequest) (interface{}, error) {
return nil, nil
},
}
_, r := setupUploadHandler(mock)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/uploads", bytes.NewReader([]byte(`{}`)))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestUploadChunk_Success(t *testing.T) {
var receivedSessionID int64
var receivedChunkIndex int
mock := &mockUploadService{
uploadChunkFn: func(ctx context.Context, sessionID int64, chunkIndex int, reader io.Reader, size int64) error {
receivedSessionID = sessionID
receivedChunkIndex = chunkIndex
readBytes, _ := io.ReadAll(reader)
if len(readBytes) == 0 {
t.Error("expected non-empty chunk data")
}
return nil
},
}
_, r := setupUploadHandler(mock)
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
part, _ := writer.CreateFormFile("chunk", "test.bin")
fmt.Fprintf(part, "chunk data here")
writer.Close()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPut, "/api/v1/files/uploads/1/chunks/0", &buf)
req.Header.Set("Content-Type", writer.FormDataContentType())
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if receivedSessionID != 1 {
t.Fatalf("expected session_id=1, got %d", receivedSessionID)
}
if receivedChunkIndex != 0 {
t.Fatalf("expected chunk_index=0, got %d", receivedChunkIndex)
}
}
func TestCompleteUpload_Success(t *testing.T) {
mock := &mockUploadService{
completeUploadFn: func(ctx context.Context, sessionID int64) (*model.FileResponse, error) {
return &model.FileResponse{
ID: 10,
Name: "completed.bin",
Size: 2048,
SHA256: "completehash",
MimeType: "application/octet-stream",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
},
}
_, r := setupUploadHandler(mock)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/api/v1/files/uploads/5/complete", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp["success"].(bool) {
t.Fatal("expected success=true")
}
data := resp["data"].(map[string]interface{})
if data["name"] != "completed.bin" {
t.Fatalf("expected name=completed.bin, got %v", data["name"])
}
}
func TestGetUploadStatus_Success(t *testing.T) {
mock := &mockUploadService{
getUploadStatusFn: func(ctx context.Context, sessionID int64) (*model.UploadSessionResponse, error) {
return &model.UploadSessionResponse{
ID: sessionID,
FileName: "status.bin",
FileSize: 4096,
ChunkSize: 2048,
TotalChunks: 2,
SHA256: "statushash",
Status: "uploading",
UploadedChunks: []int{0},
ExpiresAt: time.Now().Add(24 * time.Hour),
CreatedAt: time.Now(),
}, nil
},
}
_, r := setupUploadHandler(mock)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/api/v1/files/uploads/3", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if !resp["success"].(bool) {
t.Fatal("expected success=true")
}
data := resp["data"].(map[string]interface{})
if data["status"] != "uploading" {
t.Fatalf("expected status=uploading, got %v", data["status"])
}
}
func TestCancelUpload_Success(t *testing.T) {
var receivedSessionID int64
mock := &mockUploadService{
cancelUploadFn: func(ctx context.Context, sessionID int64) error {
receivedSessionID = sessionID
return nil
},
}
_, r := setupUploadHandler(mock)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/files/uploads/7", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if receivedSessionID != 7 {
t.Fatalf("expected session_id=7, got %d", receivedSessionID)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
data := resp["data"].(map[string]interface{})
if data["message"] != "upload cancelled" {
t.Fatalf("expected message='upload cancelled', got %v", data["message"])
}
}

View File

@@ -34,8 +34,30 @@ type ApplicationHandler interface {
SubmitApplication(c *gin.Context) SubmitApplication(c *gin.Context)
} }
type UploadHandler interface {
InitUpload(c *gin.Context)
GetUploadStatus(c *gin.Context)
UploadChunk(c *gin.Context)
CompleteUpload(c *gin.Context)
CancelUpload(c *gin.Context)
}
type FileHandler interface {
ListFiles(c *gin.Context)
GetFile(c *gin.Context)
DownloadFile(c *gin.Context)
DeleteFile(c *gin.Context)
}
type FolderHandler interface {
CreateFolder(c *gin.Context)
GetFolder(c *gin.Context)
ListFolders(c *gin.Context)
DeleteFolder(c *gin.Context)
}
// NewRouter creates a Gin engine with all API v1 routes registered with real handlers. // NewRouter creates a Gin engine with all API v1 routes registered with real handlers.
func NewRouter(jobH JobHandler, clusterH ClusterHandler, appH ApplicationHandler, logger *zap.Logger) *gin.Engine { func NewRouter(jobH JobHandler, clusterH ClusterHandler, appH ApplicationHandler, uploadH UploadHandler, fileH FileHandler, folderH FolderHandler, logger *zap.Logger) *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
r.Use(gin.Recovery()) r.Use(gin.Recovery())
@@ -68,6 +90,32 @@ func NewRouter(jobH JobHandler, clusterH ClusterHandler, appH ApplicationHandler
apps.DELETE("/:id", appH.DeleteApplication) apps.DELETE("/:id", appH.DeleteApplication)
apps.POST("/:id/submit", appH.SubmitApplication) apps.POST("/:id/submit", appH.SubmitApplication)
files := v1.Group("/files")
if uploadH != nil {
uploads := files.Group("/uploads")
uploads.POST("", uploadH.InitUpload)
uploads.GET("/:id", uploadH.GetUploadStatus)
uploads.PUT("/:id/chunks/:index", uploadH.UploadChunk)
uploads.POST("/:id/complete", uploadH.CompleteUpload)
uploads.DELETE("/:id", uploadH.CancelUpload)
}
if fileH != nil {
files.GET("", fileH.ListFiles)
files.GET("/:id", fileH.GetFile)
files.GET("/:id/download", fileH.DownloadFile)
files.DELETE("/:id", fileH.DeleteFile)
}
if folderH != nil {
folders := files.Group("/folders")
folders.POST("", folderH.CreateFolder)
folders.GET("", folderH.ListFolders)
folders.GET("/:id", folderH.GetFolder)
folders.DELETE("/:id", folderH.DeleteFolder)
}
return r return r
} }
@@ -104,6 +152,26 @@ func registerPlaceholderRoutes(v1 *gin.RouterGroup) {
apps.PUT("/:id", notImplemented) apps.PUT("/:id", notImplemented)
apps.DELETE("/:id", notImplemented) apps.DELETE("/:id", notImplemented)
apps.POST("/:id/submit", notImplemented) apps.POST("/:id/submit", notImplemented)
files := v1.Group("/files")
uploads := files.Group("/uploads")
uploads.POST("", notImplemented)
uploads.GET("/:id", notImplemented)
uploads.PUT("/:id/chunks/:index", notImplemented)
uploads.POST("/:id/complete", notImplemented)
uploads.DELETE("/:id", notImplemented)
files.GET("", notImplemented)
files.GET("/:id", notImplemented)
files.GET("/:id/download", notImplemented)
files.DELETE("/:id", notImplemented)
folders := files.Group("/folders")
folders.POST("", notImplemented)
folders.GET("", notImplemented)
folders.GET("/:id", notImplemented)
folders.DELETE("/:id", notImplemented)
} }
func notImplemented(c *gin.Context) { func notImplemented(c *gin.Context) {