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:
261
cmd/server/integration_task_test.go
Normal file
261
cmd/server/integration_task_test.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gcy_hpc_server/internal/testutil/testenv"
|
||||
)
|
||||
|
||||
// taskAPIResponse decodes the unified API response envelope.
|
||||
type taskAPIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// taskCreateData is the data payload from a successful task creation.
|
||||
type taskCreateData struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// taskListData is the data payload from listing tasks.
|
||||
type taskListData struct {
|
||||
Items []taskListItem `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
type taskListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
TaskName string `json:"task_name"`
|
||||
AppID int64 `json:"app_id"`
|
||||
Status string `json:"status"`
|
||||
SlurmJobID *int32 `json:"slurm_job_id"`
|
||||
}
|
||||
|
||||
// taskSendReq sends an HTTP request via the test env and returns the response.
|
||||
func taskSendReq(t *testing.T, env *testenv.TestEnv, method, path string, body string) *http.Response {
|
||||
t.Helper()
|
||||
var r io.Reader
|
||||
if body != "" {
|
||||
r = strings.NewReader(body)
|
||||
}
|
||||
resp := env.DoRequest(method, path, r)
|
||||
return resp
|
||||
}
|
||||
|
||||
// taskParseResp decodes the response body into a taskAPIResponse.
|
||||
func taskParseResp(t *testing.T, resp *http.Response) taskAPIResponse {
|
||||
t.Helper()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read response body: %v", err)
|
||||
}
|
||||
var result taskAPIResponse
|
||||
if err := json.Unmarshal(b, &result); err != nil {
|
||||
t.Fatalf("unmarshal response: %v (body: %s)", err, string(b))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// taskCreateViaAPI creates a task via the HTTP API and returns the task ID.
|
||||
func taskCreateViaAPI(t *testing.T, env *testenv.TestEnv, appID int64, taskName string) int64 {
|
||||
t.Helper()
|
||||
body := fmt.Sprintf(`{"app_id":%d,"task_name":"%s","values":{},"file_ids":[]}`, appID, taskName)
|
||||
resp := taskSendReq(t, env, http.MethodPost, "/api/v1/tasks", body)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 201, got %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
parsed := taskParseResp(t, resp)
|
||||
if !parsed.Success {
|
||||
t.Fatalf("expected success=true, got error: %s", parsed.Error)
|
||||
}
|
||||
|
||||
var data taskCreateData
|
||||
if err := json.Unmarshal(parsed.Data, &data); err != nil {
|
||||
t.Fatalf("unmarshal create data: %v", err)
|
||||
}
|
||||
if data.ID == 0 {
|
||||
t.Fatal("expected non-zero task ID")
|
||||
}
|
||||
return data.ID
|
||||
}
|
||||
|
||||
// ---------- Tests ----------
|
||||
|
||||
func TestIntegration_Task_Create(t *testing.T) {
|
||||
env := testenv.NewTestEnv(t)
|
||||
|
||||
// Create application
|
||||
appID, err := env.CreateApp("task-create-app", "#!/bin/bash\necho hello", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create app: %v", err)
|
||||
}
|
||||
|
||||
// Create task via API
|
||||
taskID := taskCreateViaAPI(t, env, appID, "test-task-create")
|
||||
|
||||
// Verify the task ID is positive
|
||||
if taskID <= 0 {
|
||||
t.Fatalf("expected positive task ID, got %d", taskID)
|
||||
}
|
||||
|
||||
// Wait briefly for async processing, then verify task exists in DB via list
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
resp := taskSendReq(t, env, http.MethodGet, "/api/v1/tasks", "")
|
||||
defer resp.Body.Close()
|
||||
parsed := taskParseResp(t, resp)
|
||||
|
||||
var listData taskListData
|
||||
if err := json.Unmarshal(parsed.Data, &listData); err != nil {
|
||||
t.Fatalf("unmarshal list data: %v", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, item := range listData.Items {
|
||||
if item.ID == taskID {
|
||||
found = true
|
||||
if item.TaskName != "test-task-create" {
|
||||
t.Errorf("expected task_name=test-task-create, got %s", item.TaskName)
|
||||
}
|
||||
if item.AppID != appID {
|
||||
t.Errorf("expected app_id=%d, got %d", appID, item.AppID)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("task %d not found in list", taskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Task_List(t *testing.T) {
|
||||
env := testenv.NewTestEnv(t)
|
||||
|
||||
// Create application
|
||||
appID, err := env.CreateApp("task-list-app", "#!/bin/bash\necho hello", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create app: %v", err)
|
||||
}
|
||||
|
||||
// Create 3 tasks
|
||||
taskCreateViaAPI(t, env, appID, "list-task-1")
|
||||
taskCreateViaAPI(t, env, appID, "list-task-2")
|
||||
taskCreateViaAPI(t, env, appID, "list-task-3")
|
||||
|
||||
// Allow async processing
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// List tasks
|
||||
resp := taskSendReq(t, env, http.MethodGet, "/api/v1/tasks", "")
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, string(b))
|
||||
}
|
||||
|
||||
parsed := taskParseResp(t, resp)
|
||||
if !parsed.Success {
|
||||
t.Fatalf("expected success, got error: %s", parsed.Error)
|
||||
}
|
||||
|
||||
var listData taskListData
|
||||
if err := json.Unmarshal(parsed.Data, &listData); err != nil {
|
||||
t.Fatalf("unmarshal list data: %v", err)
|
||||
}
|
||||
|
||||
if listData.Total < 3 {
|
||||
t.Fatalf("expected at least 3 tasks, got %d", listData.Total)
|
||||
}
|
||||
|
||||
// Verify each created task has required fields
|
||||
for _, item := range listData.Items {
|
||||
if item.ID == 0 {
|
||||
t.Error("expected non-zero ID")
|
||||
}
|
||||
if item.Status == "" {
|
||||
t.Error("expected non-empty status")
|
||||
}
|
||||
if item.AppID == 0 {
|
||||
t.Error("expected non-zero app_id")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Task_PollerLifecycle(t *testing.T) {
|
||||
env := testenv.NewTestEnv(t)
|
||||
|
||||
// 1. Create application
|
||||
appID, err := env.CreateApp("poller-lifecycle-app", "#!/bin/bash\necho hello", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create app: %v", err)
|
||||
}
|
||||
|
||||
// 2. Submit task via API
|
||||
taskID := taskCreateViaAPI(t, env, appID, "poller-lifecycle-task")
|
||||
|
||||
// 3. Wait for queued — TaskProcessor submits to MockSlurm asynchronously.
|
||||
// Intermediate states (submitted→preparing→downloading→ready→queued) are
|
||||
// non-deterministic; only assert the final "queued" state.
|
||||
if err := env.WaitForTaskStatus(taskID, "queued", 5*time.Second); err != nil {
|
||||
t.Fatalf("wait for queued: %v", err)
|
||||
}
|
||||
|
||||
// 4. Get slurm job ID from DB (not returned by API)
|
||||
slurmJobID, err := env.GetTaskSlurmJobID(taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("get slurm job id: %v", err)
|
||||
}
|
||||
|
||||
// 5. Transition: queued → running
|
||||
// ORDER IS CRITICAL: SetJobState BEFORE MakeTaskStale
|
||||
env.MockSlurm.SetJobState(slurmJobID, "RUNNING")
|
||||
if err := env.MakeTaskStale(taskID); err != nil {
|
||||
t.Fatalf("make task stale (running): %v", err)
|
||||
}
|
||||
if err := env.WaitForTaskStatus(taskID, "running", 5*time.Second); err != nil {
|
||||
t.Fatalf("wait for running: %v", err)
|
||||
}
|
||||
|
||||
// 6. Transition: running → completed
|
||||
// ORDER IS CRITICAL: SetJobState BEFORE MakeTaskStale
|
||||
env.MockSlurm.SetJobState(slurmJobID, "COMPLETED")
|
||||
if err := env.MakeTaskStale(taskID); err != nil {
|
||||
t.Fatalf("make task stale (completed): %v", err)
|
||||
}
|
||||
if err := env.WaitForTaskStatus(taskID, "completed", 5*time.Second); err != nil {
|
||||
t.Fatalf("wait for completed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Task_Validation(t *testing.T) {
|
||||
env := testenv.NewTestEnv(t)
|
||||
|
||||
// Missing required app_id
|
||||
resp := taskSendReq(t, env, http.MethodPost, "/api/v1/tasks", `{"task_name":"no-app-id"}`)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for missing app_id, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
parsed := taskParseResp(t, resp)
|
||||
if parsed.Success {
|
||||
t.Fatal("expected success=false for validation error")
|
||||
}
|
||||
if parsed.Error == "" {
|
||||
t.Error("expected non-empty error message")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user