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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
func newTestDB() *gorm.DB {
|
func newTestDB() *gorm.DB {
|
||||||
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||||
db.AutoMigrate(&model.JobTemplate{})
|
db.AutoMigrate(&model.Application{})
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,12 +34,15 @@ func TestRouterRegistration(t *testing.T) {
|
|||||||
defer slurmSrv.Close()
|
defer slurmSrv.Close()
|
||||||
|
|
||||||
client, _ := slurm.NewClientWithOpts(slurmSrv.URL, slurm.WithHTTPClient(slurmSrv.Client()))
|
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(
|
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.NewClusterHandler(service.NewClusterService(client, zap.NewNop()), zap.NewNop()),
|
||||||
handler.NewTemplateHandler(templateStore, zap.NewNop()),
|
appH,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,11 +61,12 @@ func TestRouterRegistration(t *testing.T) {
|
|||||||
{"GET", "/api/v1/partitions"},
|
{"GET", "/api/v1/partitions"},
|
||||||
{"GET", "/api/v1/partitions/:name"},
|
{"GET", "/api/v1/partitions/:name"},
|
||||||
{"GET", "/api/v1/diag"},
|
{"GET", "/api/v1/diag"},
|
||||||
{"GET", "/api/v1/templates"},
|
{"GET", "/api/v1/applications"},
|
||||||
{"POST", "/api/v1/templates"},
|
{"POST", "/api/v1/applications"},
|
||||||
{"GET", "/api/v1/templates/:id"},
|
{"GET", "/api/v1/applications/:id"},
|
||||||
{"PUT", "/api/v1/templates/:id"},
|
{"PUT", "/api/v1/applications/:id"},
|
||||||
{"DELETE", "/api/v1/templates/:id"},
|
{"DELETE", "/api/v1/applications/:id"},
|
||||||
|
{"POST", "/api/v1/applications/:id/submit"},
|
||||||
}
|
}
|
||||||
|
|
||||||
routeMap := map[string]bool{}
|
routeMap := map[string]bool{}
|
||||||
@@ -90,12 +94,15 @@ func TestSmokeGetJobsEndpoint(t *testing.T) {
|
|||||||
defer slurmSrv.Close()
|
defer slurmSrv.Close()
|
||||||
|
|
||||||
client, _ := slurm.NewClientWithOpts(slurmSrv.URL, slurm.WithHTTPClient(slurmSrv.Client()))
|
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(
|
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.NewClusterHandler(service.NewClusterService(client, zap.NewNop()), zap.NewNop()),
|
||||||
handler.NewTemplateHandler(templateStore, zap.NewNop()),
|
appH,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
174
internal/handler/application.go
Normal file
174
internal/handler/application.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
630
internal/handler/application_test.go
Normal file
630
internal/handler/application_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user