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,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())
}
}