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,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"])
}
}