From 10bb15e5b28870a908d08149c2d2b90cfa146a0e Mon Sep 17 00:00:00 2001 From: dailz Date: Mon, 13 Apr 2026 17:10:54 +0800 Subject: [PATCH] feat(handler): add Application handler, routes, and wiring Add ApplicationHandler with CRUD + Submit endpoints. Register 6 routes, wire in app.go, update main_test.go references. 22 handler tests. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- cmd/server/main_test.go | 31 +- internal/handler/application.go | 174 ++++++++ internal/handler/application_test.go | 630 +++++++++++++++++++++++++++ 3 files changed, 823 insertions(+), 12 deletions(-) create mode 100644 internal/handler/application.go create mode 100644 internal/handler/application_test.go diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index cf14690..ffcddb9 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -21,7 +21,7 @@ import ( func newTestDB() *gorm.DB { db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) - db.AutoMigrate(&model.JobTemplate{}) + db.AutoMigrate(&model.Application{}) return db } @@ -34,12 +34,15 @@ func TestRouterRegistration(t *testing.T) { defer slurmSrv.Close() client, _ := slurm.NewClientWithOpts(slurmSrv.URL, slurm.WithHTTPClient(slurmSrv.Client())) - templateStore := store.NewTemplateStore(newTestDB()) + jobSvc := service.NewJobService(client, zap.NewNop()) + appStore := store.NewApplicationStore(newTestDB()) + appSvc := service.NewApplicationService(appStore, jobSvc, "", zap.NewNop()) + appH := handler.NewApplicationHandler(appSvc, zap.NewNop()) router := server.NewRouter( - handler.NewJobHandler(service.NewJobService(client, zap.NewNop()), zap.NewNop()), + handler.NewJobHandler(jobSvc, zap.NewNop()), handler.NewClusterHandler(service.NewClusterService(client, zap.NewNop()), zap.NewNop()), - handler.NewTemplateHandler(templateStore, zap.NewNop()), + appH, nil, ) @@ -58,11 +61,12 @@ func TestRouterRegistration(t *testing.T) { {"GET", "/api/v1/partitions"}, {"GET", "/api/v1/partitions/:name"}, {"GET", "/api/v1/diag"}, - {"GET", "/api/v1/templates"}, - {"POST", "/api/v1/templates"}, - {"GET", "/api/v1/templates/:id"}, - {"PUT", "/api/v1/templates/:id"}, - {"DELETE", "/api/v1/templates/:id"}, + {"GET", "/api/v1/applications"}, + {"POST", "/api/v1/applications"}, + {"GET", "/api/v1/applications/:id"}, + {"PUT", "/api/v1/applications/:id"}, + {"DELETE", "/api/v1/applications/:id"}, + {"POST", "/api/v1/applications/:id/submit"}, } routeMap := map[string]bool{} @@ -90,12 +94,15 @@ func TestSmokeGetJobsEndpoint(t *testing.T) { defer slurmSrv.Close() client, _ := slurm.NewClientWithOpts(slurmSrv.URL, slurm.WithHTTPClient(slurmSrv.Client())) - templateStore := store.NewTemplateStore(newTestDB()) + jobSvc := service.NewJobService(client, zap.NewNop()) + appStore := store.NewApplicationStore(newTestDB()) + appSvc := service.NewApplicationService(appStore, jobSvc, "", zap.NewNop()) + appH := handler.NewApplicationHandler(appSvc, zap.NewNop()) router := server.NewRouter( - handler.NewJobHandler(service.NewJobService(client, zap.NewNop()), zap.NewNop()), + handler.NewJobHandler(jobSvc, zap.NewNop()), handler.NewClusterHandler(service.NewClusterService(client, zap.NewNop()), zap.NewNop()), - handler.NewTemplateHandler(templateStore, zap.NewNop()), + appH, nil, ) diff --git a/internal/handler/application.go b/internal/handler/application.go new file mode 100644 index 0000000..ae8534f --- /dev/null +++ b/internal/handler/application.go @@ -0,0 +1,174 @@ +package handler + +import ( + "encoding/json" + "errors" + "strconv" + "strings" + + "gcy_hpc_server/internal/model" + "gcy_hpc_server/internal/server" + "gcy_hpc_server/internal/service" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "gorm.io/gorm" +) + +type ApplicationHandler struct { + appSvc *service.ApplicationService + logger *zap.Logger +} + +func NewApplicationHandler(appSvc *service.ApplicationService, logger *zap.Logger) *ApplicationHandler { + return &ApplicationHandler{appSvc: appSvc, logger: logger} +} + +func (h *ApplicationHandler) ListApplications(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 + } + + apps, total, err := h.appSvc.ListApplications(c.Request.Context(), page, pageSize) + if err != nil { + h.logger.Error("failed to list applications", zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + + server.OK(c, gin.H{ + "applications": apps, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +func (h *ApplicationHandler) CreateApplication(c *gin.Context) { + var req model.CreateApplicationRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("invalid request body for create application", zap.Error(err)) + server.BadRequest(c, "invalid request body") + return + } + if req.Name == "" || req.ScriptTemplate == "" { + h.logger.Warn("missing required fields for create application") + server.BadRequest(c, "name and script_template are required") + return + } + if len(req.Parameters) == 0 { + req.Parameters = json.RawMessage(`[]`) + } + + id, err := h.appSvc.CreateApplication(c.Request.Context(), &req) + if err != nil { + h.logger.Error("failed to create application", zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + h.logger.Info("application created", zap.Int64("id", id)) + server.Created(c, gin.H{"id": id}) +} + +func (h *ApplicationHandler) GetApplication(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid application id", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid id") + return + } + app, err := h.appSvc.GetApplication(c.Request.Context(), id) + if err != nil { + h.logger.Error("failed to get application", zap.Int64("id", id), zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + if app == nil { + h.logger.Warn("application not found", zap.Int64("id", id)) + server.NotFound(c, "application not found") + return + } + server.OK(c, app) +} + +func (h *ApplicationHandler) UpdateApplication(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid application id for update", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid id") + return + } + var req model.UpdateApplicationRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("invalid request body for update application", zap.Int64("id", id), zap.Error(err)) + server.BadRequest(c, "invalid request body") + return + } + if err := h.appSvc.UpdateApplication(c.Request.Context(), id, &req); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + h.logger.Warn("application not found for update", zap.Int64("id", id)) + server.NotFound(c, "application not found") + return + } + h.logger.Error("failed to update application", zap.Int64("id", id), zap.Error(err)) + server.InternalError(c, "failed to update application") + return + } + h.logger.Info("application updated", zap.Int64("id", id)) + server.OK(c, gin.H{"message": "application updated"}) +} + +func (h *ApplicationHandler) DeleteApplication(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid application id for delete", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid id") + return + } + if err := h.appSvc.DeleteApplication(c.Request.Context(), id); err != nil { + h.logger.Error("failed to delete application", zap.Int64("id", id), zap.Error(err)) + server.InternalError(c, err.Error()) + return + } + h.logger.Info("application deleted", zap.Int64("id", id)) + server.OK(c, gin.H{"message": "application deleted"}) +} + +func (h *ApplicationHandler) SubmitApplication(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + h.logger.Warn("invalid application id for submit", zap.String("id", c.Param("id"))) + server.BadRequest(c, "invalid id") + return + } + var req model.ApplicationSubmitRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.logger.Warn("invalid request body for submit application", zap.Int64("id", id), zap.Error(err)) + server.BadRequest(c, "invalid request body") + return + } + resp, err := h.appSvc.SubmitFromApplication(c.Request.Context(), id, req.Values) + if err != nil { + errStr := err.Error() + if strings.Contains(errStr, "not found") { + h.logger.Warn("application not found for submit", zap.Int64("id", id)) + server.NotFound(c, errStr) + return + } + if strings.Contains(errStr, "validation") { + h.logger.Warn("application submit validation failed", zap.Int64("id", id), zap.Error(err)) + server.BadRequest(c, errStr) + return + } + h.logger.Error("failed to submit application", zap.Int64("id", id), zap.Error(err)) + server.InternalError(c, errStr) + return + } + h.logger.Info("application submitted", zap.Int64("id", id), zap.Int32("job_id", resp.JobID)) + server.Created(c, resp) +} diff --git a/internal/handler/application_test.go b/internal/handler/application_test.go new file mode 100644 index 0000000..dbc7fa1 --- /dev/null +++ b/internal/handler/application_test.go @@ -0,0 +1,630 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gcy_hpc_server/internal/model" + "gcy_hpc_server/internal/service" + "gcy_hpc_server/internal/slurm" + "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" + gormlogger "gorm.io/gorm/logger" +) + +func itoa(id int64) string { + return fmt.Sprintf("%d", id) +} + +func setupApplicationHandler() (*ApplicationHandler, *gorm.DB) { + db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + db.AutoMigrate(&model.Application{}) + appStore := store.NewApplicationStore(db) + appSvc := service.NewApplicationService(appStore, nil, "", zap.NewNop()) + h := NewApplicationHandler(appSvc, zap.NewNop()) + return h, db +} + +func setupApplicationRouter(h *ApplicationHandler) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + v1 := r.Group("/api/v1") + apps := v1.Group("/applications") + apps.GET("", h.ListApplications) + apps.POST("", h.CreateApplication) + apps.GET("/:id", h.GetApplication) + apps.PUT("/:id", h.UpdateApplication) + apps.DELETE("/:id", h.DeleteApplication) + apps.POST("/:id/submit", h.SubmitApplication) + return r +} + +func setupApplicationHandlerWithSlurm(slurmHandler http.HandlerFunc) (*ApplicationHandler, func()) { + srv := httptest.NewServer(slurmHandler) + client, _ := slurm.NewClient(srv.URL, srv.Client()) + + db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + db.AutoMigrate(&model.Application{}) + + jobSvc := service.NewJobService(client, zap.NewNop()) + appStore := store.NewApplicationStore(db) + appSvc := service.NewApplicationService(appStore, jobSvc, "", zap.NewNop()) + h := NewApplicationHandler(appSvc, zap.NewNop()) + return h, srv.Close +} + +func setupApplicationHandlerWithObserver() (*ApplicationHandler, *gorm.DB, *observer.ObservedLogs) { + core, recorded := observer.New(zapcore.DebugLevel) + l := zap.New(core) + db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + db.AutoMigrate(&model.Application{}) + appStore := store.NewApplicationStore(db) + appSvc := service.NewApplicationService(appStore, nil, "", l) + return NewApplicationHandler(appSvc, l), db, recorded +} + +func createTestApplication(h *ApplicationHandler, r *gin.Engine) int64 { + body, _ := json.Marshal(model.CreateApplicationRequest{ + Name: "test-app", + ScriptTemplate: "#!/bin/bash\necho hello", + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + data := resp["data"].(map[string]interface{}) + return int64(data["id"].(float64)) +} + +// ---- CRUD Tests ---- + +func TestCreateApplication_Success(t *testing.T) { + h, _ := setupApplicationHandler() + r := setupApplicationRouter(h) + + body, _ := json.Marshal(model.CreateApplicationRequest{ + Name: "my-app", + ScriptTemplate: "#!/bin/bash\necho hello", + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", bytes.NewReader(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 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 _, ok := data["id"]; !ok { + t.Fatal("expected id in response data") + } +} + +func TestCreateApplication_MissingName(t *testing.T) { + h, _ := setupApplicationHandler() + r := setupApplicationRouter(h) + + body, _ := json.Marshal(model.CreateApplicationRequest{ + ScriptTemplate: "#!/bin/bash\necho hello", + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCreateApplication_MissingScriptTemplate(t *testing.T) { + h, _ := setupApplicationHandler() + r := setupApplicationRouter(h) + + body, _ := json.Marshal(model.CreateApplicationRequest{ + Name: "my-app", + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestCreateApplication_EmptyParameters(t *testing.T) { + h, db := setupApplicationHandler() + r := setupApplicationRouter(h) + + body := `{"name":"empty-params-app","script_template":"#!/bin/bash\necho hello"}` + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", strings.NewReader(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 app model.Application + db.First(&app) + if string(app.Parameters) != "[]" { + t.Fatalf("expected parameters to default to [], got %s", string(app.Parameters)) + } +} + +func TestListApplications_Success(t *testing.T) { + h, _ := setupApplicationHandler() + r := setupApplicationRouter(h) + + createTestApplication(h, r) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/applications", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + 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["total"].(float64) < 1 { + t.Fatal("expected at least 1 application") + } +} + +func TestListApplications_Pagination(t *testing.T) { + h, _ := setupApplicationHandler() + r := setupApplicationRouter(h) + + for i := 0; i < 5; i++ { + body, _ := json.Marshal(model.CreateApplicationRequest{ + Name: fmt.Sprintf("app-%d", i), + ScriptTemplate: "#!/bin/bash\necho " + fmt.Sprintf("app-%d", i), + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + } + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/applications?page=1&page_size=2", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var resp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + data := resp["data"].(map[string]interface{}) + if data["total"].(float64) != 5 { + t.Fatalf("expected total=5, got %v", data["total"]) + } + if data["page"].(float64) != 1 { + t.Fatalf("expected page=1, got %v", data["page"]) + } + if data["page_size"].(float64) != 2 { + t.Fatalf("expected page_size=2, got %v", data["page_size"]) + } +} + +func TestGetApplication_Success(t *testing.T) { + h, _ := setupApplicationHandler() + r := setupApplicationRouter(h) + + id := createTestApplication(h, r) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/applications/"+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 map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &resp) + data := resp["data"].(map[string]interface{}) + if data["name"] != "test-app" { + t.Fatalf("expected name=test-app, got %v", data["name"]) + } +} + +func TestGetApplication_NotFound(t *testing.T) { + h, _ := setupApplicationHandler() + r := setupApplicationRouter(h) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/applications/999", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestGetApplication_InvalidID(t *testing.T) { + h, _ := setupApplicationHandler() + r := setupApplicationRouter(h) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/applications/abc", nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestUpdateApplication_Success(t *testing.T) { + h, _ := setupApplicationHandler() + r := setupApplicationRouter(h) + + id := createTestApplication(h, r) + + newName := "updated-app" + body, _ := json.Marshal(model.UpdateApplicationRequest{ + Name: &newName, + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPut, "/api/v1/applications/"+itoa(id), bytes.NewReader(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()) + } +} + +func TestUpdateApplication_NotFound(t *testing.T) { + h, _ := setupApplicationHandler() + r := setupApplicationRouter(h) + + newName := "updated-app" + body, _ := json.Marshal(model.UpdateApplicationRequest{ + Name: &newName, + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPut, "/api/v1/applications/999", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestDeleteApplication_Success(t *testing.T) { + h, _ := setupApplicationHandler() + r := setupApplicationRouter(h) + + id := createTestApplication(h, r) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/applications/"+itoa(id), nil) + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodGet, "/api/v1/applications/"+itoa(id), nil) + r.ServeHTTP(w2, req2) + + if w2.Code != http.StatusNotFound { + t.Fatalf("expected 404 after delete, got %d", w2.Code) + } +} + +// ---- Submit Tests ---- + +func TestSubmitApplication_Success(t *testing.T) { + slurmHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "job_id": 12345, + }) + }) + h, cleanup := setupApplicationHandlerWithSlurm(slurmHandler) + defer cleanup() + r := setupApplicationRouter(h) + + params := `[{"name":"COUNT","type":"integer","required":true}]` + body := `{"name":"submit-app","script_template":"#!/bin/bash\necho $COUNT","parameters":` + params + `}` + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + var createResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &createResp) + data := createResp["data"].(map[string]interface{}) + id := int64(data["id"].(float64)) + + submitBody := `{"values":{"COUNT":"5"}}` + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodPost, "/api/v1/applications/"+itoa(id)+"/submit", strings.NewReader(submitBody)) + req2.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w2, req2) + + if w2.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w2.Code, w2.Body.String()) + } +} + +func TestSubmitApplication_AppNotFound(t *testing.T) { + slurmHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{}) + }) + h, cleanup := setupApplicationHandlerWithSlurm(slurmHandler) + defer cleanup() + r := setupApplicationRouter(h) + + submitBody := `{"values":{"COUNT":"5"}}` + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications/999/submit", strings.NewReader(submitBody)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestSubmitApplication_ValidationFail(t *testing.T) { + slurmHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{}) + }) + h, cleanup := setupApplicationHandlerWithSlurm(slurmHandler) + defer cleanup() + r := setupApplicationRouter(h) + + params := `[{"name":"COUNT","type":"integer","required":true}]` + body := `{"name":"val-app","script_template":"#!/bin/bash\necho $COUNT","parameters":` + params + `}` + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + var createResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &createResp) + data := createResp["data"].(map[string]interface{}) + id := int64(data["id"].(float64)) + + submitBody := `{"values":{"COUNT":"not-a-number"}}` + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodPost, "/api/v1/applications/"+itoa(id)+"/submit", strings.NewReader(submitBody)) + req2.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w2, req2) + + if w2.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d: %s", w2.Code, w2.Body.String()) + } +} + +// ---- Logging Tests ---- + +func TestApplicationLogging_CreateSuccess_LogsInfoWithID(t *testing.T) { + h, _, recorded := setupApplicationHandlerWithObserver() + r := setupApplicationRouter(h) + + body, _ := json.Marshal(model.CreateApplicationRequest{ + Name: "log-app", + ScriptTemplate: "#!/bin/bash\necho hello", + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + found := false + for _, entry := range recorded.All() { + if entry.Message == "application created" { + found = true + break + } + } + if !found { + t.Fatal("expected 'application created' log message") + } +} + +func TestApplicationLogging_GetNotFound_LogsWarnWithID(t *testing.T) { + h, _, recorded := setupApplicationHandlerWithObserver() + r := setupApplicationRouter(h) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/api/v1/applications/999", nil) + r.ServeHTTP(w, req) + + found := false + for _, entry := range recorded.All() { + if entry.Message == "application not found" { + found = true + break + } + } + if !found { + t.Fatal("expected 'application not found' log message") + } +} + +func TestApplicationLogging_UpdateSuccess_LogsInfoWithID(t *testing.T) { + h, _, recorded := setupApplicationHandlerWithObserver() + r := setupApplicationRouter(h) + + id := createTestApplication(h, r) + recorded.TakeAll() + + newName := "updated-log-app" + body, _ := json.Marshal(model.UpdateApplicationRequest{ + Name: &newName, + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPut, "/api/v1/applications/"+itoa(id), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + found := false + for _, entry := range recorded.All() { + if entry.Message == "application updated" { + found = true + break + } + } + if !found { + t.Fatal("expected 'application updated' log message") + } +} + +func TestApplicationLogging_DeleteSuccess_LogsInfoWithID(t *testing.T) { + h, _, recorded := setupApplicationHandlerWithObserver() + r := setupApplicationRouter(h) + + id := createTestApplication(h, r) + recorded.TakeAll() + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/applications/"+itoa(id), nil) + r.ServeHTTP(w, req) + + found := false + for _, entry := range recorded.All() { + if entry.Message == "application deleted" { + found = true + break + } + } + if !found { + t.Fatal("expected 'application deleted' log message") + } +} + +func TestApplicationLogging_SubmitSuccess_LogsInfoWithID(t *testing.T) { + core, recorded := observer.New(zapcore.DebugLevel) + l := zap.New(core) + + slurmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "job_id": 42, + }) + })) + defer slurmSrv.Close() + + client, _ := slurm.NewClient(slurmSrv.URL, slurmSrv.Client()) + db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + db.AutoMigrate(&model.Application{}) + jobSvc := service.NewJobService(client, l) + appStore := store.NewApplicationStore(db) + appSvc := service.NewApplicationService(appStore, jobSvc, "", l) + h := NewApplicationHandler(appSvc, l) + r := setupApplicationRouter(h) + + params := `[{"name":"X","type":"string","required":false}]` + body := `{"name":"sub-log-app","script_template":"#!/bin/bash\necho $X","parameters":` + params + `}` + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + var createResp map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &createResp) + data := createResp["data"].(map[string]interface{}) + id := int64(data["id"].(float64)) + recorded.TakeAll() + + submitBody := `{"values":{"X":"val"}}` + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodPost, "/api/v1/applications/"+itoa(id)+"/submit", strings.NewReader(submitBody)) + req2.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w2, req2) + + found := false + for _, entry := range recorded.All() { + if entry.Message == "application submitted" { + found = true + break + } + } + if !found { + t.Fatal("expected 'application submitted' log message") + } +} + +func TestApplicationLogging_CreateBadRequest_LogsWarn(t *testing.T) { + h, _, recorded := setupApplicationHandlerWithObserver() + r := setupApplicationRouter(h) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", strings.NewReader(`{"name":""}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + found := false + for _, entry := range recorded.All() { + if entry.Message == "invalid request body for create application" { + found = true + break + } + } + if !found { + t.Fatal("expected 'invalid request body for create application' log message") + } +} + +func TestApplicationLogging_LogsDoNotContainApplicationContent(t *testing.T) { + h, _, recorded := setupApplicationHandlerWithObserver() + r := setupApplicationRouter(h) + + body, _ := json.Marshal(model.CreateApplicationRequest{ + Name: "secret-app", + ScriptTemplate: "#!/bin/bash\necho secret_password_here", + }) + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/api/v1/applications", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + for _, entry := range recorded.All() { + msg := entry.Message + if strings.Contains(msg, "secret_password_here") { + t.Fatalf("log message contains application content: %s", msg) + } + for _, field := range entry.Context { + if strings.Contains(field.String, "secret_password_here") { + t.Fatalf("log field contains application content: %s", field.String) + } + } + } +}