feat(testutil): add MockSlurm, MockMinIO, TestEnv and 37 integration tests
- mockminio: in-memory ObjectStorage with all 11 methods, thread-safe, SHA256 ETag, Range support - mockslurm: httptest server with 11 Slurm REST API endpoints, job eviction from active to history queue - testenv: one-line test environment factory (SQLite + MockSlurm + MockMinIO + all stores/services/handlers + httptest server) - integration tests: 37 tests covering Jobs(5), Cluster(5), App(6), Upload(5), File(4), Folder(4), Task(4), E2E(1) - no external dependencies, no existing files modified
This commit is contained in:
279
cmd/server/integration_upload_test.go
Normal file
279
cmd/server/integration_upload_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"gcy_hpc_server/internal/model"
|
||||
"gcy_hpc_server/internal/testutil/testenv"
|
||||
)
|
||||
|
||||
// uploadResponse mirrors server.APIResponse for upload integration tests.
|
||||
type uploadAPIResp struct {
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// uploadDecode parses an HTTP response body into uploadAPIResp.
|
||||
func uploadDecode(t *testing.T, body io.Reader) uploadAPIResp {
|
||||
t.Helper()
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
t.Fatalf("uploadDecode: read body: %v", err)
|
||||
}
|
||||
var r uploadAPIResp
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
t.Fatalf("uploadDecode: unmarshal: %v (body: %s)", err, string(data))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// uploadInitSession calls InitUpload and returns the created session.
|
||||
// Uses the real HTTP server from testenv.
|
||||
func uploadInitSession(t *testing.T, env *testenv.TestEnv, fileName string, fileSize int64, sha256Hash string) model.UploadSessionResponse {
|
||||
t.Helper()
|
||||
reqBody := model.InitUploadRequest{
|
||||
FileName: fileName,
|
||||
FileSize: fileSize,
|
||||
SHA256: sha256Hash,
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
resp := env.DoRequest("POST", "/api/v1/files/uploads", bytes.NewReader(body))
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("uploadInitSession: expected 201, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
r := uploadDecode(t, resp.Body)
|
||||
if !r.Success {
|
||||
t.Fatalf("uploadInitSession: response not success: %s", r.Error)
|
||||
}
|
||||
|
||||
var session model.UploadSessionResponse
|
||||
if err := json.Unmarshal(r.Data, &session); err != nil {
|
||||
t.Fatalf("uploadInitSession: unmarshal session: %v", err)
|
||||
}
|
||||
return session
|
||||
}
|
||||
|
||||
// uploadSendChunk sends a single chunk via multipart form data.
|
||||
// Uses raw HTTP client to set the correct multipart content type.
|
||||
func uploadSendChunk(t *testing.T, env *testenv.TestEnv, sessionID int64, chunkIndex int, chunkData []byte) {
|
||||
t.Helper()
|
||||
url := fmt.Sprintf("%s/api/v1/files/uploads/%d/chunks/%d", env.URL(), sessionID, chunkIndex)
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
part, err := writer.CreateFormFile("chunk", "chunk.bin")
|
||||
if err != nil {
|
||||
t.Fatalf("uploadSendChunk: create form file: %v", err)
|
||||
}
|
||||
part.Write(chunkData)
|
||||
writer.Close()
|
||||
|
||||
req, err := http.NewRequest("PUT", url, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("uploadSendChunk: new request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("uploadSendChunk: do request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("uploadSendChunk: expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Upload_Init(t *testing.T) {
|
||||
env := testenv.NewTestEnv(t)
|
||||
|
||||
fileData := []byte("integration test upload init content")
|
||||
h := sha256.Sum256(fileData)
|
||||
sha256Hash := hex.EncodeToString(h[:])
|
||||
|
||||
session := uploadInitSession(t, env, "init_test.txt", int64(len(fileData)), sha256Hash)
|
||||
|
||||
if session.ID <= 0 {
|
||||
t.Fatalf("expected positive session ID, got %d", session.ID)
|
||||
}
|
||||
if session.FileName != "init_test.txt" {
|
||||
t.Fatalf("expected file_name init_test.txt, got %s", session.FileName)
|
||||
}
|
||||
if session.Status != "pending" {
|
||||
t.Fatalf("expected status pending, got %s", session.Status)
|
||||
}
|
||||
if session.TotalChunks != 1 {
|
||||
t.Fatalf("expected 1 chunk for small file, got %d", session.TotalChunks)
|
||||
}
|
||||
if session.FileSize != int64(len(fileData)) {
|
||||
t.Fatalf("expected file_size %d, got %d", len(fileData), session.FileSize)
|
||||
}
|
||||
if session.SHA256 != sha256Hash {
|
||||
t.Fatalf("expected sha256 %s, got %s", sha256Hash, session.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Upload_Status(t *testing.T) {
|
||||
env := testenv.NewTestEnv(t)
|
||||
|
||||
fileData := []byte("integration test status content")
|
||||
h := sha256.Sum256(fileData)
|
||||
sha256Hash := hex.EncodeToString(h[:])
|
||||
|
||||
session := uploadInitSession(t, env, "status_test.txt", int64(len(fileData)), sha256Hash)
|
||||
|
||||
resp := env.DoRequest("GET", fmt.Sprintf("/api/v1/files/uploads/%d", session.ID), nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
r := uploadDecode(t, resp.Body)
|
||||
if !r.Success {
|
||||
t.Fatalf("response not success: %s", r.Error)
|
||||
}
|
||||
|
||||
var status model.UploadSessionResponse
|
||||
if err := json.Unmarshal(r.Data, &status); err != nil {
|
||||
t.Fatalf("unmarshal status: %v", err)
|
||||
}
|
||||
|
||||
if status.ID != session.ID {
|
||||
t.Fatalf("expected session ID %d, got %d", session.ID, status.ID)
|
||||
}
|
||||
if status.Status != "pending" {
|
||||
t.Fatalf("expected status pending, got %s", status.Status)
|
||||
}
|
||||
if status.FileName != "status_test.txt" {
|
||||
t.Fatalf("expected file_name status_test.txt, got %s", status.FileName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Upload_Chunk(t *testing.T) {
|
||||
env := testenv.NewTestEnv(t)
|
||||
|
||||
fileData := []byte("integration test chunk upload data")
|
||||
h := sha256.Sum256(fileData)
|
||||
sha256Hash := hex.EncodeToString(h[:])
|
||||
|
||||
session := uploadInitSession(t, env, "chunk_test.txt", int64(len(fileData)), sha256Hash)
|
||||
|
||||
uploadSendChunk(t, env, session.ID, 0, fileData)
|
||||
|
||||
// Verify chunk appears in uploaded_chunks via status endpoint
|
||||
resp := env.DoRequest("GET", fmt.Sprintf("/api/v1/files/uploads/%d", session.ID), nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
r := uploadDecode(t, resp.Body)
|
||||
var status model.UploadSessionResponse
|
||||
if err := json.Unmarshal(r.Data, &status); err != nil {
|
||||
t.Fatalf("unmarshal status after chunk: %v", err)
|
||||
}
|
||||
|
||||
if len(status.UploadedChunks) != 1 {
|
||||
t.Fatalf("expected 1 uploaded chunk, got %d", len(status.UploadedChunks))
|
||||
}
|
||||
if status.UploadedChunks[0] != 0 {
|
||||
t.Fatalf("expected uploaded chunk index 0, got %d", status.UploadedChunks[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Upload_Complete(t *testing.T) {
|
||||
env := testenv.NewTestEnv(t)
|
||||
|
||||
fileData := []byte("integration test complete upload data")
|
||||
h := sha256.Sum256(fileData)
|
||||
sha256Hash := hex.EncodeToString(h[:])
|
||||
|
||||
session := uploadInitSession(t, env, "complete_test.txt", int64(len(fileData)), sha256Hash)
|
||||
|
||||
// Upload all chunks
|
||||
for i := 0; i < session.TotalChunks; i++ {
|
||||
uploadSendChunk(t, env, session.ID, i, fileData)
|
||||
}
|
||||
|
||||
// Complete upload
|
||||
resp := env.DoRequest("POST", fmt.Sprintf("/api/v1/files/uploads/%d/complete", session.ID), nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
r := uploadDecode(t, resp.Body)
|
||||
if !r.Success {
|
||||
t.Fatalf("complete response not success: %s", r.Error)
|
||||
}
|
||||
|
||||
var fileResp model.FileResponse
|
||||
if err := json.Unmarshal(r.Data, &fileResp); err != nil {
|
||||
t.Fatalf("unmarshal file response: %v", err)
|
||||
}
|
||||
|
||||
if fileResp.ID <= 0 {
|
||||
t.Fatalf("expected positive file ID, got %d", fileResp.ID)
|
||||
}
|
||||
if fileResp.Name != "complete_test.txt" {
|
||||
t.Fatalf("expected name complete_test.txt, got %s", fileResp.Name)
|
||||
}
|
||||
if fileResp.Size != int64(len(fileData)) {
|
||||
t.Fatalf("expected size %d, got %d", len(fileData), fileResp.Size)
|
||||
}
|
||||
if fileResp.SHA256 != sha256Hash {
|
||||
t.Fatalf("expected sha256 %s, got %s", sha256Hash, fileResp.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Upload_Cancel(t *testing.T) {
|
||||
env := testenv.NewTestEnv(t)
|
||||
|
||||
fileData := []byte("integration test cancel upload data")
|
||||
h := sha256.Sum256(fileData)
|
||||
sha256Hash := hex.EncodeToString(h[:])
|
||||
|
||||
session := uploadInitSession(t, env, "cancel_test.txt", int64(len(fileData)), sha256Hash)
|
||||
|
||||
// Cancel the upload
|
||||
resp := env.DoRequest("DELETE", fmt.Sprintf("/api/v1/files/uploads/%d", session.ID), nil)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
r := uploadDecode(t, resp.Body)
|
||||
if !r.Success {
|
||||
t.Fatalf("cancel response not success: %s", r.Error)
|
||||
}
|
||||
|
||||
// Verify session is no longer in pending state by checking status
|
||||
statusResp := env.DoRequest("GET", fmt.Sprintf("/api/v1/files/uploads/%d", session.ID), nil)
|
||||
defer statusResp.Body.Close()
|
||||
|
||||
sr := uploadDecode(t, statusResp.Body)
|
||||
if sr.Success {
|
||||
var status model.UploadSessionResponse
|
||||
if err := json.Unmarshal(sr.Data, &status); err == nil {
|
||||
if status.Status == "pending" {
|
||||
t.Fatal("expected status to not be pending after cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user