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