package party import ( "fmt" "io/fs" "os" "path/filepath" "strings" "testing" "testing/fstest" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/compass" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/phrasebook" "git.golem15.com/golem15/summercms/modules/postcard" ) type tracePlugin struct { id string req []string log *[]string } func (p *tracePlugin) ID() string { return p.id } func (p *tracePlugin) Requires() []string { return p.req } func (p *tracePlugin) Register(*backpack.App) error { *p.log = append(*p.log, p.id+":register") return nil } func (p *tracePlugin) Boot(*backpack.App) error { *p.log = append(*p.log, p.id+":boot") return nil } type configPlugin struct { id string fsys fs.FS got *int } func (p *configPlugin) ID() string { return p.id } func (p *configPlugin) Requires() []string { return nil } func (p *configPlugin) ConfigFS() fs.FS { return p.fsys } func (p *configPlugin) Register(app *backpack.App) error { if app != nil && app.Config != nil { *p.got = app.Config.Int("golem15.hello.posts_per_page") } return nil } func (p *configPlugin) Boot(*backpack.App) error { return nil } func TestActivateMergesHasConfigBeforeRegister(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: app\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Load(dir) if err != nil { t.Fatalf("Load: %v", err) } var got int p := &configPlugin{ id: "golem15.hello", got: &got, fsys: fstest.MapFS{ "config/config.yaml": {Data: []byte("posts_per_page: 10\n")}, }, } app := backpack.New(cfg) if _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}); err != nil { t.Fatalf("activate: %v", err) } if got != 10 { t.Fatalf("posts_per_page during Register = %d, want 10 (HasConfig must merge before Register)", got) } } func TestActivateMissingRequiresNamesIDsAndSkipsBoot(t *testing.T) { var log []string p := &tracePlugin{id: "golem15.greeter", req: []string{"golem15.hello"}, log: &log} app := backpack.New(&compass.Config{}) _, err := activate([]Plugin{p}, app, []string{"golem15.greeter"}) if err == nil { t.Fatal("expected missing dependency error") } msg := err.Error() if !strings.Contains(msg, "golem15.greeter") || !strings.Contains(msg, "golem15.hello") { t.Fatalf("error %q should name both plugin IDs", msg) } if strings.Contains(strings.Join(log, ","), "boot") { t.Fatalf("Boot ran despite missing dependency: %v", log) } } func TestActivateCycleNamesIDsAndSkipsBoot(t *testing.T) { var log []string a := &tracePlugin{id: "golem15.a", req: []string{"golem15.b"}, log: &log} b := &tracePlugin{id: "golem15.b", req: []string{"golem15.a"}, log: &log} app := backpack.New(&compass.Config{}) _, err := activate([]Plugin{a, b}, app, []string{"golem15.a", "golem15.b"}) if err == nil { t.Fatal("expected cycle error") } msg := err.Error() if !strings.Contains(msg, "golem15.a") || !strings.Contains(msg, "golem15.b") { t.Fatalf("error %q should name cycle members", msg) } if strings.Contains(strings.Join(log, ","), "boot") || strings.Contains(strings.Join(log, ","), "register") { t.Fatalf("lifecycle ran despite cycle: %v", log) } } func TestActivateDuplicateIDRejected(t *testing.T) { var log []string a := &tracePlugin{id: "golem15.hello", log: &log} dup := &tracePlugin{id: "golem15.hello", log: &log} app := backpack.New(&compass.Config{}) _, err := activate([]Plugin{a, dup}, app, []string{"golem15.hello"}) if err == nil { t.Fatal("expected duplicate id error") } if !strings.Contains(err.Error(), "golem15.hello") { t.Fatalf("error %q should name the duplicate id", err) } } type bootProbe struct { id string req []string log *[]string sawOpt *bool booted *[]string } func (p *bootProbe) ID() string { return p.id } func (p *bootProbe) Requires() []string { return p.req } func (p *bootProbe) Register(*backpack.App) error { *p.log = append(*p.log, p.id+":register") return nil } func (p *bootProbe) Boot(app *backpack.App) error { if p.sawOpt != nil && p.id == "golem15.hello" { *p.sawOpt = app.HasPlugin("golem15.optional") } *p.log = append(*p.log, p.id+":boot") if p.booted != nil { *p.booted = append(*p.booted, p.id) } return nil } func TestActivateHasPluginSeesNotYetBootedPlugins(t *testing.T) { var log []string var saw bool hello := &bootProbe{id: "golem15.hello", log: &log, sawOpt: &saw} opt := &bootProbe{id: "golem15.optional", log: &log} app := backpack.New(&compass.Config{}) if _, err := activate([]Plugin{opt, hello}, app, []string{"golem15.hello", "golem15.optional"}); err != nil { t.Fatalf("activate: %v", err) } if !saw { t.Fatal("HasPlugin(golem15.optional) was false during hello Boot; complete set must be visible before Boot") } want := []string{ "golem15.hello:register", "golem15.optional:register", "golem15.hello:boot", "golem15.optional:boot", } if strings.Join(log, ",") != strings.Join(want, ",") { t.Fatalf("lifecycle = %v, want %v", log, want) } } func TestActivateIndependentPluginsKeepManifestOrder(t *testing.T) { var log []string a := &tracePlugin{id: "golem15.a", log: &log} b := &tracePlugin{id: "golem15.b", log: &log} c := &tracePlugin{id: "golem15.c", log: &log} app := backpack.New(&compass.Config{}) got, err := activate([]Plugin{c, a, b}, app, []string{"golem15.c", "golem15.a", "golem15.b"}) if err != nil { t.Fatalf("activate: %v", err) } if len(got) != 3 || got[0].ID() != "golem15.c" || got[1].ID() != "golem15.a" || got[2].ID() != "golem15.b" { t.Fatalf("order = %v", idsOf(got)) } } func idsOf(plugins []Plugin) []string { out := make([]string, len(plugins)) for i, p := range plugins { out[i] = p.ID() } return out } func TestActivateRunsAllRegisterBeforeAnyBoot(t *testing.T) { var log []string hello := &tracePlugin{id: "golem15.hello", log: &log} greeter := &tracePlugin{id: "golem15.greeter", req: []string{"golem15.hello"}, log: &log} app := backpack.New(&compass.Config{}) got, err := activate([]Plugin{greeter, hello}, app, []string{"golem15.greeter", "golem15.hello"}) if err != nil { t.Fatalf("activate: %v", err) } if len(got) != 2 { t.Fatalf("got %d plugins, want 2", len(got)) } if got[0].ID() != "golem15.hello" || got[1].ID() != "golem15.greeter" { t.Fatalf("order = %s, %s", got[0].ID(), got[1].ID()) } want := []string{ "golem15.hello:register", "golem15.greeter:register", "golem15.hello:boot", "golem15.greeter:boot", } if strings.Join(log, ",") != strings.Join(want, ",") { t.Fatalf("lifecycle = %v, want %v", log, want) } } func TestActivateReorderedManifestStillTopoSorts(t *testing.T) { var log []string hello := &tracePlugin{id: "golem15.hello", log: &log} greeter := &tracePlugin{id: "golem15.greeter", req: []string{"golem15.hello"}, log: &log} extra := &tracePlugin{id: "golem15.extra", req: []string{"golem15.greeter"}, log: &log} app := backpack.New(&compass.Config{}) got, err := activate( []Plugin{extra, greeter, hello}, app, []string{"golem15.extra", "golem15.hello", "golem15.greeter"}, ) if err != nil { t.Fatalf("activate: %v", err) } if len(got) != 3 || got[0].ID() != "golem15.hello" || got[1].ID() != "golem15.greeter" || got[2].ID() != "golem15.extra" { t.Fatalf("order = %v, want hello, greeter, extra", idsOf(got)) } want := []string{ "golem15.hello:register", "golem15.greeter:register", "golem15.extra:register", "golem15.hello:boot", "golem15.greeter:boot", "golem15.extra:boot", } if strings.Join(log, ",") != strings.Join(want, ",") { t.Fatalf("lifecycle = %v, want %v", log, want) } } func TestActivateDuplicateManifestIDRejected(t *testing.T) { var log []string p := &tracePlugin{id: "golem15.hello", log: &log} app := backpack.New(&compass.Config{}) _, err := activate([]Plugin{p}, app, []string{"golem15.hello", "golem15.hello"}) if err == nil { t.Fatal("expected duplicate manifest id error") } if !strings.Contains(err.Error(), "golem15.hello") { t.Fatalf("error %q should name the duplicate id", err) } if len(log) != 0 { t.Fatalf("lifecycle ran despite duplicate manifest: %v", log) } } func TestActivateUnregisteredPluginNamesID(t *testing.T) { app := backpack.New(&compass.Config{}) _, err := activate(nil, app, []string{"golem15.missing"}) if err == nil { t.Fatal("expected missing registered plugin error") } if !strings.Contains(err.Error(), "golem15.missing") { t.Fatalf("error %q should name the missing id", err) } } func TestActivateEmptyManifestIDRejected(t *testing.T) { var log []string p := &tracePlugin{id: "golem15.hello", log: &log} app := backpack.New(&compass.Config{}) _, err := activate([]Plugin{p}, app, []string{""}) if err == nil { t.Fatal("expected empty plugin id error") } } func TestActivateNilAppRejected(t *testing.T) { _, err := Activate(nil, []string{"golem15.hello"}) if err == nil { t.Fatal("expected nil app error") } } func TestRegisterNilIsNoop(t *testing.T) { Register(nil) } func TestActivateHasPluginFalseForUnselected(t *testing.T) { var log []string hello := &tracePlugin{id: "golem15.hello", log: &log} opt := &tracePlugin{id: "golem15.optional", log: &log} app := backpack.New(&compass.Config{}) if _, err := activate([]Plugin{hello, opt}, app, []string{"golem15.hello"}); err != nil { t.Fatalf("activate: %v", err) } if !app.HasPlugin("golem15.hello") { t.Fatal("selected plugin must be present") } if app.HasPlugin("golem15.optional") { t.Fatal("unselected registered plugin must not appear in HasPlugin") } } type commandsPlugin struct { tracePlugin } func (p *commandsPlugin) Commands() []bonfire.Command { return []bonfire.Command{{ Name: "demo:run", Description: "demo command", }} } func TestActivateDiscoversHasConfigAndHasCommands(t *testing.T) { var log []string var got int cfgPlugin := &configPlugin{ id: "golem15.hello", got: &got, fsys: fstest.MapFS{ "config/config.yaml": {Data: []byte("posts_per_page: 4\n")}, }, } cmdPlugin := &commandsPlugin{tracePlugin: tracePlugin{id: "golem15.demo", log: &log}} plain := &tracePlugin{id: "golem15.plain", log: &log} dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: app\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Load(dir) if err != nil { t.Fatalf("Load: %v", err) } app := backpack.New(cfg) gotPlugins, err := activate([]Plugin{cfgPlugin, cmdPlugin, plain}, app, []string{"golem15.hello", "golem15.demo", "golem15.plain"}) if err != nil { t.Fatalf("activate: %v", err) } if got != 4 { t.Fatalf("HasConfig value during Register = %d, want 4", got) } var sawConfig, sawCommands, sawPlainCommands bool for _, p := range gotPlugins { if _, ok := p.(pact.HasConfig); ok && p.ID() == "golem15.hello" { sawConfig = true } if hc, ok := p.(pact.HasCommands); ok { if p.ID() == "golem15.demo" { cmds := hc.Commands() if len(cmds) != 1 || cmds[0].Name != "demo:run" { t.Fatalf("HasCommands commands = %+v", cmds) } sawCommands = true } if p.ID() == "golem15.plain" { sawPlainCommands = true } } } if !sawConfig { t.Fatal("golem15.hello should satisfy HasConfig") } if !sawCommands { t.Fatal("golem15.demo should satisfy HasCommands") } if sawPlainCommands { t.Fatal("plain plugin must not satisfy HasCommands") } if _, ok := any(plain).(pact.HasConfig); ok { t.Fatal("plain plugin must not satisfy HasConfig") } } type langPlugin struct { id string fsys fs.FS got *string } func (p *langPlugin) ID() string { return p.id } func (p *langPlugin) Requires() []string { return nil } func (p *langPlugin) Register(*backpack.App) error { return nil } func (p *langPlugin) LangFS() fs.FS { return p.fsys } func (p *langPlugin) Boot(app *backpack.App) error { if p.got == nil { return nil } tr, ok := app.Lookup[*phrasebook.Translator]() if !ok { return fmt.Errorf("translator missing at boot") } *p.got = tr.GetIn("en", p.id+"::lang.greeting", nil) return nil } func TestActivatePublishesTranslatorBeforeBoot(t *testing.T) { var got string p := &langPlugin{ id: "golem15.hello", got: &got, fsys: fstest.MapFS{ "lang/en/lang.yaml": {Data: []byte("greeting: Hello\n")}, }, } app := backpack.New(&compass.Config{}) if _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}); err != nil { t.Fatalf("activate: %v", err) } if got != "Hello" { t.Fatalf("boot lookup = %q, want Hello (translator must be published before Boot)", got) } } func TestActivateMalformedLangFailsWithPluginContext(t *testing.T) { p := &langPlugin{ id: "golem15.hello", fsys: fstest.MapFS{ "lang/en.yaml": {Data: []byte("greeting: Hello\n")}, }, } app := backpack.New(&compass.Config{}) _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}) if err == nil { t.Fatal("expected malformed catalog error") } msg := err.Error() if !strings.Contains(msg, "golem15.hello") || !strings.Contains(msg, "malformed path") { t.Fatalf("error %q should name the plugin and path problem", msg) } } func TestActivateMalformedLangLeafNamesFileAndKey(t *testing.T) { p := &langPlugin{ id: "golem15.hello", fsys: fstest.MapFS{ "lang/en/lang.yaml": {Data: []byte("labels:\n count: 1\n")}, }, } app := backpack.New(&compass.Config{}) _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}) if err == nil { t.Fatal("expected non-string leaf error") } msg := err.Error() for _, want := range []string{"golem15.hello", "lang/en/lang.yaml", "labels.count", "non-string leaf"} { if !strings.Contains(msg, want) { t.Fatalf("error %q missing %s", msg, want) } } } type mailPlugin struct { id string fsys fs.FS templates []string layouts map[string]string sawMailer *bool } func (p *mailPlugin) ID() string { return p.id } func (p *mailPlugin) Requires() []string { return nil } func (p *mailPlugin) Register(*backpack.App) error { return nil } func (p *mailPlugin) MailTemplatesFS() fs.FS { return p.fsys } func (p *mailPlugin) MailTemplates() []string { return p.templates } func (p *mailPlugin) MailLayouts() map[string]string { return p.layouts } func (p *mailPlugin) Boot(app *backpack.App) error { if p.sawMailer == nil { return nil } _, ok := app.Lookup[postcard.Mailer]() *p.sawMailer = ok return nil } func TestActivatePublishesMailerBeforeBoot(t *testing.T) { var saw bool p := &mailPlugin{ id: "golem15.hello", sawMailer: &saw, templates: []string{"golem15.hello::mail.hello"}, layouts: map[string]string{"hello": "golem15.hello::mail.layouts.hello"}, fsys: fstest.MapFS{ "views/mail/hello.htm": {Data: []byte("subject = \"Hi\"\nlayout = \"hello\"\n==\nHi.\n")}, "views/mail/layouts/hello.htm": {Data: []byte("name = \"Hello\"\n==\n{{ .Content }}\n==\n
{{ .Content }}
\n")}, }, } app := backpack.New(&compass.Config{}) if _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}); err != nil { t.Fatalf("activate: %v", err) } if !saw { t.Fatal("mailer must be published before Boot") } } func TestActivateMissingMailTemplateFailsBootWithName(t *testing.T) { p := &mailPlugin{ id: "golem15.hello", templates: []string{"golem15.hello::mail.hello"}, fsys: fstest.MapFS{}, } app := backpack.New(&compass.Config{}) _, err := activate([]Plugin{p}, app, []string{"golem15.hello"}) if err == nil { t.Fatal("expected missing template error") } msg := err.Error() if !strings.Contains(msg, "party: boot golem15.hello") || !strings.Contains(msg, "golem15.hello::mail.hello") { t.Fatalf("error %q should name the boot plugin and missing template", msg) } }