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:
dailz
2026-04-16 13:23:27 +08:00
parent 73504f9fdb
commit b9b2f0d9b4
16 changed files with 4685 additions and 0 deletions

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