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,245 @@
package testenv
import (
"encoding/json"
"io"
"net/http"
"testing"
"time"
"gcy_hpc_server/internal/model"
"gcy_hpc_server/internal/storage"
)
func TestNewTestEnv(t *testing.T) {
env := NewTestEnv(t)
defer func() {
env.taskSvc.StopProcessor()
env.poller.Stop()
env.srv.Close()
}()
if env.DB == nil {
t.Fatal("DB is nil")
}
if env.MockSlurm == nil {
t.Fatal("MockSlurm is nil")
}
if env.MockMinIO == nil {
t.Fatal("MockMinIO is nil")
}
if env.srv == nil {
t.Fatal("srv is nil")
}
resp, err := http.Get(env.srv.URL + "/api/v1/applications")
if err != nil {
t.Fatalf("GET /api/v1/applications: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestAllRoutesRegistered(t *testing.T) {
env := NewTestEnv(t)
routes := []struct {
method string
path string
}{
{"POST", "/api/v1/jobs/submit"},
{"GET", "/api/v1/jobs"},
{"GET", "/api/v1/jobs/history"},
{"GET", "/api/v1/jobs/1"},
{"DELETE", "/api/v1/jobs/1"},
{"GET", "/api/v1/nodes"},
{"GET", "/api/v1/nodes/node01"},
{"GET", "/api/v1/partitions"},
{"GET", "/api/v1/partitions/normal"},
{"GET", "/api/v1/diag"},
{"GET", "/api/v1/applications"},
{"POST", "/api/v1/applications"},
{"GET", "/api/v1/applications/1"},
{"PUT", "/api/v1/applications/1"},
{"DELETE", "/api/v1/applications/1"},
{"POST", "/api/v1/applications/1/submit"},
{"POST", "/api/v1/files/uploads"},
{"GET", "/api/v1/files/uploads/1"},
{"PUT", "/api/v1/files/uploads/1/chunks/0"},
{"POST", "/api/v1/files/uploads/1/complete"},
{"DELETE", "/api/v1/files/uploads/1"},
{"GET", "/api/v1/files"},
{"GET", "/api/v1/files/1"},
{"GET", "/api/v1/files/1/download"},
{"DELETE", "/api/v1/files/1"},
{"POST", "/api/v1/files/folders"},
{"GET", "/api/v1/files/folders"},
{"GET", "/api/v1/files/folders/1"},
{"DELETE", "/api/v1/files/folders/1"},
{"POST", "/api/v1/tasks"},
{"GET", "/api/v1/tasks"},
}
if len(routes) != 31 {
t.Fatalf("expected 31 routes, got %d", len(routes))
}
for _, r := range routes {
req, err := http.NewRequest(r.method, env.srv.URL+r.path, nil)
if err != nil {
t.Fatalf("%s %s: create request: %v", r.method, r.path, err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", r.method, r.path, err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
// Gin returns 404 for unregistered routes WITHOUT a JSON body.
// Handler-level 404s return JSON with {"success":false,...}.
// So a 404 that decodes as valid JSON means the route IS registered.
if resp.StatusCode == http.StatusNotFound {
var apiResp struct {
Success bool `json:"success"`
}
if json.Unmarshal(body, &apiResp) != nil {
t.Errorf("%s %s: got router-level 404 (route not registered)", r.method, r.path)
}
}
}
}
func TestMakeTaskStale(t *testing.T) {
env := NewTestEnv(t)
ctx := t.Context()
task := &model.Task{
TaskName: "stale-test",
AppID: 1,
AppName: "test-app",
Status: model.TaskStatusSubmitted,
}
taskID, err := env.taskStore.Create(ctx, task)
if err != nil {
t.Fatalf("create task: %v", err)
}
before := time.Now()
if err := env.MakeTaskStale(taskID); err != nil {
t.Fatalf("MakeTaskStale: %v", err)
}
updated, err := env.taskStore.GetByID(ctx, taskID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if !updated.UpdatedAt.Before(before.Add(-25 * time.Second)) {
t.Errorf("expected updated_at to be >25s in the past, got %v (before=%v)", updated.UpdatedAt, before)
}
}
func TestUploadTestData(t *testing.T) {
env := NewTestEnv(t)
content := []byte("hello testenv")
fileID, blobID := env.UploadTestData("test.txt", content)
if fileID == 0 {
t.Fatal("fileID is 0")
}
if blobID == 0 {
t.Fatal("blobID is 0")
}
ctx := t.Context()
file, err := env.fileStore.GetByID(ctx, fileID)
if err != nil {
t.Fatalf("get file: %v", err)
}
if file == nil {
t.Fatal("file not found")
}
if file.Name != "test.txt" {
t.Errorf("expected name 'test.txt', got %q", file.Name)
}
blob, err := env.blobStore.GetBySHA256(ctx, file.BlobSHA256)
if err != nil {
t.Fatalf("get blob: %v", err)
}
if blob == nil {
t.Fatal("blob not found")
}
if blob.FileSize != int64(len(content)) {
t.Errorf("expected size %d, got %d", len(content), blob.FileSize)
}
obj, info, err := env.MockMinIO.GetObject(ctx, "files", blob.MinioKey, storage.GetOptions{})
if err != nil {
t.Fatalf("get object: %v", err)
}
defer obj.Close()
got, _ := io.ReadAll(obj)
if string(got) != string(content) {
t.Errorf("expected content %q, got %q", content, got)
}
if info.Size != int64(len(content)) {
t.Errorf("expected object size %d, got %d", len(content), info.Size)
}
}
func TestCreateApp(t *testing.T) {
env := NewTestEnv(t)
params := json.RawMessage(`[{"name":"cores","type":"integer","required":true}]`)
appID, err := env.CreateApp("test-app", "#!/bin/bash\necho hello", params)
if err != nil {
t.Fatalf("CreateApp: %v", err)
}
if appID == 0 {
t.Fatal("appID is 0")
}
ctx := t.Context()
app, err := env.appStore.GetByID(ctx, appID)
if err != nil {
t.Fatalf("get app: %v", err)
}
if app == nil {
t.Fatal("app not found")
}
if app.Name != "test-app" {
t.Errorf("expected name 'test-app', got %q", app.Name)
}
}
func TestDoRequest_SetsJSONContentType(t *testing.T) {
env := NewTestEnv(t)
resp := env.DoRequest("POST", "/api/v1/applications", nil)
defer resp.Body.Close()
if resp.StatusCode == 0 {
t.Fatal("no status code")
}
}
func TestDecodeResponse(t *testing.T) {
env := NewTestEnv(t)
resp := env.DoRequest("GET", "/api/v1/applications", nil)
defer resp.Body.Close()
success, data, err := env.DecodeResponse(resp)
if err != nil {
t.Fatalf("DecodeResponse: %v", err)
}
if !success {
t.Error("expected success=true for list applications")
}
if data == nil {
t.Error("expected non-nil data")
}
}