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