From 99e5758d4c86020de7cdfa35134c7045cecd0058 Mon Sep 17 00:00:00 2001 From: Huxley Date: Wed, 15 Jul 2026 20:06:40 +0800 Subject: [PATCH] test(server): cover authenticated file transfer flow - test: exercise register, login, empty listing, small-file upload, updated listing, and authenticated download through the HTTP router. - docs: mark the small-file integration flow as covered in the server roadmap. --- docs/server/roadmap.md | 2 +- internal/server/server_test.go | 192 +++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/docs/server/roadmap.md b/docs/server/roadmap.md index fd99323..c79db6f 100644 --- a/docs/server/roadmap.md +++ b/docs/server/roadmap.md @@ -27,7 +27,7 @@ Package-level implementation order (each task includes unit tests): 10. `internal/handler` — auth, account, file, admin handlers 🛠 (HTTP DTO mapping in place) 11. `internal/server` — Gin router, route registration, graceful shutdown ✅ 12. `cmd/serve.go`, `cmd/config.go`, `cmd/status.go` ✅ (serve done) -13. Integration tests +13. Integration tests 🛠 (authenticated register → login → upload → list → download route flow covered with a small file) 14. Architecture boundary tests ✅ ## Future diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 12dd639..8e7d170 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -4,6 +4,8 @@ import ( "bytes" "context" "encoding/json" + "io" + "mime/multipart" "net/http" "net/http/httptest" "testing" @@ -14,6 +16,7 @@ import ( "github.com/dhao2001/mygo/internal/config" "github.com/dhao2001/mygo/internal/repository" "github.com/dhao2001/mygo/internal/service" + "github.com/dhao2001/mygo/internal/storage" ) func TestVersionRoute(t *testing.T) { @@ -181,3 +184,192 @@ func TestAdminRoutes(t *testing.T) { t.Errorf("no-auth delete: status=%d, want %d, body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) } } + +func TestAuthenticatedFileFlow(t *testing.T) { + dbCfg := config.DatabaseConfig{ + Driver: "sqlite3", + SQLite: config.SQLiteConfig{Path: ":memory:"}, + } + db, err := repository.Open(dbCfg) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := repository.AutoMigrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + + userRepo := repository.NewUserRepository(db) + sessionRepo := repository.NewSessionRepository(db) + fileRepo := repository.NewFileRepository(db) + credentialRepo := repository.NewCredentialRepository(db) + store, err := storage.NewLocalStorage(t.TempDir()) + if err != nil { + t.Fatalf("create local storage: %v", err) + } + + const maxUploadSize = 20 << 20 + jwtSecret := []byte("test-secret") + authService := service.NewAuthService( + userRepo, + sessionRepo, + credentialRepo, + jwtSecret, + 15*time.Minute, + 168*time.Hour, + ) + fileService := service.NewFileService(fileRepo, store, maxUploadSize, nil) + cfg := &config.Config{ + JWT: config.JWTConfig{ + Secret: string(jwtSecret), + AccessTTL: 15 * time.Minute, + RefreshTTL: 168 * time.Hour, + }, + Storage: config.StorageConfig{MaxUploadSize: maxUploadSize}, + } + webApp := app.NewWebApp( + cfg, + db, + userRepo, + sessionRepo, + fileRepo, + credentialRepo, + authService, + service.NewAdminService(userRepo), + fileService, + store, + ) + t.Cleanup(func() { + if err := webApp.Close(); err != nil { + t.Errorf("close web app: %v", err) + } + }) + router := NewRouter(webApp) + + registerBody := []byte(`{"username":"web-user","email":"web@example.com","password":"password123"}`) + registerRequest := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/register", + bytes.NewReader(registerBody), + ) + registerRequest.Header.Set("Content-Type", "application/json") + registerResponse := httptest.NewRecorder() + router.ServeHTTP(registerResponse, registerRequest) + if registerResponse.Code != http.StatusCreated { + t.Fatalf("register: status=%d, body=%s", registerResponse.Code, registerResponse.Body.String()) + } + + loginBody := []byte(`{"email":"web@example.com","password":"password123"}`) + loginRequest := httptest.NewRequest( + http.MethodPost, + "/api/v1/auth/login", + bytes.NewReader(loginBody), + ) + loginRequest.Header.Set("Content-Type", "application/json") + loginResponse := httptest.NewRecorder() + router.ServeHTTP(loginResponse, loginRequest) + if loginResponse.Code != http.StatusOK { + t.Fatalf("login: status=%d, body=%s", loginResponse.Code, loginResponse.Body.String()) + } + var tokens struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(loginResponse.Body.Bytes(), &tokens); err != nil { + t.Fatalf("decode login response: %v", err) + } + if tokens.AccessToken == "" { + t.Fatal("login response has empty access token") + } + + listRequest := httptest.NewRequest(http.MethodGet, "/api/v1/files?offset=0&limit=50", nil) + listRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken) + listResponse := httptest.NewRecorder() + router.ServeHTTP(listResponse, listRequest) + if listResponse.Code != http.StatusOK { + t.Fatalf("initial list: status=%d, body=%s", listResponse.Code, listResponse.Body.String()) + } + var initialList struct { + Total int64 `json:"total"` + } + if err := json.Unmarshal(listResponse.Body.Bytes(), &initialList); err != nil { + t.Fatalf("decode initial list: %v", err) + } + if initialList.Total != 0 { + t.Fatalf("initial total=%d, want 0", initialList.Total) + } + + fileContent := []byte("MyGO browser milestone upload") + uploadBody := &bytes.Buffer{} + multipartWriter := multipart.NewWriter(uploadBody) + filePart, err := multipartWriter.CreateFormFile("file", "milestone.txt") + if err != nil { + t.Fatalf("create multipart file: %v", err) + } + if _, err := filePart.Write(fileContent); err != nil { + t.Fatalf("write multipart file: %v", err) + } + if err := multipartWriter.Close(); err != nil { + t.Fatalf("close multipart writer: %v", err) + } + uploadRequest := httptest.NewRequest(http.MethodPost, "/api/v1/files", uploadBody) + uploadRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken) + uploadRequest.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + uploadResponse := httptest.NewRecorder() + router.ServeHTTP(uploadResponse, uploadRequest) + if uploadResponse.Code != http.StatusCreated { + t.Fatalf("upload: status=%d, body=%s", uploadResponse.Code, uploadResponse.Body.String()) + } + var uploadedFile struct { + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + } + if err := json.Unmarshal(uploadResponse.Body.Bytes(), &uploadedFile); err != nil { + t.Fatalf("decode upload response: %v", err) + } + if uploadedFile.Name != "milestone.txt" || uploadedFile.Size != int64(len(fileContent)) { + t.Fatalf("uploaded file=%+v, want name milestone.txt and size %d", uploadedFile, len(fileContent)) + } + + listRequest = httptest.NewRequest(http.MethodGet, "/api/v1/files?offset=0&limit=50", nil) + listRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken) + listResponse = httptest.NewRecorder() + router.ServeHTTP(listResponse, listRequest) + if listResponse.Code != http.StatusOK { + t.Fatalf("list after upload: status=%d, body=%s", listResponse.Code, listResponse.Body.String()) + } + var uploadedList struct { + Files []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"files"` + Total int64 `json:"total"` + } + if err := json.Unmarshal(listResponse.Body.Bytes(), &uploadedList); err != nil { + t.Fatalf("decode uploaded list: %v", err) + } + if uploadedList.Total != 1 || len(uploadedList.Files) != 1 || uploadedList.Files[0].ID != uploadedFile.ID { + t.Fatalf("uploaded list=%+v, want the uploaded file", uploadedList) + } + + downloadRequest := httptest.NewRequest( + http.MethodGet, + "/api/v1/files/"+uploadedFile.ID+"/content", + nil, + ) + downloadRequest.Header.Set("Authorization", "Bearer "+tokens.AccessToken) + downloadResponse := httptest.NewRecorder() + router.ServeHTTP(downloadResponse, downloadRequest) + if downloadResponse.Code != http.StatusOK { + t.Fatalf("download: status=%d, body=%s", downloadResponse.Code, downloadResponse.Body.String()) + } + downloadedContent, err := io.ReadAll(downloadResponse.Body) + if err != nil { + t.Fatalf("read download response: %v", err) + } + if !bytes.Equal(downloadedContent, fileContent) { + t.Fatalf("downloaded content=%q, want %q", downloadedContent, fileContent) + } + if disposition := downloadResponse.Header().Get("Content-Disposition"); disposition == "" { + t.Fatal("download response has empty Content-Disposition") + } +}