Add file management API with local storage backend
- Implement FileHandler with CRUD operations for files/directories - Add FileService with business logic and SHA-256 hashing - Create LocalStorage backend for filesystem persistence - Add database repository with pagination and name uniqueness constraints - Configure max upload size in storage settings - Include comprehensive tests for all layers
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/dhao2001/mygo/internal/model"
|
||||
"github.com/dhao2001/mygo/internal/repository"
|
||||
"github.com/dhao2001/mygo/internal/service"
|
||||
"github.com/dhao2001/mygo/internal/storage"
|
||||
)
|
||||
|
||||
// inMemStore implements storage.StorageBackend with an in-memory map.
|
||||
type inMemStore struct {
|
||||
files map[string][]byte
|
||||
}
|
||||
|
||||
func newInMemStore() *inMemStore {
|
||||
return &inMemStore{files: make(map[string][]byte)}
|
||||
}
|
||||
|
||||
func (s *inMemStore) Save(_ context.Context, path string, reader io.Reader) (int64, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
s.files[path] = data
|
||||
return int64(len(data)), nil
|
||||
}
|
||||
|
||||
func (s *inMemStore) Open(_ context.Context, path string) (io.ReadCloser, error) {
|
||||
data, ok := s.files[path]
|
||||
if !ok {
|
||||
return nil, &fsNotFoundError{path}
|
||||
}
|
||||
return io.NopCloser(bytes.NewReader(data)), nil
|
||||
}
|
||||
|
||||
func (s *inMemStore) Delete(_ context.Context, path string) error {
|
||||
delete(s.files, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fsNotFoundError struct{ path string }
|
||||
|
||||
func (e *fsNotFoundError) Error() string { return "file not found on disk: " + e.path }
|
||||
|
||||
var _ storage.StorageBackend = (*inMemStore)(nil)
|
||||
|
||||
func setupFileHandler(t *testing.T) *FileHandler {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.File{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
fileRepo := repository.NewFileRepository(db)
|
||||
store := newInMemStore()
|
||||
fileService := service.NewFileService(fileRepo, store, 0)
|
||||
|
||||
return NewFileHandler(fileService)
|
||||
}
|
||||
|
||||
func setupFileRouter(t *testing.T) *gin.Engine {
|
||||
t.Helper()
|
||||
|
||||
handler := setupFileHandler(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
|
||||
// Middleware that injects user_id from header (simulates AuthRequired).
|
||||
r.Use(func(c *gin.Context) {
|
||||
userID := c.GetHeader("X-User-ID")
|
||||
if userID != "" {
|
||||
c.Set("user_id", userID)
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
files := r.Group("/api/v1/files")
|
||||
{
|
||||
files.GET("", handler.List)
|
||||
files.POST("", handler.Upload)
|
||||
files.GET("/:id", handler.Get)
|
||||
files.GET("/:id/content", handler.Download)
|
||||
files.PUT("/:id", handler.Update)
|
||||
files.DELETE("/:id", handler.Delete)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func TestFileHandler_Upload(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "test.txt")
|
||||
part.Write([]byte("hello upload"))
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var info service.FileInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if info.Name != "test.txt" {
|
||||
t.Errorf("Name = %q, want %q", info.Name, "test.txt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_UploadNoFile(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_CreateDir(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
body, _ := json.Marshal(gin.H{"name": "mydir"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String())
|
||||
}
|
||||
|
||||
var info service.FileInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if !info.IsDir {
|
||||
t.Error("IsDir should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_List(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
// Upload a file first.
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "file1.txt")
|
||||
part.Write([]byte("content1"))
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
// List files.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files", nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var list service.FileList
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if list.Total != 1 {
|
||||
t.Errorf("Total = %d, want 1", list.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_Get(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
// Upload a file first.
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "info.txt")
|
||||
part.Write([]byte("file info content"))
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Get metadata.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
var info service.FileInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &info); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if info.ID != uploaded.ID {
|
||||
t.Errorf("ID = %q, want %q", info.ID, uploaded.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_GetNotFound(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/files/nonexistent", nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_Download(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
// Upload a file.
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "download.txt")
|
||||
content := []byte("download me please")
|
||||
part.Write(content)
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Download.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID+"/content", nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
if !bytes.Equal(rec.Body.Bytes(), content) {
|
||||
t.Errorf("content = %q, want %q", rec.Body.String(), content)
|
||||
}
|
||||
if rec.Header().Get("Content-Disposition") == "" {
|
||||
t.Error("Content-Disposition header should be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_Update(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
// Upload a file.
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "oldname.txt")
|
||||
part.Write([]byte("content"))
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Rename.
|
||||
updateBody, _ := json.Marshal(gin.H{"name": "newname.txt"})
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
|
||||
var updated service.FileInfo
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if updated.Name != "newname.txt" {
|
||||
t.Errorf("Name = %q, want %q", updated.Name, "newname.txt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_UpdateNoFields(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
// Upload a file.
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "file.txt")
|
||||
part.Write([]byte("content"))
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
updateBody, _ := json.Marshal(gin.H{})
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/files/"+uploaded.ID, bytes.NewReader(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_Delete(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
// Upload a file.
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "delete-me.txt")
|
||||
part.Write([]byte("content"))
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Delete.
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/files/"+uploaded.ID, nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("status = %d, want %d; body = %s", rec.Code, http.StatusNoContent, rec.Body.String())
|
||||
}
|
||||
|
||||
// Verify gone.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status after delete = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileHandler_ForbiddenAccess(t *testing.T) {
|
||||
r := setupFileRouter(t)
|
||||
|
||||
// Upload as user1.
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, _ := writer.CreateFormFile("file", "private.txt")
|
||||
part.Write([]byte("secret"))
|
||||
writer.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/files", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("X-User-ID", "user1")
|
||||
rec := httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
var uploaded service.FileInfo
|
||||
json.Unmarshal(rec.Body.Bytes(), &uploaded)
|
||||
|
||||
// Try to access as user2.
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/files/"+uploaded.ID, nil)
|
||||
req.Header.Set("X-User-ID", "user2")
|
||||
rec = httptest.NewRecorder()
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d", rec.Code, http.StatusForbidden)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user