From 36c152f4bbbffecddcbcd0068fc356335fac37c7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 16 Sep 2026 13:27:53 +0200 Subject: [PATCH] test(01-02): add failing test for typed events and context - Cover Fire/Collect/UntilHandled, priority, panics and bus isolation - Require towel actor/org/collection/locale accessors on context - Assert hello command demonstrates all three dispatch modes --- backpack/app_test.go | 13 +++ examples/hello/hello_test.go | 12 +-- festival/bus_test.go | 166 +++++++++++++++++++++++++++++++++++ towel/context_test.go | 75 ++++++++++++++++ 4 files changed, 260 insertions(+), 6 deletions(-) create mode 100644 festival/bus_test.go create mode 100644 towel/context_test.go diff --git a/backpack/app_test.go b/backpack/app_test.go index 2f8097a..3876621 100644 --- a/backpack/app_test.go +++ b/backpack/app_test.go @@ -7,8 +7,21 @@ import ( "strconv" "strings" "testing" + + "git.golem15.com/golem15/summercms/compass" ) +func TestEventBusesAreAppScoped(t *testing.T) { + a := New(&compass.Config{}) + b := New(&compass.Config{}) + if a.Events == nil || b.Events == nil { + t.Fatal("App must own an event bus") + } + if a.Events == b.Events { + t.Fatal("apps must not share a bus") + } +} + func TestDoesNotImportParty(t *testing.T) { entries, err := os.ReadDir(".") if err != nil { diff --git a/examples/hello/hello_test.go b/examples/hello/hello_test.go index 8672f2b..9a877e3 100644 --- a/examples/hello/hello_test.go +++ b/examples/hello/hello_test.go @@ -17,7 +17,7 @@ import ( func TestGreeterHelloPrintsLayeredConfig(t *testing.T) { got := runHello(t, nil) - want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional" + want := "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true" if got != want { t.Fatalf("output = %q, want %q", got, want) } @@ -25,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 extra=hello-from-optional" + want := "name=hello-dev posts_per_page=10 debug=true extra=hello-from-optional events=ok collected=greeter handled=true" if got != want { t.Fatalf("output = %q, want %q", got, want) } @@ -35,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 extra=hello-from-optional" + want := "name=hello-app posts_per_page=25 debug=false extra=hello-from-optional events=ok collected=greeter handled=true" if got != want { t.Fatalf("output = %q, want %q", got, want) } @@ -55,7 +55,7 @@ func runHello(t *testing.T, env map[string]string) 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=" + want := "name=hello-app posts_per_page=10 debug=false extra= events=ok collected=greeter handled=true" if got != want { t.Fatalf("output = %q, want %q", got, want) } @@ -104,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 extra=hello-from-optional" { - t.Fatalf("output = %q, want layered production config with optional extra", got) + if got := strings.TrimSpace(string(out)); got != "name=hello-app posts_per_page=10 debug=false extra=hello-from-optional events=ok collected=greeter handled=true" { + t.Fatalf("output = %q, want layered production config with optional extra and events", got) } } diff --git a/festival/bus_test.go b/festival/bus_test.go new file mode 100644 index 0000000..81cb61c --- /dev/null +++ b/festival/bus_test.go @@ -0,0 +1,166 @@ +package festival + +import ( + "context" + "errors" + "strings" + "testing" +) + +type evt struct { + data map[string]any + handled bool +} + +func (e *evt) Collected() map[string]any { return e.data } +func (e *evt) IsHandled() bool { return e.handled } + +func TestFireRunsAllListenersAndJoinsErrors(t *testing.T) { + bus := New() + var order []string + bus.Listen[*evt]("golem15.a", func(ctx context.Context, e *evt) error { + order = append(order, "a") + return errors.New("err-a") + }) + bus.Listen[*evt]("golem15.b", func(ctx context.Context, e *evt) error { + order = append(order, "b") + return errors.New("err-b") + }) + err := bus.Fire(context.Background(), &evt{}) + if err == nil { + t.Fatal("expected joined errors") + } + if !strings.Contains(err.Error(), "err-a") || !strings.Contains(err.Error(), "err-b") { + t.Fatalf("joined error = %v", err) + } + if strings.Join(order, ",") != "a,b" { + t.Fatalf("order = %v, want a,b", order) + } +} + +func TestCollectMergesPayloadsLaterWinsAndKeepsPartialOnError(t *testing.T) { + bus := New() + bus.Listen[*evt]("golem15.a", func(ctx context.Context, e *evt) error { + e.data = map[string]any{"k": "a", "only": "a"} + return nil + }) + bus.Listen[*evt]("golem15.b", func(ctx context.Context, e *evt) error { + e.data = map[string]any{"k": "b"} + return errors.New("collect-b") + }) + got, err := bus.Collect(context.Background(), &evt{}) + if err == nil || !strings.Contains(err.Error(), "collect-b") { + t.Fatalf("expected collect error, got %v", err) + } + if got["k"] != "b" { + t.Fatalf("later listener should win k, got %v", got) + } + if got["only"] != "a" { + t.Fatalf("partial payload missing only=a: %v", got) + } +} + +func TestUntilHandledStopsOnFirstHandled(t *testing.T) { + bus := New() + var order []string + bus.Listen[*evt]("golem15.a", func(ctx context.Context, e *evt) error { + order = append(order, "a") + e.handled = true + return nil + }) + bus.Listen[*evt]("golem15.b", func(ctx context.Context, e *evt) error { + order = append(order, "b") + return nil + }) + ok, err := bus.UntilHandled(context.Background(), &evt{}) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("expected handled") + } + if strings.Join(order, ",") != "a" { + t.Fatalf("order = %v, want only a", order) + } +} + +func TestUntilHandledStopsOnFirstError(t *testing.T) { + bus := New() + var order []string + bus.Listen[*evt]("golem15.a", func(ctx context.Context, e *evt) error { + order = append(order, "a") + return errors.New("stop") + }) + bus.Listen[*evt]("golem15.b", func(ctx context.Context, e *evt) error { + order = append(order, "b") + e.handled = true + return nil + }) + ok, err := bus.UntilHandled(context.Background(), &evt{}) + if err == nil || !strings.Contains(err.Error(), "stop") { + t.Fatalf("expected first error, got ok=%v err=%v", ok, err) + } + if ok { + t.Fatal("error should not report handled") + } + if strings.Join(order, ",") != "a" { + t.Fatalf("order = %v, want only a", order) + } +} + +func TestPriorityDescendingStableTies(t *testing.T) { + bus := New() + var order []string + bus.ListenPriority[*evt]("golem15.low", 1, func(ctx context.Context, e *evt) error { + order = append(order, "low") + return nil + }) + bus.Listen[*evt]("golem15.default-a", func(ctx context.Context, e *evt) error { + order = append(order, "default-a") + return nil + }) + bus.Listen[*evt]("golem15.default-b", func(ctx context.Context, e *evt) error { + order = append(order, "default-b") + return nil + }) + bus.ListenPriority[*evt]("golem15.high", 10, func(ctx context.Context, e *evt) error { + order = append(order, "high") + return nil + }) + if err := bus.Fire(context.Background(), &evt{}); err != nil { + t.Fatal(err) + } + if strings.Join(order, ",") != "high,low,default-a,default-b" { + t.Fatalf("order = %v", order) + } +} + +func TestPanicRecoveredNamesOwnerPlugin(t *testing.T) { + bus := New() + bus.Listen[*evt]("golem15.boom", func(ctx context.Context, e *evt) error { + panic("kapow") + }) + err := bus.Fire(context.Background(), &evt{}) + if err == nil { + t.Fatal("expected panic error") + } + if !strings.Contains(err.Error(), "golem15.boom") { + t.Fatalf("error should name owner plugin, got %v", err) + } +} + +func TestBusesAreIsolated(t *testing.T) { + a := New() + b := New() + called := false + a.Listen[*evt]("golem15.a", func(ctx context.Context, e *evt) error { + called = true + return nil + }) + if err := b.Fire(context.Background(), &evt{}); err != nil { + t.Fatal(err) + } + if called { + t.Fatal("bus B should not invoke bus A's listeners") + } +} diff --git a/towel/context_test.go b/towel/context_test.go new file mode 100644 index 0000000..f90b62b --- /dev/null +++ b/towel/context_test.go @@ -0,0 +1,75 @@ +package towel + +import ( + "context" + "go/parser" + "go/token" + "os" + "strings" + "testing" +) + +func TestContextAccessorsRoundTrip(t *testing.T) { + ctx := context.Background() + ctx = WithActor(ctx, "user-1") + ctx = WithOrganization(ctx, "org-9") + ctx = WithCollection(ctx, "col-3") + ctx = WithLocale(ctx, "pl") + + if got, ok := Actor(ctx); !ok || got != "user-1" { + t.Fatalf("Actor = (%q, %v)", got, ok) + } + if got, ok := Organization(ctx); !ok || got != "org-9" { + t.Fatalf("Organization = (%q, %v)", got, ok) + } + if got, ok := Collection(ctx); !ok || got != "col-3" { + t.Fatalf("Collection = (%q, %v)", got, ok) + } + if got, ok := Locale(ctx); !ok || got != "pl" { + t.Fatalf("Locale = (%q, %v)", got, ok) + } + + other := context.Background() + if _, ok := Actor(other); ok { + t.Fatal("Actor leaked across contexts") + } +} + +func TestNoPackageGlobalRequestState(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + fset := token.NewFileSet() + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, name, nil, parser.SkipObjectResolution) + if err != nil { + t.Fatal(err) + } + for _, spec := range file.Decls { + gen, ok := spec.(interface{ TokString() string }) + _ = gen + _ = ok + } + src, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + // Reject package-level request-state variables. Type keys are ok. + for _, line := range strings.Split(string(src), "\n") { + trim := strings.TrimSpace(line) + if strings.HasPrefix(trim, "var ") && !strings.Contains(trim, "struct{}") && !strings.Contains(trim, "key") { + if strings.Contains(strings.ToLower(trim), "actor") || + strings.Contains(strings.ToLower(trim), "locale") || + strings.Contains(strings.ToLower(trim), "organization") || + strings.Contains(strings.ToLower(trim), "collection") { + t.Fatalf("package-global request state in %s: %s", name, trim) + } + } + } + } +}