Files
hpc/internal/handler/template_test.go
dailz e6162063ca 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>
2026-04-10 08:40:06 +08:00

388 lines
12 KiB
Go

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