feat: 添加 HTTP 处理层和结构化日志
- JobHandler: 提交/查询/取消/历史,5xx Error + 4xx Warn 日志 - ClusterHandler: 节点/分区/诊断,错误和未找到日志 - TemplateHandler: CRUD 操作,创建/更新/删除 Info + 未找到 Warn - 不记录成功响应(由 middleware.RequestLogger 处理) - 不记录请求体和模板内容(安全考虑) - 完整 TDD 测试,使用 zaptest/observer 验证日志级别和字段 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
94
internal/handler/cluster.go
Normal file
94
internal/handler/cluster.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gcy_hpc_server/internal/server"
|
||||||
|
"gcy_hpc_server/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClusterHandler handles HTTP requests for cluster operations (nodes, partitions, diag).
|
||||||
|
type ClusterHandler struct {
|
||||||
|
clusterSvc *service.ClusterService
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClusterHandler creates a new ClusterHandler with the given ClusterService.
|
||||||
|
func NewClusterHandler(clusterSvc *service.ClusterService, logger *zap.Logger) *ClusterHandler {
|
||||||
|
return &ClusterHandler{clusterSvc: clusterSvc, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNodes handles GET /api/v1/nodes.
|
||||||
|
func (h *ClusterHandler) GetNodes(c *gin.Context) {
|
||||||
|
nodes, err := h.clusterSvc.GetNodes(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("handler error", zap.String("method", "GetNodes"), zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OK(c, nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNode handles GET /api/v1/nodes/:name.
|
||||||
|
func (h *ClusterHandler) GetNode(c *gin.Context) {
|
||||||
|
name := c.Param("name")
|
||||||
|
|
||||||
|
resp, err := h.clusterSvc.GetNode(c.Request.Context(), name)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("handler error", zap.String("method", "GetNode"), zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
h.logger.Warn("not found", zap.String("method", "GetNode"), zap.String("name", name))
|
||||||
|
server.NotFound(c, "node not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OK(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPartitions handles GET /api/v1/partitions.
|
||||||
|
func (h *ClusterHandler) GetPartitions(c *gin.Context) {
|
||||||
|
partitions, err := h.clusterSvc.GetPartitions(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("handler error", zap.String("method", "GetPartitions"), zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OK(c, partitions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPartition handles GET /api/v1/partitions/:name.
|
||||||
|
func (h *ClusterHandler) GetPartition(c *gin.Context) {
|
||||||
|
name := c.Param("name")
|
||||||
|
|
||||||
|
resp, err := h.clusterSvc.GetPartition(c.Request.Context(), name)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("handler error", zap.String("method", "GetPartition"), zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
h.logger.Warn("not found", zap.String("method", "GetPartition"), zap.String("name", name))
|
||||||
|
server.NotFound(c, "partition not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OK(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDiag handles GET /api/v1/diag.
|
||||||
|
func (h *ClusterHandler) GetDiag(c *gin.Context) {
|
||||||
|
resp, err := h.clusterSvc.GetDiag(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("handler error", zap.String("method", "GetDiag"), zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OK(c, resp)
|
||||||
|
}
|
||||||
634
internal/handler/cluster_test.go
Normal file
634
internal/handler/cluster_test.go
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gcy_hpc_server/internal/service"
|
||||||
|
"gcy_hpc_server/internal/slurm"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupClusterHandler(slurmHandler http.HandlerFunc) (*httptest.Server, *ClusterHandler) {
|
||||||
|
srv := httptest.NewServer(slurmHandler)
|
||||||
|
client, _ := slurm.NewClient(srv.URL, srv.Client())
|
||||||
|
clusterSvc := service.NewClusterService(client, zap.NewNop())
|
||||||
|
return srv, NewClusterHandler(clusterSvc, zap.NewNop())
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupClusterHandlerWithObserver(slurmHandler http.HandlerFunc) (*httptest.Server, *ClusterHandler, *observer.ObservedLogs) {
|
||||||
|
core, recorded := observer.New(zapcore.DebugLevel)
|
||||||
|
l := zap.New(core)
|
||||||
|
srv := httptest.NewServer(slurmHandler)
|
||||||
|
client, _ := slurm.NewClient(srv.URL, srv.Client())
|
||||||
|
clusterSvc := service.NewClusterService(client, l)
|
||||||
|
return srv, NewClusterHandler(clusterSvc, l), recorded
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupClusterRouter(h *ClusterHandler) *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
v1 := r.Group("/api/v1")
|
||||||
|
v1.GET("/nodes", h.GetNodes)
|
||||||
|
v1.GET("/nodes/:name", h.GetNode)
|
||||||
|
v1.GET("/partitions", h.GetPartitions)
|
||||||
|
v1.GET("/partitions/:name", h.GetPartition)
|
||||||
|
v1.GET("/diag", h.GetDiag)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNodes_Success(t *testing.T) {
|
||||||
|
srv, h := setupClusterHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"nodes": []map[string]interface{}{
|
||||||
|
{"name": "node1", "state": []string{"IDLE"}, "cpus": 64, "real_memory": 128000},
|
||||||
|
},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/nodes", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["success"] != true {
|
||||||
|
t.Fatal("expected success=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNode_Success(t *testing.T) {
|
||||||
|
srv, h := setupClusterHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"nodes": []map[string]interface{}{
|
||||||
|
{"name": "node1", "state": []string{"IDLE"}, "cpus": 64, "real_memory": 128000},
|
||||||
|
},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/nodes/node1", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["success"] != true {
|
||||||
|
t.Fatal("expected success=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetNode_NotFound(t *testing.T) {
|
||||||
|
srv, h := setupClusterHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"nodes": []map[string]interface{}{},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/nodes/nonexistent", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPartitions_Success(t *testing.T) {
|
||||||
|
srv, h := setupClusterHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"partitions": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "normal",
|
||||||
|
"partition": map[string]interface{}{
|
||||||
|
"state": []string{"UP"},
|
||||||
|
},
|
||||||
|
"nodes": map[string]interface{}{
|
||||||
|
"configured": "node[1-10]",
|
||||||
|
"total": int32(10),
|
||||||
|
},
|
||||||
|
"cpus": map[string]interface{}{
|
||||||
|
"total": int32(640),
|
||||||
|
},
|
||||||
|
"maximums": map[string]interface{}{
|
||||||
|
"time": map[string]interface{}{"number": int64(60)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/partitions", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["success"] != true {
|
||||||
|
t.Fatal("expected success=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPartition_Success(t *testing.T) {
|
||||||
|
srv, h := setupClusterHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"partitions": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "normal",
|
||||||
|
"partition": map[string]interface{}{
|
||||||
|
"state": []string{"UP"},
|
||||||
|
},
|
||||||
|
"nodes": map[string]interface{}{
|
||||||
|
"configured": "node[1-10]",
|
||||||
|
"total": int32(10),
|
||||||
|
},
|
||||||
|
"cpus": map[string]interface{}{
|
||||||
|
"total": int32(640),
|
||||||
|
},
|
||||||
|
"maximums": map[string]interface{}{
|
||||||
|
"time": map[string]interface{}{"number": int64(60)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/partitions/normal", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["success"] != true {
|
||||||
|
t.Fatal("expected success=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetPartition_NotFound(t *testing.T) {
|
||||||
|
srv, h := setupClusterHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"partitions": []map[string]interface{}{},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/partitions/nonexistent", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDiag_Success(t *testing.T) {
|
||||||
|
srv, h := setupClusterHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"statistics": map[string]interface{}{
|
||||||
|
"server_thread_count": 3,
|
||||||
|
"agent_queue_size": 0,
|
||||||
|
"jobs_submitted": 100,
|
||||||
|
"jobs_started": 90,
|
||||||
|
"jobs_completed": 85,
|
||||||
|
"schedule_cycle_last": 10,
|
||||||
|
"schedule_cycle_total": 500,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/diag", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("failed to parse response: %v", err)
|
||||||
|
}
|
||||||
|
if resp["success"] != true {
|
||||||
|
t.Fatal("expected success=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logging tests ---
|
||||||
|
|
||||||
|
func TestClusterHandler_GetNodes_InternalError_LogsError(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, `{"errors":[{"error":"internal"}]}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/nodes", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
handlerLogs := recorded.FilterMessage("handler error")
|
||||||
|
if handlerLogs.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 handler error log, got %d", handlerLogs.Len())
|
||||||
|
}
|
||||||
|
entry := handlerLogs.All()[0]
|
||||||
|
if entry.Level != zapcore.ErrorLevel {
|
||||||
|
t.Fatalf("expected Error level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
assertField(t, entry.Context, "method", "GetNodes")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterHandler_GetNodes_Success_NoLogs(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"nodes": []map[string]interface{}{
|
||||||
|
{"name": "node1"},
|
||||||
|
},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/nodes", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recorded.Len() != 0 {
|
||||||
|
t.Fatalf("expected 0 log entries on success, got %d", recorded.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterHandler_GetNode_InternalError_LogsError(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, `{"errors":[{"error":"internal"}]}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/nodes/node1", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
handlerLogs := recorded.FilterMessage("handler error")
|
||||||
|
if handlerLogs.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 handler error log, got %d", handlerLogs.Len())
|
||||||
|
}
|
||||||
|
entry := handlerLogs.All()[0]
|
||||||
|
if entry.Level != zapcore.ErrorLevel {
|
||||||
|
t.Fatalf("expected Error level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
assertField(t, entry.Context, "method", "GetNode")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterHandler_GetNode_NotFound_LogsWarn(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"nodes": []map[string]interface{}{},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/nodes/nonexistent", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recorded.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 log entry, got %d", recorded.Len())
|
||||||
|
}
|
||||||
|
entry := recorded.All()[0]
|
||||||
|
if entry.Level != zapcore.WarnLevel {
|
||||||
|
t.Fatalf("expected Warn level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
if entry.Message != "not found" {
|
||||||
|
t.Fatalf("expected message 'not found', got %q", entry.Message)
|
||||||
|
}
|
||||||
|
assertField(t, entry.Context, "method", "GetNode")
|
||||||
|
assertField(t, entry.Context, "name", "nonexistent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterHandler_GetNode_Success_NoLogs(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"nodes": []map[string]interface{}{
|
||||||
|
{"name": "node1", "state": []string{"IDLE"}, "cpus": 64, "real_memory": 128000},
|
||||||
|
},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/nodes/node1", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recorded.Len() != 0 {
|
||||||
|
t.Fatalf("expected 0 log entries on success, got %d", recorded.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterHandler_GetPartitions_InternalError_LogsError(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, `{"errors":[{"error":"internal"}]}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/partitions", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
handlerLogs := recorded.FilterMessage("handler error")
|
||||||
|
if handlerLogs.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 handler error log, got %d", handlerLogs.Len())
|
||||||
|
}
|
||||||
|
entry := handlerLogs.All()[0]
|
||||||
|
if entry.Level != zapcore.ErrorLevel {
|
||||||
|
t.Fatalf("expected Error level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
assertField(t, entry.Context, "method", "GetPartitions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterHandler_GetPartitions_Success_NoLogs(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"partitions": []map[string]interface{}{
|
||||||
|
{"name": "normal"},
|
||||||
|
},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/partitions", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recorded.Len() != 0 {
|
||||||
|
t.Fatalf("expected 0 log entries on success, got %d", recorded.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterHandler_GetPartition_InternalError_LogsError(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, `{"errors":[{"error":"internal"}]}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/partitions/normal", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
handlerLogs := recorded.FilterMessage("handler error")
|
||||||
|
if handlerLogs.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 handler error log, got %d", handlerLogs.Len())
|
||||||
|
}
|
||||||
|
entry := handlerLogs.All()[0]
|
||||||
|
if entry.Level != zapcore.ErrorLevel {
|
||||||
|
t.Fatalf("expected Error level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
assertField(t, entry.Context, "method", "GetPartition")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterHandler_GetPartition_NotFound_LogsWarn(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"partitions": []map[string]interface{}{},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/partitions/nonexistent", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recorded.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 log entry, got %d", recorded.Len())
|
||||||
|
}
|
||||||
|
entry := recorded.All()[0]
|
||||||
|
if entry.Level != zapcore.WarnLevel {
|
||||||
|
t.Fatalf("expected Warn level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
if entry.Message != "not found" {
|
||||||
|
t.Fatalf("expected message 'not found', got %q", entry.Message)
|
||||||
|
}
|
||||||
|
assertField(t, entry.Context, "method", "GetPartition")
|
||||||
|
assertField(t, entry.Context, "name", "nonexistent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterHandler_GetPartition_Success_NoLogs(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"partitions": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": "normal",
|
||||||
|
"partition": map[string]interface{}{
|
||||||
|
"state": []string{"UP"},
|
||||||
|
},
|
||||||
|
"nodes": map[string]interface{}{
|
||||||
|
"configured": "node[1-10]",
|
||||||
|
"total": int32(10),
|
||||||
|
},
|
||||||
|
"cpus": map[string]interface{}{
|
||||||
|
"total": int32(640),
|
||||||
|
},
|
||||||
|
"maximums": map[string]interface{}{
|
||||||
|
"time": map[string]interface{}{"number": int64(60)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"last_update": map[string]interface{}{},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/partitions/normal", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recorded.Len() != 0 {
|
||||||
|
t.Fatalf("expected 0 log entries on success, got %d", recorded.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterHandler_GetDiag_InternalError_LogsError(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, `{"errors":[{"error":"internal"}]}`)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/diag", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
handlerLogs := recorded.FilterMessage("handler error")
|
||||||
|
if handlerLogs.Len() != 1 {
|
||||||
|
t.Fatalf("expected 1 handler error log, got %d", handlerLogs.Len())
|
||||||
|
}
|
||||||
|
entry := handlerLogs.All()[0]
|
||||||
|
if entry.Level != zapcore.ErrorLevel {
|
||||||
|
t.Fatalf("expected Error level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
assertField(t, entry.Context, "method", "GetDiag")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClusterHandler_GetDiag_Success_NoLogs(t *testing.T) {
|
||||||
|
srv, h, recorded := setupClusterHandlerWithObserver(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"statistics": map[string]interface{}{
|
||||||
|
"server_thread_count": 3,
|
||||||
|
"agent_queue_size": 0,
|
||||||
|
"jobs_submitted": 100,
|
||||||
|
"jobs_started": 90,
|
||||||
|
"jobs_completed": 85,
|
||||||
|
"schedule_cycle_last": 10,
|
||||||
|
"schedule_cycle_total": 500,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupClusterRouter(h)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/diag", nil)
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recorded.Len() != 0 {
|
||||||
|
t.Fatalf("expected 0 log entries on success, got %d", recorded.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertField checks that a zap Field slice contains a string field with the given key and value.
|
||||||
|
func assertField(t *testing.T, fields []zapcore.Field, key, value string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, f := range fields {
|
||||||
|
if f.Key == key && f.String == value {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("expected field %q=%q in context, got %v", key, value, fields)
|
||||||
|
}
|
||||||
118
internal/handler/job.go
Normal file
118
internal/handler/job.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gcy_hpc_server/internal/model"
|
||||||
|
"gcy_hpc_server/internal/server"
|
||||||
|
"gcy_hpc_server/internal/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobHandler handles HTTP requests for job operations.
|
||||||
|
type JobHandler struct {
|
||||||
|
jobSvc *service.JobService
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewJobHandler creates a new JobHandler with the given JobService.
|
||||||
|
func NewJobHandler(jobSvc *service.JobService, logger *zap.Logger) *JobHandler {
|
||||||
|
return &JobHandler{jobSvc: jobSvc, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitJob handles POST /api/v1/jobs/submit.
|
||||||
|
func (h *JobHandler) SubmitJob(c *gin.Context) {
|
||||||
|
var req model.SubmitJobRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
h.logger.Warn("bad request", zap.String("method", "SubmitJob"), zap.String("error", "invalid request body"))
|
||||||
|
server.BadRequest(c, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Script == "" {
|
||||||
|
h.logger.Warn("bad request", zap.String("method", "SubmitJob"), zap.String("error", "script is required"))
|
||||||
|
server.BadRequest(c, "script is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.jobSvc.SubmitJob(c.Request.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("handler error", zap.String("method", "SubmitJob"), zap.Int("status", http.StatusBadGateway), zap.Error(err))
|
||||||
|
server.ErrorWithStatus(c, http.StatusBadGateway, "slurm error: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.Created(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJobs handles GET /api/v1/jobs.
|
||||||
|
func (h *JobHandler) GetJobs(c *gin.Context) {
|
||||||
|
jobs, err := h.jobSvc.GetJobs(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("handler error", zap.String("method", "GetJobs"), zap.Int("status", http.StatusInternalServerError), zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OK(c, jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJob handles GET /api/v1/jobs/:id.
|
||||||
|
func (h *JobHandler) GetJob(c *gin.Context) {
|
||||||
|
jobID := c.Param("id")
|
||||||
|
|
||||||
|
resp, err := h.jobSvc.GetJob(c.Request.Context(), jobID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("handler error", zap.String("method", "GetJob"), zap.Int("status", http.StatusInternalServerError), zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resp == nil {
|
||||||
|
h.logger.Warn("bad request", zap.String("method", "GetJob"), zap.String("error", "job not found"))
|
||||||
|
server.NotFound(c, "job not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OK(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelJob handles DELETE /api/v1/jobs/:id.
|
||||||
|
func (h *JobHandler) CancelJob(c *gin.Context) {
|
||||||
|
jobID := c.Param("id")
|
||||||
|
|
||||||
|
err := h.jobSvc.CancelJob(c.Request.Context(), jobID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("handler error", zap.String("method", "CancelJob"), zap.Int("status", http.StatusBadGateway), zap.Error(err))
|
||||||
|
server.ErrorWithStatus(c, http.StatusBadGateway, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OK(c, gin.H{"message": "job cancelled"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJobHistory handles GET /api/v1/jobs/history.
|
||||||
|
func (h *JobHandler) GetJobHistory(c *gin.Context) {
|
||||||
|
var query model.JobHistoryQuery
|
||||||
|
if err := c.ShouldBindQuery(&query); err != nil {
|
||||||
|
h.logger.Warn("bad request", zap.String("method", "GetJobHistory"), zap.String("error", "invalid query params"))
|
||||||
|
server.BadRequest(c, "invalid query params")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if query.Page < 1 {
|
||||||
|
query.Page = 1
|
||||||
|
}
|
||||||
|
if query.PageSize < 1 {
|
||||||
|
query.PageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.jobSvc.GetJobHistory(c.Request.Context(), &query)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("handler error", zap.String("method", "GetJobHistory"), zap.Int("status", http.StatusInternalServerError), zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OK(c, resp)
|
||||||
|
}
|
||||||
821
internal/handler/job_test.go
Normal file
821
internal/handler/job_test.go
Normal file
@@ -0,0 +1,821 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gcy_hpc_server/internal/service"
|
||||||
|
"gcy_hpc_server/internal/slurm"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupJobRouter(h *JobHandler) *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
v1 := r.Group("/api/v1")
|
||||||
|
jobs := v1.Group("/jobs")
|
||||||
|
{
|
||||||
|
jobs.POST("/submit", h.SubmitJob)
|
||||||
|
jobs.GET("", h.GetJobs)
|
||||||
|
jobs.GET("/history", h.GetJobHistory)
|
||||||
|
jobs.GET("/:id", h.GetJob)
|
||||||
|
jobs.DELETE("/:id", h.CancelJob)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupJobHandler(mux *http.ServeMux) (*httptest.Server, *JobHandler) {
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
client, _ := slurm.NewClient(srv.URL, srv.Client())
|
||||||
|
jobSvc := service.NewJobService(client, zap.NewNop())
|
||||||
|
return srv, NewJobHandler(jobSvc, zap.NewNop())
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupJobHandlerWithObserver(mux *http.ServeMux) (*httptest.Server, *JobHandler, *observer.ObservedLogs) {
|
||||||
|
core, recorded := observer.New(zapcore.DebugLevel)
|
||||||
|
l := zap.New(core)
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
client, _ := slurm.NewClient(srv.URL, srv.Client())
|
||||||
|
jobSvc := service.NewJobService(client, l)
|
||||||
|
return srv, NewJobHandler(jobSvc, l), recorded
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlerLogs(logs *observer.ObservedLogs) []observer.LoggedEntry {
|
||||||
|
var handler []observer.LoggedEntry
|
||||||
|
for _, e := range logs.All() {
|
||||||
|
for _, f := range e.Context {
|
||||||
|
if f.Key == "method" {
|
||||||
|
handler = append(handler, e)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitJob_Success(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/job/submit", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: slurm.Ptr(int32(123))},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
body := `{"script":"#!/bin/bash\necho hello"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/submit", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if !resp["success"].(bool) {
|
||||||
|
t.Fatal("expected success=true")
|
||||||
|
}
|
||||||
|
data := resp["data"].(map[string]interface{})
|
||||||
|
if int(data["job_id"].(float64)) != 123 {
|
||||||
|
t.Errorf("expected job_id=123, got %v", data["job_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitJob_MissingScript(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
body := `{"partition":"normal"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/submit", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if resp["success"].(bool) {
|
||||||
|
t.Fatal("expected success=false")
|
||||||
|
}
|
||||||
|
if resp["error"] != "invalid request body" && resp["error"] != "script is required" {
|
||||||
|
t.Errorf("expected validation error, got %v", resp["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitJob_EmptyScript(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
body := `{"script":""}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/submit", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if resp["success"].(bool) {
|
||||||
|
t.Fatal("expected success=false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitJob_SlurmError(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/job/submit", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, `{"errors":[{"error":"internal error"}]}`)
|
||||||
|
})
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
body := `{"script":"#!/bin/bash\necho hello"}`
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/submit", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("expected 502, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobs_Success(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobInfoResp{
|
||||||
|
Jobs: []slurm.JobInfo{
|
||||||
|
{JobID: slurm.Ptr(int32(1)), Name: slurm.Ptr("job1")},
|
||||||
|
{JobID: slurm.Ptr(int32(2)), Name: slurm.Ptr("job2")},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if !resp["success"].(bool) {
|
||||||
|
t.Fatal("expected success=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJob_Success(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/job/42", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobInfoResp{
|
||||||
|
Jobs: []slurm.JobInfo{
|
||||||
|
{JobID: slurm.Ptr(int32(42)), Name: slurm.Ptr("test-job")},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/42", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
data := resp["data"].(map[string]interface{})
|
||||||
|
if int(data["job_id"].(float64)) != 42 {
|
||||||
|
t.Errorf("expected job_id=42, got %v", data["job_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJob_NotFound(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/job/999", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobInfoResp{
|
||||||
|
Jobs: []slurm.JobInfo{},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/999", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if resp["error"].(string) != "job not found" {
|
||||||
|
t.Errorf("expected 'job not found' error, got %v", resp["error"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelJob_Success(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/job/42", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiResp{})
|
||||||
|
})
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/jobs/42", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if !resp["success"].(bool) {
|
||||||
|
t.Fatal("expected success=true")
|
||||||
|
}
|
||||||
|
data := resp["data"].(map[string]interface{})
|
||||||
|
if data["message"].(string) != "job cancelled" {
|
||||||
|
t.Errorf("expected 'job cancelled', got %v", data["message"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobHistory_Success(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurmdb/v0.0.40/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiSlurmdbdJobsResp{
|
||||||
|
Jobs: []slurm.Job{
|
||||||
|
{JobID: slurm.Ptr(int32(1)), Name: slurm.Ptr("hist1")},
|
||||||
|
{JobID: slurm.Ptr(int32(2)), Name: slurm.Ptr("hist2")},
|
||||||
|
{JobID: slurm.Ptr(int32(3)), Name: slurm.Ptr("hist3")},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/history?page=1&page_size=2", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
data := resp["data"].(map[string]interface{})
|
||||||
|
jobs := data["jobs"].([]interface{})
|
||||||
|
if len(jobs) != 2 {
|
||||||
|
t.Fatalf("expected 2 jobs on page 1, got %d", len(jobs))
|
||||||
|
}
|
||||||
|
if int(data["total"].(float64)) != 3 {
|
||||||
|
t.Errorf("expected total=3, got %v", data["total"])
|
||||||
|
}
|
||||||
|
if int(data["page"].(float64)) != 1 {
|
||||||
|
t.Errorf("expected page=1, got %v", data["page"])
|
||||||
|
}
|
||||||
|
if int(data["page_size"].(float64)) != 2 {
|
||||||
|
t.Errorf("expected page_size=2, got %v", data["page_size"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobHistory_DefaultPagination(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurmdb/v0.0.40/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiSlurmdbdJobsResp{
|
||||||
|
Jobs: []slurm.Job{
|
||||||
|
{JobID: slurm.Ptr(int32(1)), Name: slurm.Ptr("h1")},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/history", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
data := resp["data"].(map[string]interface{})
|
||||||
|
if int(data["page"].(float64)) != 1 {
|
||||||
|
t.Errorf("expected default page=1, got %v", data["page"])
|
||||||
|
}
|
||||||
|
if int(data["page_size"].(float64)) != 20 {
|
||||||
|
t.Errorf("expected default page_size=20, got %v", data["page_size"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitJob_InvalidBody(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv, handler := setupJobHandler(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/submit", bytes.NewBufferString(`not json`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Logging verification tests ---
|
||||||
|
|
||||||
|
func TestSubmitJob_InvalidBody_LogsWarn(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/submit", bytes.NewBufferString(`not json`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 1 {
|
||||||
|
t.Fatalf("expected 1 handler log entry, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
entry := hLogs[0]
|
||||||
|
if entry.Level != zapcore.WarnLevel {
|
||||||
|
t.Errorf("expected Warn level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
if entry.Context[0].Key != "method" || entry.Context[0].String != "SubmitJob" {
|
||||||
|
t.Errorf("expected method=SubmitJob, got %v", entry.Context[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitJob_EmptyScript_LogsWarn(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/submit", bytes.NewBufferString(`{"script":""}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 1 {
|
||||||
|
t.Fatalf("expected 1 handler log entry, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
entry := hLogs[0]
|
||||||
|
if entry.Level != zapcore.WarnLevel {
|
||||||
|
t.Errorf("expected Warn level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitJob_SlurmError_LogsError(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/job/submit", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, `{"errors":[{"error":"internal error"}]}`)
|
||||||
|
})
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/submit", bytes.NewBufferString(`{"script":"#!/bin/bash\necho hello"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("expected 502, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 1 {
|
||||||
|
t.Fatalf("expected 1 handler log entry, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
entry := hLogs[0]
|
||||||
|
if entry.Level != zapcore.ErrorLevel {
|
||||||
|
t.Errorf("expected Error level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
foundMethod := false
|
||||||
|
foundStatus := false
|
||||||
|
for _, f := range entry.Context {
|
||||||
|
if f.Key == "method" && f.String == "SubmitJob" {
|
||||||
|
foundMethod = true
|
||||||
|
}
|
||||||
|
if f.Key == "status" && f.Integer == http.StatusBadGateway {
|
||||||
|
foundStatus = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMethod {
|
||||||
|
t.Error("expected method=SubmitJob in log fields")
|
||||||
|
}
|
||||||
|
if !foundStatus {
|
||||||
|
t.Error("expected status=502 in log fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubmitJob_Success_NoHandlerLogs(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/job/submit", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobSubmitResponse{
|
||||||
|
Result: &slurm.JobSubmitResponseMsg{JobID: slurm.Ptr(int32(123))},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs/submit", bytes.NewBufferString(`{"script":"#!/bin/bash\necho hello"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 0 {
|
||||||
|
t.Errorf("expected no handler log entries on success, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobs_Error_LogsError(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 1 {
|
||||||
|
t.Fatalf("expected 1 handler log entry, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
entry := hLogs[0]
|
||||||
|
if entry.Level != zapcore.ErrorLevel {
|
||||||
|
t.Errorf("expected Error level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
foundMethod := false
|
||||||
|
for _, f := range entry.Context {
|
||||||
|
if f.Key == "method" && f.String == "GetJobs" {
|
||||||
|
foundMethod = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMethod {
|
||||||
|
t.Error("expected method=GetJobs in log fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJob_NotFound_LogsWarn(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/job/999", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobInfoResp{Jobs: []slurm.JobInfo{}})
|
||||||
|
})
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/999", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 1 {
|
||||||
|
t.Fatalf("expected 1 handler log entry, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
entry := hLogs[0]
|
||||||
|
if entry.Level != zapcore.WarnLevel {
|
||||||
|
t.Errorf("expected Warn level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
foundMethod := false
|
||||||
|
for _, f := range entry.Context {
|
||||||
|
if f.Key == "method" && f.String == "GetJob" {
|
||||||
|
foundMethod = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMethod {
|
||||||
|
t.Error("expected method=GetJob in log fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJob_Error_LogsError(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/job/42", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/42", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 1 {
|
||||||
|
t.Fatalf("expected 1 handler log entry, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
if hLogs[0].Level != zapcore.ErrorLevel {
|
||||||
|
t.Errorf("expected Error level, got %v", hLogs[0].Level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelJob_SlurmError_LogsError(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/job/42", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/jobs/42", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("expected 502, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 1 {
|
||||||
|
t.Fatalf("expected 1 handler log entry, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
entry := hLogs[0]
|
||||||
|
if entry.Level != zapcore.ErrorLevel {
|
||||||
|
t.Errorf("expected Error level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
foundMethod := false
|
||||||
|
for _, f := range entry.Context {
|
||||||
|
if f.Key == "method" && f.String == "CancelJob" {
|
||||||
|
foundMethod = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMethod {
|
||||||
|
t.Error("expected method=CancelJob in log fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobHistory_InvalidQuery_LogsWarn(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurmdb/v0.0.40/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiSlurmdbdJobsResp{})
|
||||||
|
})
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/history?page=abc", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 1 {
|
||||||
|
t.Fatalf("expected 1 handler log entry, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
entry := hLogs[0]
|
||||||
|
if entry.Level != zapcore.WarnLevel {
|
||||||
|
t.Errorf("expected Warn level, got %v", entry.Level)
|
||||||
|
}
|
||||||
|
foundMethod := false
|
||||||
|
for _, f := range entry.Context {
|
||||||
|
if f.Key == "method" && f.String == "GetJobHistory" {
|
||||||
|
foundMethod = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundMethod {
|
||||||
|
t.Error("expected method=GetJobHistory in log fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobHistory_Error_LogsError(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurmdb/v0.0.40/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/history?page=1&page_size=10", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("expected 500, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 1 {
|
||||||
|
t.Fatalf("expected 1 handler log entry, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
if hLogs[0].Level != zapcore.ErrorLevel {
|
||||||
|
t.Errorf("expected Error level, got %v", hLogs[0].Level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobHistory_Success_NoHandlerLogs(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurmdb/v0.0.40/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiSlurmdbdJobsResp{
|
||||||
|
Jobs: []slurm.Job{
|
||||||
|
{JobID: slurm.Ptr(int32(1)), Name: slurm.Ptr("h1")},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs/history?page=1&page_size=10", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 0 {
|
||||||
|
t.Errorf("expected no handler log entries on success, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetJobs_Success_NoHandlerLogs(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/jobs", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiJobInfoResp{Jobs: []slurm.JobInfo{}})
|
||||||
|
})
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/jobs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 0 {
|
||||||
|
t.Errorf("expected no handler log entries on success, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelJob_Success_NoHandlerLogs(t *testing.T) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/slurm/v0.0.40/job/42", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(slurm.OpenapiResp{})
|
||||||
|
})
|
||||||
|
srv, handler, logs := setupJobHandlerWithObserver(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
router := setupJobRouter(handler)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/v1/jobs/42", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
hLogs := handlerLogs(logs)
|
||||||
|
if len(hLogs) != 0 {
|
||||||
|
t.Errorf("expected no handler log entries on success, got %d", len(hLogs))
|
||||||
|
}
|
||||||
|
}
|
||||||
139
internal/handler/template.go
Normal file
139
internal/handler/template.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gcy_hpc_server/internal/model"
|
||||||
|
"gcy_hpc_server/internal/server"
|
||||||
|
"gcy_hpc_server/internal/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TemplateHandler struct {
|
||||||
|
store *store.TemplateStore
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTemplateHandler(s *store.TemplateStore, logger *zap.Logger) *TemplateHandler {
|
||||||
|
return &TemplateHandler{store: s, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TemplateHandler) ListTemplates(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
templates, total, err := h.store.List(c.Request.Context(), page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to list templates", zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OK(c, gin.H{
|
||||||
|
"templates": templates,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": pageSize,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TemplateHandler) CreateTemplate(c *gin.Context) {
|
||||||
|
var req model.CreateTemplateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
h.logger.Warn("invalid request body for create template", zap.Error(err))
|
||||||
|
server.BadRequest(c, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" || req.Script == "" {
|
||||||
|
h.logger.Warn("missing required fields for create template")
|
||||||
|
server.BadRequest(c, "name and script are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := h.store.Create(c.Request.Context(), &req)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to create template", zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("template created", zap.Int64("id", id))
|
||||||
|
server.Created(c, gin.H{"id": id})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TemplateHandler) GetTemplate(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("invalid template id", zap.String("id", c.Param("id")))
|
||||||
|
server.BadRequest(c, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := h.store.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("failed to get template", zap.Int64("id", id), zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tmpl == nil {
|
||||||
|
h.logger.Warn("template not found", zap.Int64("id", id))
|
||||||
|
server.NotFound(c, "template not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
server.OK(c, tmpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TemplateHandler) UpdateTemplate(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("invalid template id for update", zap.String("id", c.Param("id")))
|
||||||
|
server.BadRequest(c, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req model.UpdateTemplateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
h.logger.Warn("invalid request body for update template", zap.Int64("id", id), zap.Error(err))
|
||||||
|
server.BadRequest(c, "invalid request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.Update(c.Request.Context(), id, &req); err != nil {
|
||||||
|
h.logger.Error("failed to update template", zap.Int64("id", id), zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("template updated", zap.Int64("id", id))
|
||||||
|
server.OK(c, gin.H{"message": "template updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TemplateHandler) DeleteTemplate(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("invalid template id for delete", zap.String("id", c.Param("id")))
|
||||||
|
server.BadRequest(c, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.store.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
h.logger.Error("failed to delete template", zap.Int64("id", id), zap.Error(err))
|
||||||
|
server.InternalError(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("template deleted", zap.Int64("id", id))
|
||||||
|
server.OK(c, gin.H{"message": "template deleted"})
|
||||||
|
}
|
||||||
387
internal/handler/template_test.go
Normal file
387
internal/handler/template_test.go
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gcy_hpc_server/internal/model"
|
||||||
|
"gcy_hpc_server/internal/server"
|
||||||
|
"gcy_hpc_server/internal/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTemplateHandler() (*TemplateHandler, *gorm.DB) {
|
||||||
|
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
db.AutoMigrate(&model.JobTemplate{})
|
||||||
|
s := store.NewTemplateStore(db)
|
||||||
|
h := NewTemplateHandler(s, zap.NewNop())
|
||||||
|
return h, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTemplateRouter(h *TemplateHandler) *gin.Engine {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
v1 := r.Group("/api/v1")
|
||||||
|
templates := v1.Group("/templates")
|
||||||
|
templates.GET("", h.ListTemplates)
|
||||||
|
templates.POST("", h.CreateTemplate)
|
||||||
|
templates.GET("/:id", h.GetTemplate)
|
||||||
|
templates.PUT("/:id", h.UpdateTemplate)
|
||||||
|
templates.DELETE("/:id", h.DeleteTemplate)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListTemplates_Success(t *testing.T) {
|
||||||
|
h, _ := setupTemplateHandler()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
// Seed data
|
||||||
|
h.store.Create(context.Background(), &model.CreateTemplateRequest{Name: "test-tpl", Script: "echo hi", Partition: "normal", QOS: "high", CPUs: 4, Memory: "4GB"})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/templates", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp server.APIResponse
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("expected success=true, got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTemplate_Success(t *testing.T) {
|
||||||
|
h, _ := setupTemplateHandler()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
body := `{"name":"my-tpl","description":"desc","script":"echo hello","partition":"gpu"}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/templates", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp server.APIResponse
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("expected success=true, got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTemplate_MissingFields(t *testing.T) {
|
||||||
|
h, _ := setupTemplateHandler()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
body := `{"name":"","script":""}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/templates", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTemplate_Success(t *testing.T) {
|
||||||
|
h, _ := setupTemplateHandler()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
// Seed data
|
||||||
|
id, _ := h.store.Create(context.Background(), &model.CreateTemplateRequest{Name: "test-tpl", Script: "echo hi", Partition: "normal"})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/templates/"+itoa(id), nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp server.APIResponse
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("expected success=true, got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTemplate_NotFound(t *testing.T) {
|
||||||
|
h, _ := setupTemplateHandler()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/templates/999", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTemplate_InvalidID(t *testing.T) {
|
||||||
|
h, _ := setupTemplateHandler()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/templates/abc", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTemplate_Success(t *testing.T) {
|
||||||
|
h, _ := setupTemplateHandler()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
// Seed data
|
||||||
|
id, _ := h.store.Create(context.Background(), &model.CreateTemplateRequest{Name: "old", Script: "echo hi"})
|
||||||
|
|
||||||
|
body := `{"name":"updated"}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("PUT", "/api/v1/templates/"+itoa(id), bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp server.APIResponse
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("expected success=true, got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteTemplate_Success(t *testing.T) {
|
||||||
|
h, _ := setupTemplateHandler()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
// Seed data
|
||||||
|
id, _ := h.store.Create(context.Background(), &model.CreateTemplateRequest{Name: "to-delete", Script: "echo hi"})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("DELETE", "/api/v1/templates/"+itoa(id), nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var resp server.APIResponse
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("expected success=true, got: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// itoa converts int64 to string for URL path construction.
|
||||||
|
func itoa(id int64) string {
|
||||||
|
return fmt.Sprintf("%d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTemplateHandlerWithObserver() (*TemplateHandler, *gorm.DB, *observer.ObservedLogs) {
|
||||||
|
core, recorded := observer.New(zapcore.DebugLevel)
|
||||||
|
l := zap.New(core)
|
||||||
|
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
|
db.AutoMigrate(&model.JobTemplate{})
|
||||||
|
s := store.NewTemplateStore(db)
|
||||||
|
return NewTemplateHandler(s, l), db, recorded
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateLogging_CreateSuccess_LogsInfoWithID(t *testing.T) {
|
||||||
|
h, _, recorded := setupTemplateHandlerWithObserver()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
body := `{"name":"log-tpl","script":"echo hi"}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/templates", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := recorded.FilterMessage("template created").FilterLevelExact(zapcore.InfoLevel).All()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 info log for 'template created', got %d", len(entries))
|
||||||
|
}
|
||||||
|
fields := entries[0].ContextMap()
|
||||||
|
if fields["id"] == nil {
|
||||||
|
t.Fatal("expected log entry to contain 'id' field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateLogging_UpdateSuccess_LogsInfoWithID(t *testing.T) {
|
||||||
|
h, _, recorded := setupTemplateHandlerWithObserver()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
id, _ := h.store.Create(context.Background(), &model.CreateTemplateRequest{Name: "old", Script: "echo hi"})
|
||||||
|
|
||||||
|
body := `{"name":"updated"}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("PUT", "/api/v1/templates/"+itoa(id), bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := recorded.FilterMessage("template updated").FilterLevelExact(zapcore.InfoLevel).All()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 info log for 'template updated', got %d", len(entries))
|
||||||
|
}
|
||||||
|
fields := entries[0].ContextMap()
|
||||||
|
if fields["id"] == nil {
|
||||||
|
t.Fatal("expected log entry to contain 'id' field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateLogging_DeleteSuccess_LogsInfoWithID(t *testing.T) {
|
||||||
|
h, _, recorded := setupTemplateHandlerWithObserver()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
id, _ := h.store.Create(context.Background(), &model.CreateTemplateRequest{Name: "to-delete", Script: "echo hi"})
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("DELETE", "/api/v1/templates/"+itoa(id), nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := recorded.FilterMessage("template deleted").FilterLevelExact(zapcore.InfoLevel).All()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 info log for 'template deleted', got %d", len(entries))
|
||||||
|
}
|
||||||
|
fields := entries[0].ContextMap()
|
||||||
|
if fields["id"] == nil {
|
||||||
|
t.Fatal("expected log entry to contain 'id' field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateLogging_GetNotFound_LogsWarnWithID(t *testing.T) {
|
||||||
|
h, _, recorded := setupTemplateHandlerWithObserver()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/templates/999", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := recorded.FilterMessage("template not found").FilterLevelExact(zapcore.WarnLevel).All()
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("expected 1 warn log for 'template not found', got %d", len(entries))
|
||||||
|
}
|
||||||
|
fields := entries[0].ContextMap()
|
||||||
|
if fields["id"] == nil {
|
||||||
|
t.Fatal("expected log entry to contain 'id' field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateLogging_CreateBadRequest_LogsWarn(t *testing.T) {
|
||||||
|
h, _, recorded := setupTemplateHandlerWithObserver()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
body := `{"name":"","script":""}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/templates", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
warnEntries := recorded.FilterLevelExact(zapcore.WarnLevel).All()
|
||||||
|
if len(warnEntries) == 0 {
|
||||||
|
t.Fatal("expected at least 1 warn log for bad request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateLogging_InvalidID_LogsWarn(t *testing.T) {
|
||||||
|
h, _, recorded := setupTemplateHandlerWithObserver()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/templates/abc", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
warnEntries := recorded.FilterLevelExact(zapcore.WarnLevel).All()
|
||||||
|
if len(warnEntries) == 0 {
|
||||||
|
t.Fatal("expected at least 1 warn log for invalid id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateLogging_ListSuccess_NoInfoLog(t *testing.T) {
|
||||||
|
h, _, recorded := setupTemplateHandlerWithObserver()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
// Seed data
|
||||||
|
h.store.Create(context.Background(), &model.CreateTemplateRequest{Name: "test-tpl", Script: "echo hi"})
|
||||||
|
|
||||||
|
// Reset recorded logs so the create log doesn't interfere
|
||||||
|
recorded.TakeAll()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/v1/templates", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
infoEntries := recorded.FilterLevelExact(zapcore.InfoLevel).All()
|
||||||
|
if len(infoEntries) != 0 {
|
||||||
|
t.Fatalf("expected 0 info logs for list success, got %d: %+v", len(infoEntries), infoEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateLogging_LogsDoNotContainTemplateContent(t *testing.T) {
|
||||||
|
h, _, recorded := setupTemplateHandlerWithObserver()
|
||||||
|
r := setupTemplateRouter(h)
|
||||||
|
|
||||||
|
body := `{"name":"secret-name","script":"secret-script","partition":"secret-partition"}`
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("POST", "/api/v1/templates", bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := recorded.All()
|
||||||
|
for _, e := range entries {
|
||||||
|
logStr := e.Message + " " + fmt.Sprintf("%v", e.ContextMap())
|
||||||
|
if strings.Contains(logStr, "secret-name") || strings.Contains(logStr, "secret-script") || strings.Contains(logStr, "secret-partition") {
|
||||||
|
t.Fatalf("log entry contains sensitive template content: %s", logStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user