From e6162063cafe37b140a1102976a25358d97e87e2 Mon Sep 17 00:00:00 2001 From: dailz Date: Fri, 10 Apr 2026 08:40:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20HTTP=20=E5=A4=84?= =?UTF-8?q?=E7=90=86=E5=B1=82=E5=92=8C=E7=BB=93=E6=9E=84=E5=8C=96=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/handler/cluster.go | 94 ++++ internal/handler/cluster_test.go | 634 +++++++++++++++++++++++ internal/handler/job.go | 118 +++++ internal/handler/job_test.go | 821 ++++++++++++++++++++++++++++++ internal/handler/template.go | 139 +++++ internal/handler/template_test.go | 387 ++++++++++++++ 6 files changed, 2193 insertions(+) create mode 100644 internal/handler/cluster.go create mode 100644 internal/handler/cluster_test.go create mode 100644 internal/handler/job.go create mode 100644 internal/handler/job_test.go create mode 100644 internal/handler/template.go create mode 100644 internal/handler/template_test.go diff --git a/internal/handler/cluster.go b/internal/handler/cluster.go new file mode 100644 index 0000000..adbe106 --- /dev/null +++ b/internal/handler/cluster.go @@ -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) +} diff --git a/internal/handler/cluster_test.go b/internal/handler/cluster_test.go new file mode 100644 index 0000000..77163c4 --- /dev/null +++ b/internal/handler/cluster_test.go @@ -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) +} diff --git a/internal/handler/job.go b/internal/handler/job.go new file mode 100644 index 0000000..cb2e5bf --- /dev/null +++ b/internal/handler/job.go @@ -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) +} diff --git a/internal/handler/job_test.go b/internal/handler/job_test.go new file mode 100644 index 0000000..edae13c --- /dev/null +++ b/internal/handler/job_test.go @@ -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)) + } +} diff --git a/internal/handler/template.go b/internal/handler/template.go new file mode 100644 index 0000000..e361761 --- /dev/null +++ b/internal/handler/template.go @@ -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"}) +} diff --git a/internal/handler/template_test.go b/internal/handler/template_test.go new file mode 100644 index 0000000..79951fc --- /dev/null +++ b/internal/handler/template_test.go @@ -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) + } + } +}