A repeater saved with non-sequential keys was read only at keys 0 to len-1, so a model at a higher key was dropped while the import reported success and refused to run again. Every key is now read, numeric keys in ascending order.
187 lines
7.6 KiB
Go
187 lines
7.6 KiB
Go
package golem
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/sm-golem-plugin/console"
|
|
"git.golem15.com/golem15/sm-golem-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-golem-plugin/internal/pgtest"
|
|
"git.golem15.com/golem15/sm-golem-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
)
|
|
|
|
// TestGolemAdminSchemas compiles the AI models admin YAML under cabana: the
|
|
// list shows no key column, the form's api_key is a password input (write
|
|
// only, T-14-24), the screen requires golem15.golem.access_settings, the
|
|
// model hides its key from every serialization, and the plugin hands its
|
|
// migrations and models to the framework.
|
|
func TestGolemAdminSchemas(t *testing.T) {
|
|
p := &Plugin{}
|
|
ctl := p.AdminControllers()[0]
|
|
list, err := cabana.CompileList("golem15.golem", ctl, p.AdminFS())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
form, err := cabana.CompileForm("golem15.golem", ctl, p.AdminFS())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
listJSON, _ := json.Marshal(list)
|
|
formJSON, _ := json.Marshal(form)
|
|
if bytes.Contains(listJSON, []byte("api_key")) {
|
|
t.Fatalf("the list shows the key: %s", listJSON)
|
|
}
|
|
for _, col := range []string{`"name"`, `"adapter"`, `"model"`, `"is_enabled"`, `"sort_order"`} {
|
|
if !bytes.Contains(listJSON, []byte(col)) {
|
|
t.Errorf("the list lacks %s: %s", col, listJSON)
|
|
}
|
|
}
|
|
if !bytes.Contains(formJSON, []byte(`"api_key"`)) || !bytes.Contains(formJSON, []byte(`"password"`)) {
|
|
t.Fatalf("the form's api_key is not a password input: %s", formJSON)
|
|
}
|
|
type permissioned interface{ RequiredPermissions() []string }
|
|
if pc, ok := ctl.(permissioned); !ok || strings.Join(pc.RequiredPermissions(), ",") != controllers.PermissionAccessSettings {
|
|
t.Fatalf("the models screen permissions = %v", ctl)
|
|
}
|
|
if hidden := (models.AiModel{}).Hidden(); strings.Join(hidden, ",") != "api_key" {
|
|
t.Errorf("Hidden() = %v", hidden)
|
|
}
|
|
m := models.AiModel{Name: "Hidden", APIKey: lagoon.NewEncrypted("sk-hidden-0001")}
|
|
raw, _ := json.Marshal(m)
|
|
if strings.Contains(string(raw), "sk-hidden-0001") || strings.Contains(string(raw), "api_key") {
|
|
t.Fatalf("the model serializes its key, even redacted: %s", raw)
|
|
}
|
|
if len(models.All()) == 0 || len(p.Models()) == 0 || len(p.Migrations()) == 0 {
|
|
t.Fatal("the plugin hands no models or migrations to the framework")
|
|
}
|
|
|
|
// The admin hooks refuse a record of another type, and an update
|
|
// outside cabana's transaction.
|
|
c := controllers.ModelsAdminController{}
|
|
if err := c.FormBeforeCreate(context.Background(), &struct{}{}); err == nil {
|
|
t.Error("FormBeforeCreate accepted a foreign record")
|
|
}
|
|
if err := c.FormBeforeUpdate(context.Background(), &struct{}{}); err == nil {
|
|
t.Error("FormBeforeUpdate accepted a foreign record")
|
|
}
|
|
if err := c.FormBeforeUpdate(context.Background(), &models.AiModel{}); err == nil {
|
|
t.Error("FormBeforeUpdate ran without a transaction")
|
|
}
|
|
if err := c.FormBeforeUpdate(context.Background(), &models.AiModel{APIKey: lagoon.NewEncrypted("sk-new-0001")}); err != nil {
|
|
t.Errorf("a new key needs no stored one: %v", err)
|
|
}
|
|
}
|
|
|
|
// stubInput is a bonfire.Input with one --file flag.
|
|
type stubInput struct{ file string }
|
|
|
|
func (s stubInput) Args() []string { return nil }
|
|
func (s stubInput) Argument(string) (string, bool) { return "", false }
|
|
func (s stubInput) Flags(string) []string { return nil }
|
|
func (s stubInput) Flag(name string) (string, bool) { return s.file, name == "file" && s.file != "" }
|
|
|
|
// TestGolemImportCommand runs golem:import-settings as the CLI does: it
|
|
// refuses a missing app or database, reports a clean notice as info and
|
|
// exit 0, imports a file, and fails on unreadable or malformed settings,
|
|
// reading every repeater shape and scalar form PHP stores.
|
|
func TestGolemImportCommand(t *testing.T) {
|
|
ctx := context.Background()
|
|
run := func(app *backpack.App, file string) (string, error) {
|
|
var buf bytes.Buffer
|
|
err := console.ImportSettings(app).Run(ctx, stubInput{file: file}, bonfire.NewOutput(nil, &buf, &buf))
|
|
return buf.String(), err
|
|
}
|
|
if _, err := run(nil, ""); err == nil {
|
|
t.Fatal("a nil app must fail")
|
|
}
|
|
bare := backpack.New(testConfig(t))
|
|
if _, err := run(bare, ""); err == nil {
|
|
t.Fatal("an app without a database must fail")
|
|
}
|
|
db := pgtest.DB(t)
|
|
app := backpack.New(testConfig(t))
|
|
if err := lagoon.Publish(app, pgtest.SQL(t), db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec(`DROP TABLE IF EXISTS system_settings`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := run(app, ""); err != nil || !strings.Contains(out, "nothing to import") {
|
|
t.Fatalf("a missing settings table = %q %v", out, err)
|
|
}
|
|
dir := t.TempDir()
|
|
write := func(name, body string) string {
|
|
path := filepath.Join(dir, name)
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
// A repeater saved with numbered keys, scalars as numbers and booleans.
|
|
numbered := write("numbered.json", `{"models":{"0":{"name":"Zero","model":"m0","api_key":"k0","is_enabled":1,"is_default":true,"accepts_images":"1","max_completion_tokens":2048},`+
|
|
`"1":{"name":7,"model":true,"api_key":"k1","is_enabled":0,"generates_images":"0","max_completion_tokens":"x"},"2":"not an item"}}`)
|
|
out, err := run(app, numbered)
|
|
if err != nil || !strings.Contains(out, "Imported 2 AI models.") {
|
|
t.Fatalf("numbered import = %q %v", out, err)
|
|
}
|
|
var rows []models.AiModel
|
|
if err := db.Order("sort_order").Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 2 || !rows[0].IsEnabled || !rows[0].IsDefault || !rows[0].AcceptsImages || *rows[0].MaxCompletionTokens != 2048 ||
|
|
rows[1].Name != "7" || rows[1].Model != "1" || rows[1].IsEnabled || rows[1].MaxCompletionTokens != nil {
|
|
t.Fatalf("numbered rows = %+v", rows)
|
|
}
|
|
if out, err := run(app, numbered); !errors.Is(err, console.ErrModelsNotEmpty) || !strings.Contains(out, "already has rows") {
|
|
t.Fatalf("a second import = %q %v", out, err)
|
|
}
|
|
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A repeater whose middle item was deleted keeps every remaining key,
|
|
// in numeric order (WR-05).
|
|
sparse := write("sparse.json", `{"models":{"10":{"name":"Ten","model":"m10","api_key":"k10"},"0":{"name":"Zero","model":"m0","api_key":"k0"},"2":{"name":"Two","model":"m2","api_key":"k2"}}}`)
|
|
if out, err := run(app, sparse); err != nil || !strings.Contains(out, "Imported 3 AI models.") {
|
|
t.Fatalf("sparse import = %q %v", out, err)
|
|
}
|
|
rows = nil
|
|
if err := db.Order("sort_order").Find(&rows).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(rows) != 3 || rows[0].Name != "Zero" || rows[1].Name != "Two" || rows[2].Name != "Ten" {
|
|
t.Fatalf("sparse rows = %+v", rows)
|
|
}
|
|
if err := db.Exec(`DELETE FROM golem15_golem_models`).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, body := range map[string]string{"empty.json": `{"models":[]}`, "null.json": `{"models":null}`} {
|
|
if out, err := run(app, write(name, body)); err != nil || !strings.Contains(out, "no AI models") {
|
|
t.Errorf("%s = %q %v", name, out, err)
|
|
}
|
|
}
|
|
for name, body := range map[string]string{"bad.json": `{not json`, "scalar.json": `{"models":"nope"}`} {
|
|
if _, err := run(app, write(name, body)); err == nil {
|
|
t.Errorf("%s was imported", name)
|
|
}
|
|
}
|
|
if _, err := run(app, filepath.Join(dir, "missing.json")); err == nil {
|
|
t.Error("a missing file was imported")
|
|
}
|
|
if pgtest.SQL(t) == nil {
|
|
t.Fatal("pgtest.SQL")
|
|
}
|
|
}
|