package service import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "gcy_hpc_server/internal/model" "gcy_hpc_server/internal/slurm" "go.uber.org/zap" "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest/observer" ) func mockJobServer(handler http.HandlerFunc) (*slurm.Client, func()) { srv := httptest.NewServer(handler) client, _ := slurm.NewClient(srv.URL, srv.Client()) return client, srv.Close } func TestSubmitJob(t *testing.T) { jobID := int32(123) client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/slurm/v0.0.40/job/submit" { t.Errorf("unexpected path: %s", r.URL.Path) } var body slurm.JobSubmitReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if body.Job == nil || body.Job.Script == nil || *body.Job.Script != "#!/bin/bash\necho hello" { t.Errorf("unexpected script in request body") } resp := slurm.OpenapiJobSubmitResponse{ Result: &slurm.JobSubmitResponseMsg{ JobID: &jobID, }, } json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) resp, err := svc.SubmitJob(context.Background(), &model.SubmitJobRequest{ Script: "#!/bin/bash\necho hello", Partition: "normal", QOS: "high", JobName: "test-job", CPUs: 4, TimeLimit: "60", }) if err != nil { t.Fatalf("SubmitJob: %v", err) } if resp.JobID != 123 { t.Errorf("expected JobID 123, got %d", resp.JobID) } } func TestSubmitJob_WithOptionalFields(t *testing.T) { client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body slurm.JobSubmitReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if body.Job == nil { t.Fatal("job desc is nil") } if body.Job.Partition != nil { t.Error("expected partition nil for empty string") } if body.Job.CpusPerTask != nil { t.Error("expected cpus_per_task nil when CPUs=0") } jobID := int32(456) resp := slurm.OpenapiJobSubmitResponse{ Result: &slurm.JobSubmitResponseMsg{JobID: &jobID}, } json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) resp, err := svc.SubmitJob(context.Background(), &model.SubmitJobRequest{ Script: "echo hi", }) if err != nil { t.Fatalf("SubmitJob: %v", err) } if resp.JobID != 456 { t.Errorf("expected JobID 456, got %d", resp.JobID) } } func TestSubmitJob_Error(t *testing.T) { client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":"internal"}`)) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) _, err := svc.SubmitJob(context.Background(), &model.SubmitJobRequest{ Script: "echo fail", }) if err == nil { t.Fatal("expected error, got nil") } } func TestGetJobs(t *testing.T) { jobID := int32(100) name := "my-job" partition := "gpu" ts := int64(1700000000) nodes := "node01" client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } resp := slurm.OpenapiJobInfoResp{ Jobs: slurm.JobInfoMsg{ { JobID: &jobID, Name: &name, JobState: []string{"RUNNING"}, Partition: &partition, SubmitTime: &slurm.Uint64NoVal{Number: &ts}, StartTime: &slurm.Uint64NoVal{Number: &ts}, EndTime: &slurm.Uint64NoVal{Number: &ts}, Nodes: &nodes, }, }, } json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) result, err := svc.GetJobs(context.Background(), &model.JobListQuery{Page: 1, PageSize: 20}) if err != nil { t.Fatalf("GetJobs: %v", err) } if result.Total != 1 { t.Fatalf("expected total 1, got %d", result.Total) } if len(result.Jobs) != 1 { t.Fatalf("expected 1 job, got %d", len(result.Jobs)) } j := result.Jobs[0] if j.JobID != 100 { t.Errorf("expected JobID 100, got %d", j.JobID) } if j.Name != "my-job" { t.Errorf("expected Name my-job, got %s", j.Name) } if len(j.State) != 1 || j.State[0] != "RUNNING" { t.Errorf("expected State [RUNNING], got %v", j.State) } if j.Partition != "gpu" { t.Errorf("expected Partition gpu, got %s", j.Partition) } if j.SubmitTime == nil || *j.SubmitTime != ts { t.Errorf("expected SubmitTime %d, got %v", ts, j.SubmitTime) } if j.Nodes != "node01" { t.Errorf("expected Nodes node01, got %s", j.Nodes) } if result.Page != 1 { t.Errorf("expected Page 1, got %d", result.Page) } if result.PageSize != 20 { t.Errorf("expected PageSize 20, got %d", result.PageSize) } } func TestGetJob(t *testing.T) { jobID := int32(200) name := "single-job" client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := slurm.OpenapiJobInfoResp{ Jobs: slurm.JobInfoMsg{ { JobID: &jobID, Name: &name, }, }, } json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) job, err := svc.GetJob(context.Background(), "200") if err != nil { t.Fatalf("GetJob: %v", err) } if job == nil { t.Fatal("expected job, got nil") } if job.JobID != 200 { t.Errorf("expected JobID 200, got %d", job.JobID) } } func TestGetJob_NotFound(t *testing.T) { client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := slurm.OpenapiJobInfoResp{Jobs: slurm.JobInfoMsg{}} json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) job, err := svc.GetJob(context.Background(), "999") if err != nil { t.Fatalf("GetJob: %v", err) } if job != nil { t.Errorf("expected nil for not found, got %+v", job) } } func TestCancelJob(t *testing.T) { client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { t.Errorf("expected DELETE, got %s", r.Method) } resp := slurm.OpenapiResp{} json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) err := svc.CancelJob(context.Background(), "300") if err != nil { t.Fatalf("CancelJob: %v", err) } } func TestCancelJob_Error(t *testing.T) { client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte(`not found`)) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) err := svc.CancelJob(context.Background(), "999") if err == nil { t.Fatal("expected error, got nil") } } func TestGetJobHistory(t *testing.T) { jobID1 := int32(10) jobID2 := int32(20) jobID3 := int32(30) name1 := "hist-1" name2 := "hist-2" name3 := "hist-3" submission1 := int64(1700000000) submission2 := int64(1700001000) submission3 := int64(1700002000) partition := "normal" client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { t.Errorf("expected GET, got %s", r.Method) } users := r.URL.Query().Get("users") if users != "testuser" { t.Errorf("expected users=testuser, got %s", users) } resp := slurm.OpenapiSlurmdbdJobsResp{ Jobs: slurm.JobList{ { JobID: &jobID1, Name: &name1, Partition: &partition, State: &slurm.JobState{Current: []string{"COMPLETED"}}, Time: &slurm.JobTime{Submission: &submission1}, }, { JobID: &jobID2, Name: &name2, Partition: &partition, State: &slurm.JobState{Current: []string{"FAILED"}}, Time: &slurm.JobTime{Submission: &submission2}, }, { JobID: &jobID3, Name: &name3, Partition: &partition, State: &slurm.JobState{Current: []string{"CANCELLED"}}, Time: &slurm.JobTime{Submission: &submission3}, }, }, } json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) result, err := svc.GetJobHistory(context.Background(), &model.JobHistoryQuery{ Users: "testuser", Page: 1, PageSize: 2, }) if err != nil { t.Fatalf("GetJobHistory: %v", err) } if result.Total != 3 { t.Errorf("expected Total 3, got %d", result.Total) } if result.Page != 1 { t.Errorf("expected Page 1, got %d", result.Page) } if result.PageSize != 2 { t.Errorf("expected PageSize 2, got %d", result.PageSize) } if len(result.Jobs) != 2 { t.Fatalf("expected 2 jobs on page 1, got %d", len(result.Jobs)) } if result.Jobs[0].JobID != 10 { t.Errorf("expected first job ID 10, got %d", result.Jobs[0].JobID) } if result.Jobs[1].JobID != 20 { t.Errorf("expected second job ID 20, got %d", result.Jobs[1].JobID) } if len(result.Jobs[0].State) != 1 || result.Jobs[0].State[0] != "COMPLETED" { t.Errorf("expected state [COMPLETED], got %v", result.Jobs[0].State) } } func TestGetJobHistory_Page2(t *testing.T) { jobID1 := int32(10) jobID2 := int32(20) name1 := "a" name2 := "b" client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := slurm.OpenapiSlurmdbdJobsResp{ Jobs: slurm.JobList{ {JobID: &jobID1, Name: &name1}, {JobID: &jobID2, Name: &name2}, }, } json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) result, err := svc.GetJobHistory(context.Background(), &model.JobHistoryQuery{ Page: 2, PageSize: 1, }) if err != nil { t.Fatalf("GetJobHistory: %v", err) } if result.Total != 2 { t.Errorf("expected Total 2, got %d", result.Total) } if len(result.Jobs) != 1 { t.Fatalf("expected 1 job on page 2, got %d", len(result.Jobs)) } if result.Jobs[0].JobID != 20 { t.Errorf("expected job ID 20, got %d", result.Jobs[0].JobID) } } func TestGetJobHistory_DefaultPagination(t *testing.T) { client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := slurm.OpenapiSlurmdbdJobsResp{Jobs: slurm.JobList{}} json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) result, err := svc.GetJobHistory(context.Background(), &model.JobHistoryQuery{}) if err != nil { t.Fatalf("GetJobHistory: %v", err) } if result.Page != 1 { t.Errorf("expected default page 1, got %d", result.Page) } if result.PageSize != 20 { t.Errorf("expected default pageSize 20, got %d", result.PageSize) } } func TestGetJobHistory_QueryMapping(t *testing.T) { client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() if v := q.Get("account"); v != "proj1" { t.Errorf("expected account=proj1, got %s", v) } if v := q.Get("partition"); v != "gpu" { t.Errorf("expected partition=gpu, got %s", v) } if v := q.Get("state"); v != "COMPLETED" { t.Errorf("expected state=COMPLETED, got %s", v) } if v := q.Get("job_name"); v != "myjob" { t.Errorf("expected job_name=myjob, got %s", v) } if v := q.Get("start_time"); v != "1700000000" { t.Errorf("expected start_time=1700000000, got %s", v) } if v := q.Get("end_time"); v != "1700099999" { t.Errorf("expected end_time=1700099999, got %s", v) } resp := slurm.OpenapiSlurmdbdJobsResp{Jobs: slurm.JobList{}} json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) _, err := svc.GetJobHistory(context.Background(), &model.JobHistoryQuery{ Users: "testuser", Account: "proj1", Partition: "gpu", State: "COMPLETED", JobName: "myjob", StartTime: "1700000000", EndTime: "1700099999", }) if err != nil { t.Fatalf("GetJobHistory: %v", err) } } func TestGetJobHistory_Error(t *testing.T) { client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":"db down"}`)) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) _, err := svc.GetJobHistory(context.Background(), &model.JobHistoryQuery{}) if err == nil { t.Fatal("expected error, got nil") } } func TestMapJobInfo_ExitCode(t *testing.T) { returnCode := int64(2) ji := &slurm.JobInfo{ ExitCode: &slurm.ProcessExitCodeVerbose{ ReturnCode: &slurm.Uint32NoVal{Number: &returnCode}, }, } resp := mapJobInfo(ji) if resp.ExitCode == nil || *resp.ExitCode != 2 { t.Errorf("expected exit code 2, got %v", resp.ExitCode) } } func TestMapSlurmdbJob_NilFields(t *testing.T) { j := &slurm.Job{} resp := mapSlurmdbJob(j) if resp.JobID != 0 { t.Errorf("expected JobID 0, got %d", resp.JobID) } if resp.State != nil { t.Errorf("expected nil State, got %v", resp.State) } if resp.SubmitTime != nil { t.Errorf("expected nil SubmitTime, got %v", resp.SubmitTime) } } // --------------------------------------------------------------------------- // Structured logging tests using zaptest/observer // --------------------------------------------------------------------------- func newJobServiceWithObserver(srv *httptest.Server) (*JobService, *observer.ObservedLogs) { core, recorded := observer.New(zapcore.DebugLevel) l := zap.New(core) client, _ := slurm.NewClient(srv.URL, srv.Client()) return NewJobService(client, l), recorded } func TestJobService_SubmitJob_SuccessLog(t *testing.T) { jobID := int32(789) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := slurm.OpenapiJobSubmitResponse{ Result: &slurm.JobSubmitResponseMsg{JobID: &jobID}, } json.NewEncoder(w).Encode(resp) })) defer srv.Close() svc, recorded := newJobServiceWithObserver(srv) _, err := svc.SubmitJob(context.Background(), &model.SubmitJobRequest{ Script: "echo hi", JobName: "log-test-job", }) if err != nil { t.Fatalf("unexpected error: %v", err) } entries := recorded.All() if len(entries) != 3 { t.Fatalf("expected 3 log entries, got %d", len(entries)) } if entries[2].Level != zapcore.InfoLevel { t.Errorf("expected InfoLevel, got %v", entries[2].Level) } fields := entries[2].ContextMap() if fields["job_name"] != "log-test-job" { t.Errorf("expected job_name=log-test-job, got %v", fields["job_name"]) } gotJobID, ok := fields["job_id"] if !ok { t.Fatal("expected job_id field in log entry") } if gotJobID != int32(789) && gotJobID != int64(789) { t.Errorf("expected job_id=789, got %v (%T)", gotJobID, gotJobID) } } func TestJobService_SubmitJob_ErrorLog(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":"internal"}`)) })) defer srv.Close() svc, recorded := newJobServiceWithObserver(srv) _, err := svc.SubmitJob(context.Background(), &model.SubmitJobRequest{Script: "echo fail"}) if err == nil { t.Fatal("expected error, got nil") } entries := recorded.All() if len(entries) != 3 { t.Fatalf("expected 3 log entries, got %d", len(entries)) } if entries[2].Level != zapcore.ErrorLevel { t.Errorf("expected ErrorLevel, got %v", entries[2].Level) } fields := entries[2].ContextMap() if fields["operation"] != "submit" { t.Errorf("expected operation=submit, got %v", fields["operation"]) } if _, ok := fields["error"]; !ok { t.Error("expected error field in log entry") } } func TestJobService_CancelJob_SuccessLog(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := slurm.OpenapiResp{} json.NewEncoder(w).Encode(resp) })) defer srv.Close() svc, recorded := newJobServiceWithObserver(srv) err := svc.CancelJob(context.Background(), "555") if err != nil { t.Fatalf("unexpected error: %v", err) } entries := recorded.All() if len(entries) != 3 { t.Fatalf("expected 3 log entries, got %d", len(entries)) } if entries[2].Level != zapcore.InfoLevel { t.Errorf("expected InfoLevel, got %v", entries[2].Level) } fields := entries[2].ContextMap() if fields["job_id"] != "555" { t.Errorf("expected job_id=555, got %v", fields["job_id"]) } } func TestJobService_CancelJob_ErrorLog(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) w.Write([]byte(`not found`)) })) defer srv.Close() svc, recorded := newJobServiceWithObserver(srv) err := svc.CancelJob(context.Background(), "999") if err == nil { t.Fatal("expected error, got nil") } entries := recorded.All() if len(entries) != 3 { t.Fatalf("expected 3 log entries, got %d", len(entries)) } if entries[2].Level != zapcore.ErrorLevel { t.Errorf("expected ErrorLevel, got %v", entries[2].Level) } fields := entries[2].ContextMap() if fields["operation"] != "cancel" { t.Errorf("expected operation=cancel, got %v", fields["operation"]) } if fields["job_id"] != "999" { t.Errorf("expected job_id=999, got %v", fields["job_id"]) } if _, ok := fields["error"]; !ok { t.Error("expected error field in log entry") } } func TestJobService_GetJobs_ErrorLog(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":"down"}`)) })) defer srv.Close() svc, recorded := newJobServiceWithObserver(srv) _, err := svc.GetJobs(context.Background(), &model.JobListQuery{Page: 1, PageSize: 20}) if err == nil { t.Fatal("expected error, got nil") } entries := recorded.All() if len(entries) != 3 { t.Fatalf("expected 3 log entries, got %d", len(entries)) } if entries[2].Level != zapcore.ErrorLevel { t.Errorf("expected ErrorLevel, got %v", entries[2].Level) } fields := entries[2].ContextMap() if fields["operation"] != "get_jobs" { t.Errorf("expected operation=get_jobs, got %v", fields["operation"]) } if _, ok := fields["error"]; !ok { t.Error("expected error field in log entry") } } func TestJobService_GetJob_ErrorLog(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":"down"}`)) })) defer srv.Close() svc, recorded := newJobServiceWithObserver(srv) _, err := svc.GetJob(context.Background(), "200") if err == nil { t.Fatal("expected error, got nil") } entries := recorded.All() if len(entries) != 3 { t.Fatalf("expected 3 log entries, got %d", len(entries)) } if entries[2].Level != zapcore.ErrorLevel { t.Errorf("expected ErrorLevel, got %v", entries[2].Level) } fields := entries[2].ContextMap() if fields["operation"] != "get_job" { t.Errorf("expected operation=get_job, got %v", fields["operation"]) } if fields["job_id"] != "200" { t.Errorf("expected job_id=200, got %v", fields["job_id"]) } if _, ok := fields["error"]; !ok { t.Error("expected error field in log entry") } } func TestJobService_GetJobHistory_ErrorLog(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"error":"db down"}`)) })) defer srv.Close() svc, recorded := newJobServiceWithObserver(srv) _, err := svc.GetJobHistory(context.Background(), &model.JobHistoryQuery{}) if err == nil { t.Fatal("expected error, got nil") } entries := recorded.All() if len(entries) != 3 { t.Fatalf("expected 3 log entries, got %d", len(entries)) } if entries[2].Level != zapcore.ErrorLevel { t.Errorf("expected ErrorLevel, got %v", entries[2].Level) } fields := entries[2].ContextMap() if fields["operation"] != "get_job_history" { t.Errorf("expected operation=get_job_history, got %v", fields["operation"]) } if _, ok := fields["error"]; !ok { t.Error("expected error field in log entry") } } // --------------------------------------------------------------------------- // Fallback to SlurmDBD history tests // --------------------------------------------------------------------------- func TestGetJob_FallbackToHistory_Found(t *testing.T) { jobID := int32(198) name := "hist-job" ts := int64(1700000000) client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/slurm/v0.0.40/job/198": w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]interface{}{ "errors": []map[string]interface{}{ { "description": "Unable to query JobId=198", "error_number": float64(2017), "error": "Invalid job id specified", "source": "_handle_job_get", }, }, "jobs": []interface{}{}, }) case "/slurmdb/v0.0.40/job/198": resp := slurm.OpenapiSlurmdbdJobsResp{ Jobs: slurm.JobList{ { JobID: &jobID, Name: &name, State: &slurm.JobState{Current: []string{"COMPLETED"}}, Time: &slurm.JobTime{Submission: &ts, Start: &ts, End: &ts}, }, }, } json.NewEncoder(w).Encode(resp) default: w.WriteHeader(http.StatusNotFound) } })) defer cleanup() svc := NewJobService(client, zap.NewNop()) job, err := svc.GetJob(context.Background(), "198") if err != nil { t.Fatalf("GetJob: %v", err) } if job == nil { t.Fatal("expected job, got nil") } if job.JobID != 198 { t.Errorf("expected JobID 198, got %d", job.JobID) } if job.Name != "hist-job" { t.Errorf("expected Name hist-job, got %s", job.Name) } } func TestGetJob_FallbackToHistory_NotFound(t *testing.T) { client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusNotFound) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) job, err := svc.GetJob(context.Background(), "999") if err != nil { t.Fatalf("GetJob: %v", err) } if job != nil { t.Errorf("expected nil, got %+v", job) } } func TestGetJob_FallbackToHistory_HistoryError(t *testing.T) { client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/slurm/v0.0.40/job/500": w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]interface{}{ "errors": []map[string]interface{}{ { "description": "Unable to query JobId=500", "error_number": float64(2017), "error": "Invalid job id specified", "source": "_handle_job_get", }, }, "jobs": []interface{}{}, }) case "/slurmdb/v0.0.40/job/500": w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(`{"errors":[{"error":"db error"}]}`)) default: w.WriteHeader(http.StatusNotFound) } })) defer cleanup() svc := NewJobService(client, zap.NewNop()) job, err := svc.GetJob(context.Background(), "500") if err == nil { t.Fatal("expected error, got nil") } if job != nil { t.Errorf("expected nil job, got %+v", job) } if !strings.Contains(err.Error(), "get job history") { t.Errorf("expected error to contain 'get job history', got %s", err.Error()) } } // --------------------------------------------------------------------------- // New scheduling field mapping tests // --------------------------------------------------------------------------- func TestSubmitJob_AllSchedulingFields(t *testing.T) { jobID := int32(999) // Prepare all 22 new scheduling field values var ( memoryPerNode = int64(4096) memoryPerCpu = int64(1024) nodes = "2" tasks = int32(4) cpusPerTask = int32(2) constraints = "gpu&fast" reservation = "resv01" account = "proj-alpha" nice = int32(100) mailType = "BEGIN,END,FAIL" mailUser = "admin@example.com" stdOut = "/tmp/job_%j.out" stdErr = "/tmp/job_%j.err" stdIn = "/dev/null" reqNodes = "node01,node02" exclNodes = "node03,node04" beginTime = int64(1700000000) deadline = int64(1700099999) array = "1-10" dependency = "afterok:123" requeue = true killOnNodeFail = true ) client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body slurm.JobSubmitReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if body.Job == nil { t.Fatal("job desc is nil") } j := body.Job // --- Existing fields still work --- if j.Script == nil || *j.Script != "#!/bin/bash\necho test" { t.Errorf("Script mismatch: %v", j.Script) } if j.Partition == nil || *j.Partition != "normal" { t.Errorf("Partition mismatch: %v", j.Partition) } if j.Qos == nil || *j.Qos != "high" { t.Errorf("QOS mismatch: %v", j.Qos) } if j.Name == nil || *j.Name != "full-test" { t.Errorf("Name mismatch: %v", j.Name) } // CPUs=8 maps to CpusPerTask, then overridden by explicit CpusPerTask=2 if j.CpusPerTask == nil || *j.CpusPerTask != cpusPerTask { t.Errorf("CpusPerTask mismatch: got %v, want %d (explicit CpusPerTask overrides CPUs)", j.CpusPerTask, cpusPerTask) } if j.MinimumCpus != nil { t.Errorf("MinimumCpus should be nil, got %v", j.MinimumCpus) } // --- 22 new scheduling fields --- // MemoryPerNode → *Uint64NoVal if j.MemoryPerNode == nil || j.MemoryPerNode.Set == nil || !*j.MemoryPerNode.Set || j.MemoryPerNode.Number == nil || *j.MemoryPerNode.Number != memoryPerNode { t.Errorf("MemoryPerNode mismatch: %v", j.MemoryPerNode) } // MemoryPerCpu → *Uint64NoVal if j.MemoryPerCpu == nil || j.MemoryPerCpu.Set == nil || !*j.MemoryPerCpu.Set || j.MemoryPerCpu.Number == nil || *j.MemoryPerCpu.Number != memoryPerCpu { t.Errorf("MemoryPerCpu mismatch: %v", j.MemoryPerCpu) } // Nodes → *string if j.Nodes == nil || *j.Nodes != nodes { t.Errorf("Nodes mismatch: %v", j.Nodes) } // Tasks → *int32 if j.Tasks == nil || *j.Tasks != tasks { t.Errorf("Tasks mismatch: got %v, want %d", j.Tasks, tasks) } // CpusPerTask → *int32 if j.CpusPerTask == nil || *j.CpusPerTask != cpusPerTask { t.Errorf("CpusPerTask mismatch: got %v, want %d", j.CpusPerTask, cpusPerTask) } // Constraints → *string if j.Constraints == nil || *j.Constraints != constraints { t.Errorf("Constraints mismatch: %v", j.Constraints) } // Reservation → *string if j.Reservation == nil || *j.Reservation != reservation { t.Errorf("Reservation mismatch: %v", j.Reservation) } // Account → *string if j.Account == nil || *j.Account != account { t.Errorf("Account mismatch: %v", j.Account) } // Nice → *int32 if j.Nice == nil || *j.Nice != nice { t.Errorf("Nice mismatch: got %v, want %d", j.Nice, nice) } // MailType → []string (comma-split) if len(j.MailType) != 3 || j.MailType[0] != "BEGIN" || j.MailType[1] != "END" || j.MailType[2] != "FAIL" { t.Errorf("MailType mismatch: %v", j.MailType) } // MailUser → *string if j.MailUser == nil || *j.MailUser != mailUser { t.Errorf("MailUser mismatch: %v", j.MailUser) } // StandardOutput → *string if j.StandardOutput == nil || *j.StandardOutput != stdOut { t.Errorf("StandardOutput mismatch: %v", j.StandardOutput) } // StandardError → *string if j.StandardError == nil || *j.StandardError != stdErr { t.Errorf("StandardError mismatch: %v", j.StandardError) } // StandardInput → *string if j.StandardInput == nil || *j.StandardInput != stdIn { t.Errorf("StandardInput mismatch: %v", j.StandardInput) } // RequiredNodes → CSVString ([]string) if len(j.RequiredNodes) != 2 || j.RequiredNodes[0] != "node01" || j.RequiredNodes[1] != "node02" { t.Errorf("RequiredNodes mismatch: %v", j.RequiredNodes) } // ExcludedNodes → CSVString ([]string) if len(j.ExcludedNodes) != 2 || j.ExcludedNodes[0] != "node03" || j.ExcludedNodes[1] != "node04" { t.Errorf("ExcludedNodes mismatch: %v", j.ExcludedNodes) } // BeginTime → *Uint64NoVal if j.BeginTime == nil || j.BeginTime.Number == nil || *j.BeginTime.Number != beginTime { t.Errorf("BeginTime mismatch: %v", j.BeginTime) } // Deadline → *int64 (NO wrapper) if j.Deadline == nil || *j.Deadline != deadline { t.Errorf("Deadline mismatch: %v", j.Deadline) } // Array → *string if j.Array == nil || *j.Array != array { t.Errorf("Array mismatch: %v", j.Array) } // Dependency → *string if j.Dependency == nil || *j.Dependency != dependency { t.Errorf("Dependency mismatch: %v", j.Dependency) } // Requeue → *bool if j.Requeue == nil || *j.Requeue != requeue { t.Errorf("Requeue mismatch: %v", j.Requeue) } // KillOnNodeFail → *bool if j.KillOnNodeFail == nil || *j.KillOnNodeFail != killOnNodeFail { t.Errorf("KillOnNodeFail mismatch: %v", j.KillOnNodeFail) } resp := slurm.OpenapiJobSubmitResponse{ Result: &slurm.JobSubmitResponseMsg{JobID: &jobID}, } json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) resp, err := svc.SubmitJob(context.Background(), &model.SubmitJobRequest{ Script: "#!/bin/bash\necho test", Partition: "normal", QOS: "high", JobName: "full-test", CPUs: 8, TimeLimit: "60", MemoryPerNode: &memoryPerNode, MemoryPerCpu: &memoryPerCpu, Nodes: &nodes, Tasks: &tasks, CpusPerTask: &cpusPerTask, Constraints: &constraints, Reservation: &reservation, Account: &account, Nice: &nice, MailType: &mailType, MailUser: &mailUser, StandardOutput: &stdOut, StandardError: &stdErr, StandardInput: &stdIn, RequiredNodes: &reqNodes, ExcludedNodes: &exclNodes, BeginTime: &beginTime, Deadline: &deadline, Array: &array, Dependency: &dependency, Requeue: &requeue, KillOnNodeFail: &killOnNodeFail, }) if err != nil { t.Fatalf("SubmitJob: %v", err) } if resp.JobID != 999 { t.Errorf("expected JobID 999, got %d", resp.JobID) } } func TestSubmitJob_BackwardCompat(t *testing.T) { jobID := int32(555) client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body slurm.JobSubmitReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if body.Job == nil { t.Fatal("job desc is nil") } j := body.Job // Existing fields: Script and WorkDir should be set if j.Script == nil || *j.Script != "echo hi" { t.Errorf("Script mismatch: %v", j.Script) } if j.CurrentWorkingDirectory == nil || *j.CurrentWorkingDirectory != "/home/user" { t.Errorf("CurrentWorkingDirectory mismatch: %v", j.CurrentWorkingDirectory) } // All new scheduling fields should be nil/empty if j.MemoryPerNode != nil { t.Errorf("MemoryPerNode should be nil, got %v", j.MemoryPerNode) } if j.MemoryPerCpu != nil { t.Errorf("MemoryPerCpu should be nil, got %v", j.MemoryPerCpu) } if j.Nodes != nil { t.Errorf("Nodes should be nil, got %v", j.Nodes) } if j.Tasks != nil { t.Errorf("Tasks should be nil, got %v", j.Tasks) } if j.CpusPerTask != nil { t.Errorf("CpusPerTask should be nil, got %v", j.CpusPerTask) } if j.Constraints != nil { t.Errorf("Constraints should be nil, got %v", j.Constraints) } if j.Reservation != nil { t.Errorf("Reservation should be nil, got %v", j.Reservation) } if j.Account != nil { t.Errorf("Account should be nil, got %v", j.Account) } if j.Nice != nil { t.Errorf("Nice should be nil, got %v", j.Nice) } if len(j.MailType) != 0 { t.Errorf("MailType should be empty, got %v", j.MailType) } if j.MailUser != nil { t.Errorf("MailUser should be nil, got %v", j.MailUser) } if j.StandardOutput != nil { t.Errorf("StandardOutput should be nil, got %v", j.StandardOutput) } if j.StandardError != nil { t.Errorf("StandardError should be nil, got %v", j.StandardError) } if j.StandardInput != nil { t.Errorf("StandardInput should be nil, got %v", j.StandardInput) } if len(j.RequiredNodes) != 0 { t.Errorf("RequiredNodes should be empty, got %v", j.RequiredNodes) } if len(j.ExcludedNodes) != 0 { t.Errorf("ExcludedNodes should be empty, got %v", j.ExcludedNodes) } if j.BeginTime != nil { t.Errorf("BeginTime should be nil, got %v", j.BeginTime) } if j.Deadline != nil { t.Errorf("Deadline should be nil, got %v", j.Deadline) } if j.Array != nil { t.Errorf("Array should be nil, got %v", j.Array) } if j.Dependency != nil { t.Errorf("Dependency should be nil, got %v", j.Dependency) } if j.Requeue != nil { t.Errorf("Requeue should be nil, got %v", j.Requeue) } if j.KillOnNodeFail != nil { t.Errorf("KillOnNodeFail should be nil, got %v", j.KillOnNodeFail) } resp := slurm.OpenapiJobSubmitResponse{ Result: &slurm.JobSubmitResponseMsg{JobID: &jobID}, } json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) resp, err := svc.SubmitJob(context.Background(), &model.SubmitJobRequest{ Script: "echo hi", WorkDir: "/home/user", }) if err != nil { t.Fatalf("SubmitJob: %v", err) } if resp.JobID != 555 { t.Errorf("expected JobID 555, got %d", resp.JobID) } } func TestSubmitJob_MemoryBothSet(t *testing.T) { jobID := int32(777) memoryPerNode := int64(4096) memoryPerCpu := int64(1024) client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body slurm.JobSubmitReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) } if body.Job == nil { t.Fatal("job desc is nil") } j := body.Job // Both memory fields should be mapped independently if j.MemoryPerNode == nil || j.MemoryPerNode.Set == nil || !*j.MemoryPerNode.Set || j.MemoryPerNode.Number == nil || *j.MemoryPerNode.Number != memoryPerNode { t.Errorf("MemoryPerNode mismatch: %v", j.MemoryPerNode) } if j.MemoryPerCpu == nil || j.MemoryPerCpu.Set == nil || !*j.MemoryPerCpu.Set || j.MemoryPerCpu.Number == nil || *j.MemoryPerCpu.Number != memoryPerCpu { t.Errorf("MemoryPerCpu mismatch: %v", j.MemoryPerCpu) } resp := slurm.OpenapiJobSubmitResponse{ Result: &slurm.JobSubmitResponseMsg{JobID: &jobID}, } json.NewEncoder(w).Encode(resp) })) defer cleanup() svc := NewJobService(client, zap.NewNop()) resp, err := svc.SubmitJob(context.Background(), &model.SubmitJobRequest{ Script: "echo mem", MemoryPerNode: &memoryPerNode, MemoryPerCpu: &memoryPerCpu, }) if err != nil { t.Fatalf("SubmitJob: %v", err) } if resp.JobID != 777 { t.Errorf("expected JobID 777, got %d", resp.JobID) } } func TestGetJob_FallbackToHistory_EmptyHistory(t *testing.T) { client, cleanup := mockJobServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/slurm/v0.0.40/job/777": w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]interface{}{ "errors": []map[string]interface{}{ { "description": "Unable to query JobId=777", "error_number": float64(2017), "error": "Invalid job id specified", "source": "_handle_job_get", }, }, "jobs": []interface{}{}, }) case "/slurmdb/v0.0.40/job/777": resp := slurm.OpenapiSlurmdbdJobsResp{Jobs: slurm.JobList{}} json.NewEncoder(w).Encode(resp) default: w.WriteHeader(http.StatusNotFound) } })) defer cleanup() svc := NewJobService(client, zap.NewNop()) job, err := svc.GetJob(context.Background(), "777") if err != nil { t.Fatalf("GetJob: %v", err) } if job != nil { t.Errorf("expected nil, got %+v", job) } }