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:
138
internal/handler/file_handler.go
Normal file
138
internal/handler/file_handler.go
Normal 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"})
|
||||
}
|
||||
369
internal/handler/file_handler_test.go
Normal file
369
internal/handler/file_handler_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
133
internal/handler/folder_handler.go
Normal file
133
internal/handler/folder_handler.go
Normal 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"})
|
||||
}
|
||||
206
internal/handler/folder_handler_test.go
Normal file
206
internal/handler/folder_handler_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
154
internal/handler/upload_handler.go
Normal file
154
internal/handler/upload_handler.go
Normal 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"})
|
||||
}
|
||||
307
internal/handler/upload_handler_test.go
Normal file
307
internal/handler/upload_handler_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user