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:
dailz
2026-04-10 08:40:06 +08:00
parent 4903f7d07f
commit e6162063ca
6 changed files with 2193 additions and 0 deletions

View 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)
}