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