From 4a8153aa6cc6bb7f0e5a53c0e651b3f57b4901da Mon Sep 17 00:00:00 2001 From: dailz Date: Mon, 13 Apr 2026 17:08:24 +0800 Subject: [PATCH] feat(model): add Application model and store Add Application and ParameterSchema models with CRUD store. Includes 10 store tests and ParamType constants. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/model/application.go | 76 ++++++++ internal/store/application_store.go | 114 ++++++++++++ internal/store/application_store_test.go | 227 +++++++++++++++++++++++ 3 files changed, 417 insertions(+) create mode 100644 internal/model/application.go create mode 100644 internal/store/application_store.go create mode 100644 internal/store/application_store_test.go diff --git a/internal/model/application.go b/internal/model/application.go new file mode 100644 index 0000000..39d292a --- /dev/null +++ b/internal/model/application.go @@ -0,0 +1,76 @@ +package model + +import ( + "encoding/json" + "time" +) + +// Parameter type constants for ParameterSchema.Type. +const ( + ParamTypeString = "string" + ParamTypeInteger = "integer" + ParamTypeEnum = "enum" + ParamTypeFile = "file" + ParamTypeDirectory = "directory" + ParamTypeBoolean = "boolean" +) + +// Application represents a parameterized application definition for HPC job submission. +type Application struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"uniqueIndex;size:255;not null" json:"name"` + Description string `gorm:"type:text" json:"description,omitempty"` + Icon string `gorm:"size:255" json:"icon,omitempty"` + Category string `gorm:"size:255" json:"category,omitempty"` + ScriptTemplate string `gorm:"type:text;not null" json:"script_template"` + Parameters json.RawMessage `gorm:"type:json" json:"parameters,omitempty"` + Scope string `gorm:"size:50;default:'system'" json:"scope,omitempty"` + CreatedBy int64 `json:"created_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Application) TableName() string { + return "applications" +} + +// ParameterSchema defines a single parameter in an application's form schema. +type ParameterSchema struct { + Name string `json:"name"` + Label string `json:"label,omitempty"` + Type string `json:"type"` + Required bool `json:"required,omitempty"` + Default string `json:"default,omitempty"` + Options []string `json:"options,omitempty"` + Description string `json:"description,omitempty"` +} + +// CreateApplicationRequest is the DTO for creating a new application. +type CreateApplicationRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + Category string `json:"category,omitempty"` + ScriptTemplate string `json:"script_template" binding:"required"` + Parameters json.RawMessage `json:"parameters,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// UpdateApplicationRequest is the DTO for updating an existing application. +// All fields are optional. Parameters uses *json.RawMessage to distinguish +// between "not provided" (nil) and "set to empty" (non-nil). +type UpdateApplicationRequest struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Icon *string `json:"icon,omitempty"` + Category *string `json:"category,omitempty"` + ScriptTemplate *string `json:"script_template,omitempty"` + Parameters *json.RawMessage `json:"parameters,omitempty"` + Scope *string `json:"scope,omitempty"` +} + +// ApplicationSubmitRequest is the DTO for submitting a job from an application. +// ApplicationID is parsed from the URL :id parameter, not included in the body. +type ApplicationSubmitRequest struct { + Values map[string]string `json:"values" binding:"required"` +} diff --git a/internal/store/application_store.go b/internal/store/application_store.go new file mode 100644 index 0000000..6d0600a --- /dev/null +++ b/internal/store/application_store.go @@ -0,0 +1,114 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + "gcy_hpc_server/internal/model" + + "gorm.io/gorm" +) + +type ApplicationStore struct { + db *gorm.DB +} + +func NewApplicationStore(db *gorm.DB) *ApplicationStore { + return &ApplicationStore{db: db} +} + +func (s *ApplicationStore) List(ctx context.Context, page, pageSize int) ([]model.Application, int, error) { + var apps []model.Application + var total int64 + + if err := s.db.WithContext(ctx).Model(&model.Application{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + offset := (page - 1) * pageSize + if err := s.db.WithContext(ctx).Order("id DESC").Limit(pageSize).Offset(offset).Find(&apps).Error; err != nil { + return nil, 0, err + } + + return apps, int(total), nil +} + +func (s *ApplicationStore) GetByID(ctx context.Context, id int64) (*model.Application, error) { + var app model.Application + err := s.db.WithContext(ctx).First(&app, id).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &app, nil +} + +func (s *ApplicationStore) Create(ctx context.Context, req *model.CreateApplicationRequest) (int64, error) { + params := req.Parameters + if len(params) == 0 { + params = json.RawMessage(`[]`) + } + + app := &model.Application{ + Name: req.Name, + Description: req.Description, + Icon: req.Icon, + Category: req.Category, + ScriptTemplate: req.ScriptTemplate, + Parameters: params, + Scope: req.Scope, + } + if err := s.db.WithContext(ctx).Create(app).Error; err != nil { + return 0, err + } + return app.ID, nil +} + +func (s *ApplicationStore) Update(ctx context.Context, id int64, req *model.UpdateApplicationRequest) error { + updates := map[string]interface{}{} + if req.Name != nil { + updates["name"] = *req.Name + } + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Icon != nil { + updates["icon"] = *req.Icon + } + if req.Category != nil { + updates["category"] = *req.Category + } + if req.ScriptTemplate != nil { + updates["script_template"] = *req.ScriptTemplate + } + if req.Parameters != nil { + updates["parameters"] = *req.Parameters + } + if req.Scope != nil { + updates["scope"] = *req.Scope + } + + if len(updates) == 0 { + return nil + } + + result := s.db.WithContext(ctx).Model(&model.Application{}).Where("id = ?", id).Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (s *ApplicationStore) Delete(ctx context.Context, id int64) error { + result := s.db.WithContext(ctx).Delete(&model.Application{}, id) + if result.Error != nil { + return result.Error + } + return nil +} diff --git a/internal/store/application_store_test.go b/internal/store/application_store_test.go new file mode 100644 index 0000000..81a4a2b --- /dev/null +++ b/internal/store/application_store_test.go @@ -0,0 +1,227 @@ +package store + +import ( + "context" + "testing" + + "gcy_hpc_server/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func newAppTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Application{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + return db +} + +func TestApplicationStore_Create_Success(t *testing.T) { + db := newAppTestDB(t) + s := NewApplicationStore(db) + + id, err := s.Create(context.Background(), &model.CreateApplicationRequest{ + Name: "gromacs", + Description: "Molecular dynamics simulator", + Category: "simulation", + ScriptTemplate: "#!/bin/bash\nmodule load gromacs", + Parameters: []byte(`[{"name":"ntasks","type":"number","required":true}]`), + Scope: "system", + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if id <= 0 { + t.Errorf("Create() id = %d, want positive", id) + } +} + +func TestApplicationStore_GetByID_Success(t *testing.T) { + db := newAppTestDB(t) + s := NewApplicationStore(db) + + id, _ := s.Create(context.Background(), &model.CreateApplicationRequest{ + Name: "lammps", + ScriptTemplate: "#!/bin/bash\nmodule load lammps", + }) + + app, err := s.GetByID(context.Background(), id) + if err != nil { + t.Fatalf("GetByID() error = %v", err) + } + if app == nil { + t.Fatal("GetByID() returned nil, expected application") + } + if app.Name != "lammps" { + t.Errorf("Name = %q, want %q", app.Name, "lammps") + } + if app.ID != id { + t.Errorf("ID = %d, want %d", app.ID, id) + } +} + +func TestApplicationStore_GetByID_NotFound(t *testing.T) { + db := newAppTestDB(t) + s := NewApplicationStore(db) + + app, err := s.GetByID(context.Background(), 99999) + if err != nil { + t.Fatalf("GetByID() error = %v", err) + } + if app != nil { + t.Error("GetByID() expected nil for not-found, got non-nil") + } +} + +func TestApplicationStore_List_Pagination(t *testing.T) { + db := newAppTestDB(t) + s := NewApplicationStore(db) + + for i := 0; i < 5; i++ { + s.Create(context.Background(), &model.CreateApplicationRequest{ + Name: "app-" + string(rune('A'+i)), + ScriptTemplate: "#!/bin/bash\necho " + string(rune('A'+i)), + }) + } + + apps, total, err := s.List(context.Background(), 1, 3) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if total != 5 { + t.Errorf("total = %d, want 5", total) + } + if len(apps) != 3 { + t.Errorf("len(apps) = %d, want 3", len(apps)) + } + + apps2, total2, err := s.List(context.Background(), 2, 3) + if err != nil { + t.Fatalf("List() page 2 error = %v", err) + } + if total2 != 5 { + t.Errorf("total2 = %d, want 5", total2) + } + if len(apps2) != 2 { + t.Errorf("len(apps2) = %d, want 2", len(apps2)) + } +} + +func TestApplicationStore_Update_Success(t *testing.T) { + db := newAppTestDB(t) + s := NewApplicationStore(db) + + id, _ := s.Create(context.Background(), &model.CreateApplicationRequest{ + Name: "orig", + ScriptTemplate: "#!/bin/bash\necho original", + }) + + newName := "updated" + newDesc := "updated description" + err := s.Update(context.Background(), id, &model.UpdateApplicationRequest{ + Name: &newName, + Description: &newDesc, + }) + if err != nil { + t.Fatalf("Update() error = %v", err) + } + + app, _ := s.GetByID(context.Background(), id) + if app.Name != "updated" { + t.Errorf("Name = %q, want %q", app.Name, "updated") + } + if app.Description != "updated description" { + t.Errorf("Description = %q, want %q", app.Description, "updated description") + } +} + +func TestApplicationStore_Update_NotFound(t *testing.T) { + db := newAppTestDB(t) + s := NewApplicationStore(db) + + name := "nope" + err := s.Update(context.Background(), 99999, &model.UpdateApplicationRequest{ + Name: &name, + }) + if err == nil { + t.Fatal("Update() expected error for not-found, got nil") + } +} + +func TestApplicationStore_Delete_Success(t *testing.T) { + db := newAppTestDB(t) + s := NewApplicationStore(db) + + id, _ := s.Create(context.Background(), &model.CreateApplicationRequest{ + Name: "to-delete", + ScriptTemplate: "#!/bin/bash\necho bye", + }) + + err := s.Delete(context.Background(), id) + if err != nil { + t.Fatalf("Delete() error = %v", err) + } + + app, _ := s.GetByID(context.Background(), id) + if app != nil { + t.Error("GetByID() after delete returned non-nil") + } +} + +func TestApplicationStore_Delete_Idempotent(t *testing.T) { + db := newAppTestDB(t) + s := NewApplicationStore(db) + + err := s.Delete(context.Background(), 99999) + if err != nil { + t.Fatalf("Delete() non-existent error = %v, want nil (idempotent)", err) + } +} + +func TestApplicationStore_Create_DuplicateName(t *testing.T) { + db := newAppTestDB(t) + s := NewApplicationStore(db) + + _, err := s.Create(context.Background(), &model.CreateApplicationRequest{ + Name: "dup-app", + ScriptTemplate: "#!/bin/bash\necho 1", + }) + if err != nil { + t.Fatalf("first Create() error = %v", err) + } + + _, err = s.Create(context.Background(), &model.CreateApplicationRequest{ + Name: "dup-app", + ScriptTemplate: "#!/bin/bash\necho 2", + }) + if err == nil { + t.Fatal("expected error for duplicate name, got nil") + } +} + +func TestApplicationStore_Create_EmptyParameters(t *testing.T) { + db := newAppTestDB(t) + s := NewApplicationStore(db) + + id, err := s.Create(context.Background(), &model.CreateApplicationRequest{ + Name: "no-params", + ScriptTemplate: "#!/bin/bash\necho hello", + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + app, _ := s.GetByID(context.Background(), id) + if string(app.Parameters) != "[]" { + t.Errorf("Parameters = %q, want []", string(app.Parameters)) + } +}