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:
dailz
2026-04-13 17:10:54 +08:00
parent d3eb728c2f
commit 10bb15e5b2
3 changed files with 823 additions and 12 deletions

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