Files
sm-golem-plugin/golem_admin_test.go
Jakub Zych 9f60b6138c fix(14): WR-05 import every key of an object-shaped models repeater
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.
2026-10-04 08:08:44 +02:00

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