From 5f2291849d41786661d874578c0ba3e162912d47 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:20:53 +0200 Subject: [PATCH] test(01-02): add failing test for optional plugin services - Cover typed Publish/Lookup, duplicate providers and HasPlugin - Require missing/cycle errors to name plugin IDs before Boot - Assert greeter works with and without an optional extra --- backpack/services_test.go | 72 +++++++++++ examples/hello/hello_test.go | 53 +++++++- examples/hello/plugins/greeter/plugin_test.go | 75 +++++++++++ party/registry_test.go | 122 ++++++++++++++++++ 4 files changed, 317 insertions(+), 5 deletions(-) create mode 100644 backpack/services_test.go create mode 100644 examples/hello/plugins/greeter/plugin_test.go diff --git a/backpack/services_test.go b/backpack/services_test.go new file mode 100644 index 0000000..9daa5be --- /dev/null +++ b/backpack/services_test.go @@ -0,0 +1,72 @@ +package backpack + +import ( + "testing" + + "git.golem15.com/golem15/summercms/compass" +) + +type ping interface { + Ping() string +} + +type pingImpl struct{ s string } + +func (p pingImpl) Ping() string { return p.s } + +func TestPublishLookup(t *testing.T) { + app := New(&compass.Config{}) + if err := app.Publish[ping](pingImpl{s: "pong"}); err != nil { + t.Fatalf("Publish: %v", err) + } + got, ok := app.Lookup[ping]() + if !ok { + t.Fatal("Lookup returned false") + } + if got.Ping() != "pong" { + t.Fatalf("Ping = %q, want pong", got.Ping()) + } +} + +func TestLookupAbsentInterface(t *testing.T) { + app := New(&compass.Config{}) + got, ok := app.Lookup[ping]() + if ok { + t.Fatalf("Lookup = (%v, true), want false", got) + } + if got != nil { + t.Fatalf("absent lookup value = %v, want nil", got) + } +} + +func TestPublishDuplicateRejected(t *testing.T) { + app := New(&compass.Config{}) + if err := app.Publish[ping](pingImpl{s: "a"}); err != nil { + t.Fatalf("Publish: %v", err) + } + if err := app.Publish[ping](pingImpl{s: "b"}); err == nil { + t.Fatal("expected duplicate publish error") + } +} + +func TestServiceRegistriesAreAppScoped(t *testing.T) { + a := New(&compass.Config{}) + b := New(&compass.Config{}) + if err := a.Publish[ping](pingImpl{s: "a"}); err != nil { + t.Fatal(err) + } + if _, ok := b.Lookup[ping](); ok { + t.Fatal("app B should not see app A's service") + } +} + +func TestHasPluginUsesCompleteSet(t *testing.T) { + app := New(&compass.Config{}) + app.SetPlugins([]string{"golem15.hello", "golem15.optional"}) + if !app.HasPlugin("golem15.optional") { + t.Fatal("HasPlugin(golem15.optional) = false") + } + if app.HasPlugin("golem15.missing") { + t.Fatal("HasPlugin(golem15.missing) = true") + } +} diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index 5bbfcdb..8672f2b 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -7,11 +7,17 @@ import ( "os/exec" "strings" "testing" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/compass" + "git.golem15.com/golem15/summercms/pact" + "git.golem15.com/golem15/summercms/party" ) func TestGreeterHelloPrintsLayeredConfig(t *testing.T) { got := runHello(t, nil) - want := "name=hello-app posts_per_page=10 debug=false" + want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional" if got != want { t.Fatalf("output = %q, want %q", got, want) } @@ -19,7 +25,7 @@ func TestGreeterHelloPrintsLayeredConfig(t *testing.T) { func TestGreeterHelloSeesDevelopmentOverlay(t *testing.T) { got := runHello(t, map[string]string{"SUMMER_ENV": "development"}) - want := "name=hello-dev posts_per_page=10 debug=true" + want := "name=hello-dev posts_per_page=10 debug=true extra=hello-from-optional" if got != want { t.Fatalf("output = %q, want %q", got, want) } @@ -29,7 +35,7 @@ func TestGreeterHelloSeesEnvVarOverrideOfPluginDefault(t *testing.T) { got := runHello(t, map[string]string{ "SUMMER_GOLEM15__HELLO__POSTS_PER_PAGE": "25", }) - want := "name=hello-app posts_per_page=25 debug=false" + want := "name=hello-app posts_per_page=25 debug=false extra=hello-from-optional" if got != want { t.Fatalf("output = %q, want %q", got, want) } @@ -47,6 +53,43 @@ func runHello(t *testing.T, env map[string]string) string { return strings.TrimSpace(buf.String()) } +func TestGreeterRunsWithoutOptionalPlugin(t *testing.T) { + got := runHelloIDs(t, []string{"golem15.hello", "golem15.greeter"}, nil) + want := "name=hello-app posts_per_page=10 debug=false extra=" + if got != want { + t.Fatalf("output = %q, want %q", got, want) + } +} + +func runHelloIDs(t *testing.T, ids []string, env map[string]string) string { + t.Helper() + for k, v := range env { + t.Setenv(k, v) + } + cfg, err := compass.Load("config") + if err != nil { + t.Fatalf("Load: %v", err) + } + app := backpack.New(cfg) + plugins, err := party.Activate(app, ids) + if err != nil { + t.Fatalf("Activate: %v", err) + } + var commands []bonfire.Command + for _, plugin := range plugins { + if hasCommands, ok := plugin.(pact.HasCommands); ok { + commands = append(commands, hasCommands.Commands()...) + } + } + var buf bytes.Buffer + root := bonfire.NewRoot("hello", commands, &buf) + root.SetArgs([]string{"greeter:hello"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + return strings.TrimSpace(buf.String()) +} + func TestBuiltBinaryGreeterHello(t *testing.T) { first := runSummerBuild(t) h1 := generatedHash(t) @@ -61,8 +104,8 @@ func TestBuiltBinaryGreeterHello(t *testing.T) { if err != nil { t.Fatalf("bin/hello greeter:hello: %v\n%s", err, out) } - if got := strings.TrimSpace(string(out)); got != "name=hello-app posts_per_page=10 debug=false" { - t.Fatalf("output = %q, want layered production config", got) + if got := strings.TrimSpace(string(out)); got != "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional" { + t.Fatalf("output = %q, want layered production config with optional extra", got) } } diff --git a/examples/hello/plugins/greeter/plugin_test.go b/examples/hello/plugins/greeter/plugin_test.go new file mode 100644 index 0000000..f2b6280 --- /dev/null +++ b/examples/hello/plugins/greeter/plugin_test.go @@ -0,0 +1,75 @@ +package greeter + +import ( + "bytes" + "context" + "fmt" + "io" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bonfire" + "git.golem15.com/golem15/summercms/compass" + "git.golem15.com/golem15/summercms/pact" +) + +type testOut struct{ bytes.Buffer } + +func (o *testOut) Write(p []byte) (int, error) { return o.Buffer.Write(p) } +func (o *testOut) Println(a ...any) { fmt.Fprintln(&o.Buffer, a...) } +func (o *testOut) Printf(format string, a ...any) { + fmt.Fprintf(&o.Buffer, format, a...) +} + +var _ bonfire.Output = (*testOut)(nil) +var _ io.Writer = (*testOut)(nil) + +type extraMsg struct{ msg string } + +func (e extraMsg) Message() string { return e.msg } + +func TestBootSkipsAbsentOptionalPlugin(t *testing.T) { + app := backpack.New(&compass.Config{}) + app.SetPlugins([]string{"golem15.hello", "golem15.greeter"}) + p := &Plugin{} + if err := p.Boot(app); err != nil { + t.Fatalf("Boot: %v", err) + } + out := runHelloCmd(t, p) + if strings.Contains(out, "hello-from-optional") { + t.Fatalf("absent optional leaked into output: %q", out) + } + if !strings.Contains(out, "extra=") { + t.Fatalf("output %q should include extra=", out) + } +} + +func TestBootUsesPublishedOptionalService(t *testing.T) { + app := backpack.New(&compass.Config{}) + app.SetPlugins([]string{"golem15.hello", "golem15.greeter", "golem15.optional"}) + if err := app.Publish[pact.OptionalMessage](extraMsg{msg: "hello-from-optional"}); err != nil { + t.Fatalf("Publish: %v", err) + } + p := &Plugin{} + if err := p.Boot(app); err != nil { + t.Fatalf("Boot: %v", err) + } + out := runHelloCmd(t, p) + if !strings.Contains(out, "extra=hello-from-optional") { + t.Fatalf("output = %q, want extra=hello-from-optional", out) + } +} + +func runHelloCmd(t *testing.T, p *Plugin) string { + t.Helper() + cmds := p.Commands() + if len(cmds) == 0 { + t.Fatal("no commands") + } + var out testOut + if err := cmds[0].Run(context.Background(), nil, &out); err != nil { + t.Fatalf("Run: %v", err) + } + return strings.TrimSpace(out.String()) +} diff --git a/party/registry_test.go b/party/registry_test.go index f914571..21ef950 100644 --- a/party/registry_test.go +++ b/party/registry_test.go @@ -76,6 +76,128 @@ func TestActivateMergesHasConfigBeforeRegister(t *testing.T) { } } +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}