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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
76
internal/model/application.go
Normal file
76
internal/model/application.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
114
internal/store/application_store.go
Normal file
114
internal/store/application_store.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
227
internal/store/application_store_test.go
Normal file
227
internal/store/application_store_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user