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
This commit is contained in:
Jakub Zych
2026-09-16 13:20:53 +02:00
parent cc44a1fa49
commit 5f2291849d
4 changed files with 317 additions and 5 deletions

72
backpack/services_test.go Normal file
View File

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

View File

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

View File

@@ -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())
}

View File

@@ -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}