diff --git a/compare.go b/compare.go index 6118a08..0e035c8 100644 --- a/compare.go +++ b/compare.go @@ -1,10 +1,8 @@ package testkit import ( - "reflect" - "github.com/dogmatiq/dogma" - "google.golang.org/protobuf/proto" + "github.com/dogmatiq/testkit/internal/compare" ) // A MessageComparator is a function that returns true if two messages are @@ -16,9 +14,11 @@ type MessageComparator func(a, b dogma.Message) bool // It is the default implementation of the MessageComparator type. // // It supports comparison of protocol buffers messages using the proto.Equal() -// function. All other types are compared using reflect.DeepEqual(). +// function. All other types are compared using semantics equivalent to +// reflect.DeepEqual(), except that function values are compared by their +// definition site rather than by pointer identity. func DefaultMessageComparator(a, b dogma.Message) bool { - return equal(a, b) + return compare.Equal(a, b) } // WithMessageComparator returns a test option that sets the comparator @@ -32,14 +32,3 @@ func WithMessageComparator(c MessageComparator) TestOption { t.predicateOptions.MessageComparator = c }) } - -// equal returns true if a and b are considered equal. -func equal(a, b any) bool { - if pa, ok := a.(proto.Message); ok { - if pb, ok := b.(proto.Message); ok { - return proto.Equal(pa, pb) - } - } - - return reflect.DeepEqual(a, b) -} diff --git a/engine/internal/aggregate/controller.go b/engine/internal/aggregate/controller.go index 8c02c63..23c374a 100644 --- a/engine/internal/aggregate/controller.go +++ b/engine/internal/aggregate/controller.go @@ -13,6 +13,7 @@ import ( "github.com/dogmatiq/testkit/engine/internal/panicx" "github.com/dogmatiq/testkit/envelope" "github.com/dogmatiq/testkit/fact" + "github.com/dogmatiq/testkit/internal/compare" "github.com/dogmatiq/testkit/internal/x/xreflect" "github.com/dogmatiq/testkit/location" ) @@ -73,7 +74,7 @@ func (c *Controller) Handle( } id := c.route(env, mt) - inst, root := c.instanceByID(obs, env, id) + inst, root, shadowRoot := c.instanceByID(obs, env, id) s := &scope{ instanceID: id, @@ -82,11 +83,24 @@ func (c *Controller) Handle( observer: obs, now: now, root: root, + shadowRoot: shadowRoot, command: env, streamID: uuidpb.Derive(c.Config.Identity().GetKey(), id).AsString(), offset: uint64(len(inst.history)), } + if inst.snapshotted && !compare.Equal(root, shadowRoot) { + panic(panicx.UnexpectedBehavior{ + Handler: c.Config, + Interface: "AggregateRoot", + Method: "UnmarshalBinary", + Implementation: root, + Message: env.Message, + Description: "aggregate root state differs when built from events versus snapshot", + Location: location.OfMethod(root, "UnmarshalBinary"), + }) + } + panicx.EnrichUnexpectedMessage( c.Config, "AggregateMessageHandler", @@ -102,12 +116,10 @@ func (c *Controller) Handle( }, ) + s.guardAgainstDirectMutation("", location.Location{}) + if len(s.events) != 0 { - if c.instances == nil { - c.instances = map[string]*instance{} - } inst.history = append(inst.history, s.events...) - c.instances[id] = inst c.takeSnapshot(root, inst, env) } @@ -150,15 +162,18 @@ func (c *Controller) route(env *envelope.Envelope, mt message.Type) string { return id } -// instanceByID returns the instance and root for the given instance ID. +// instanceByID returns the instance, root, and shadow root for the given +// instance ID. The shadow root is built by replaying the full event history +// from New(), ignoring any snapshot. func (c *Controller) instanceByID( obs fact.Observer, env *envelope.Envelope, id string, -) (*instance, dogma.AggregateRoot) { - root := c.Config.Source.Get().New() +) (inst *instance, root, shadowRoot dogma.AggregateRoot) { + root = c.Config.Source.Get().New() + shadowRoot = c.Config.Source.Get().New() - if xreflect.IsNil(root) { + if xreflect.IsNil(root) || xreflect.IsNil(shadowRoot) { panic(panicx.UnexpectedBehavior{ Handler: c.Config, Interface: "AggregateMessageHandler", @@ -170,53 +185,73 @@ func (c *Controller) instanceByID( }) } - if inst, ok := c.instances[id]; ok { - if inst.snapshotted { - if err := root.UnmarshalBinary(inst.snapshot); err != nil { - panic(panicx.UnexpectedBehavior{ - Handler: c.Config, - Interface: "AggregateRoot", - Method: "UnmarshalBinary", - Implementation: root, - Message: env.Message, - Description: fmt.Sprintf("unable to unmarshal the aggregate root: %s", err), - Location: location.OfMethod(root, "UnmarshalBinary"), - }) - } - } - - for _, ev := range inst.history[inst.snapshotOffset:] { - panicx.EnrichUnexpectedMessage( - c.Config, - "AggregateRoot", - "ApplyEvent", - root, - ev.Message, - func() { - root.ApplyEvent( - ev.Message.(dogma.Event), - ) - }, - ) - } - - obs.Notify(fact.AggregateInstanceLoaded{ + inst, ok := c.instances[id] + if !ok { + obs.Notify(fact.AggregateInstanceNotFound{ Handler: c.Config, InstanceID: id, - Root: root, Envelope: env, }) - return inst, root + if c.instances == nil { + c.instances = map[string]*instance{} + } + + inst = &instance{} + c.instances[id] = inst + + return inst, root, shadowRoot + } + + for _, ev := range inst.history { + panicx.EnrichUnexpectedMessage( + c.Config, + "AggregateRoot", + "ApplyEvent", + shadowRoot, + ev.Message, + func() { + shadowRoot.ApplyEvent(ev.Message.(dogma.Event)) + }, + ) + } + + if inst.snapshotted { + if err := root.UnmarshalBinary(inst.snapshot); err != nil { + panic(panicx.UnexpectedBehavior{ + Handler: c.Config, + Interface: "AggregateRoot", + Method: "UnmarshalBinary", + Implementation: root, + Message: env.Message, + Description: fmt.Sprintf("unable to unmarshal the aggregate root: %s", err), + Location: location.OfMethod(root, "UnmarshalBinary"), + }) + } + } + + for _, ev := range inst.history[inst.snapshotOffset:] { + panicx.EnrichUnexpectedMessage( + c.Config, + "AggregateRoot", + "ApplyEvent", + root, + ev.Message, + func() { + root.ApplyEvent(ev.Message.(dogma.Event)) + }, + ) } - obs.Notify(fact.AggregateInstanceNotFound{ - Handler: c.Config, - InstanceID: id, - Envelope: env, + obs.Notify(fact.AggregateInstanceLoaded{ + Handler: c.Config, + InstanceID: id, + Root: root, + Envelope: env, + SnapshotOffset: inst.snapshotOffset, }) - return &instance{}, root + return inst, root, shadowRoot } // takeSnapshot attempts to store a snapshot of the aggregate root. diff --git a/engine/internal/aggregate/controller_test.go b/engine/internal/aggregate/controller_test.go index e89e4a3..874c6eb 100644 --- a/engine/internal/aggregate/controller_test.go +++ b/engine/internal/aggregate/controller_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "testing" "time" @@ -17,7 +16,6 @@ import ( "github.com/dogmatiq/testkit/envelope" "github.com/dogmatiq/testkit/fact" "github.com/dogmatiq/testkit/internal/x/xtesting" - "github.com/dogmatiq/testkit/location" ) func TestControllerHandlerConfig(t *testing.T) { @@ -150,27 +148,27 @@ func TestControllerHandle(t *testing.T) { return "" } - x := mustPanicUnexpectedBehavior(t, func() { + xtesting.ExpectPanicMatching(t, func() { _, _ = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), f.command, ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "RouteCommandToInstance") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) + xtesting.Expect( + t, + "unexpected description", + x.Description, + "routed a command of type *stubs.CommandStub[TypeA] to an empty ID", + ) + xtesting.ExpectLocation(t, x.Location, "/stubs/aggregate.go") }) - - xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) - xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") - xtesting.Expect(t, "unexpected method", x.Method, "RouteCommandToInstance") - xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) - xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) - xtesting.Expect( - t, - "unexpected description", - x.Description, - "routed a command of type *stubs.CommandStub[TypeA] to an empty ID", - ) - expectLocation(t, x.Location, "/stubs/aggregate.go") }) t.Run("records AggregateInstanceNotFound when the instance does not exist", func(t *testing.T) { @@ -236,22 +234,22 @@ func TestControllerHandle(t *testing.T) { return nil } - x := mustPanicUnexpectedBehavior(t, func() { + xtesting.ExpectPanicMatching(t, func() { _, _ = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), f.command, ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "New") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) + xtesting.Expect(t, "unexpected description", x.Description, "returned a nil aggregate root") + xtesting.ExpectLocation(t, x.Location, "/stubs/aggregate.go") }) - - xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) - xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") - xtesting.Expect(t, "unexpected method", x.Method, "New") - xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) - xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) - xtesting.Expect(t, "unexpected description", x.Description, "returned a nil aggregate root") - expectLocation(t, x.Location, "/stubs/aggregate.go") }) t.Run("panics if New returns nil when the instance exists", func(t *testing.T) { @@ -262,22 +260,22 @@ func TestControllerHandle(t *testing.T) { return nil } - x := mustPanicUnexpectedBehavior(t, func() { + xtesting.ExpectPanicMatching(t, func() { _, _ = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), f.command, ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "New") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) + xtesting.Expect(t, "unexpected description", x.Description, "returned a nil aggregate root") + xtesting.ExpectLocation(t, x.Location, "/stubs/aggregate.go") }) - - xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) - xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") - xtesting.Expect(t, "unexpected method", x.Method, "New") - xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) - xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) - xtesting.Expect(t, "unexpected description", x.Description, "returned a nil aggregate root") - expectLocation(t, x.Location, "/stubs/aggregate.go") }) t.Run("records AggregateInstanceLoaded when the instance exists", func(t *testing.T) { @@ -310,7 +308,8 @@ func TestControllerHandle(t *testing.T) { Root: &stubs.AggregateRootStub{ AppliedEvents: []dogma.Event{stubs.EventA1}, }, - Envelope: f.command, + Envelope: f.command, + SnapshotOffset: 1, }, ) }) @@ -355,19 +354,19 @@ func TestControllerHandle(t *testing.T) { panic(dogma.UnexpectedMessage) } - x := mustPanicUnexpectedMessage(t, func() { + xtesting.ExpectPanicMatching(t, func() { _, _ = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), f.command, ) + }, func(x panicx.UnexpectedMessage) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "RouteCommandToInstance") + xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) }) - - xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) - xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") - xtesting.Expect(t, "unexpected method", x.Method, "RouteCommandToInstance") - xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) }) t.Run("provides more context to UnexpectedMessage panics from HandleCommand", func(t *testing.T) { @@ -380,19 +379,19 @@ func TestControllerHandle(t *testing.T) { panic(dogma.UnexpectedMessage) } - x := mustPanicUnexpectedMessage(t, func() { + xtesting.ExpectPanicMatching(t, func() { _, _ = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), f.command, ) + }, func(x panicx.UnexpectedMessage) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleCommand") + xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) }) - - xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) - xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") - xtesting.Expect(t, "unexpected method", x.Method, "HandleCommand") - xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) }) t.Run("provides more context to UnexpectedMessage panics from ApplyEvent when called with new events", func(t *testing.T) { @@ -413,19 +412,19 @@ func TestControllerHandle(t *testing.T) { } } - x := mustPanicUnexpectedMessage(t, func() { + xtesting.ExpectPanicMatching(t, func() { _, _ = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), f.command, ) + }, func(x panicx.UnexpectedMessage) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateRoot") + xtesting.Expect(t, "unexpected method", x.Method, "ApplyEvent") + xtesting.Expect(t, "unexpected message", x.Message, stubs.EventA1) }) - - xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) - xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateRoot") - xtesting.Expect(t, "unexpected method", x.Method, "ApplyEvent") - xtesting.Expect(t, "unexpected message", x.Message, stubs.EventA1) }) t.Run("provides more context to UnexpectedMessage panics from ApplyEvent when called with historical events", func(t *testing.T) { @@ -450,19 +449,19 @@ func TestControllerHandle(t *testing.T) { } } - x := mustPanicUnexpectedMessage(t, func() { + xtesting.ExpectPanicMatching(t, func() { _, _ = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), f.command, ) + }, func(x panicx.UnexpectedMessage) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateRoot") + xtesting.Expect(t, "unexpected method", x.Method, "ApplyEvent") + xtesting.Expect(t, "unexpected message", x.Message, stubs.EventA1) }) - - xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) - xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateRoot") - xtesting.Expect(t, "unexpected method", x.Method, "ApplyEvent") - xtesting.Expect(t, "unexpected message", x.Message, stubs.EventA1) }) t.Run("panics if MarshalBinary fails with a non-ErrNotSupported error", func(t *testing.T) { @@ -550,106 +549,72 @@ func TestControllerHandle(t *testing.T) { ) }) - t.Run("calls UnmarshalBinary when MarshalBinary returns nil", func(t *testing.T) { - f := newControllerTestFixture() - - f.handler.NewFunc = func() *stubs.AggregateRootStub { - return &stubs.AggregateRootStub{ - MarshalBinaryFunc: func() ([]byte, error) { - return nil, nil - }, - } - } - f.handler.HandleCommandFunc = func( - _ *stubs.AggregateRootStub, - s dogma.AggregateCommandScope[*stubs.AggregateRootStub], - _ dogma.Command, - ) { - s.RecordEvent(stubs.EventA1) - } - - _, err := f.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - f.command, - ) - if err != nil { - t.Fatal(err) - } - - called := false - f.handler.NewFunc = func() *stubs.AggregateRootStub { - return &stubs.AggregateRootStub{ - UnmarshalBinaryFunc: func([]byte) error { - called = true - return nil - }, - } - } - - _, err = f.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - f.command, - ) - if err != nil { - t.Fatal(err) - } - - xtesting.Expect(t, "expected UnmarshalBinary to be called", called, true) - }) - - t.Run("calls UnmarshalBinary when MarshalBinary returns an empty slice", func(t *testing.T) { - f := newControllerTestFixture() - - f.handler.NewFunc = func() *stubs.AggregateRootStub { - return &stubs.AggregateRootStub{ - MarshalBinaryFunc: func() ([]byte, error) { - return []byte{}, nil - }, - } - } - f.handler.HandleCommandFunc = func( - _ *stubs.AggregateRootStub, - s dogma.AggregateCommandScope[*stubs.AggregateRootStub], - _ dogma.Command, - ) { - s.RecordEvent(stubs.EventA1) - } - - _, err := f.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - f.command, - ) - if err != nil { - t.Fatal(err) - } + t.Run("calls UnmarshalBinary when MarshalBinary returns empty data", func(t *testing.T) { + cases := []struct { + Name string + Data []byte + }{ + {"nil", nil}, + {"empty slice", []byte{}}, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + f := newControllerTestFixture() + + f.handler.NewFunc = func() *stubs.AggregateRootStub { + return &stubs.AggregateRootStub{ + MarshalBinaryFunc: func() ([]byte, error) { + return c.Data, nil + }, + } + } + f.handler.HandleCommandFunc = func( + _ *stubs.AggregateRootStub, + s dogma.AggregateCommandScope[*stubs.AggregateRootStub], + _ dogma.Command, + ) { + s.RecordEvent(stubs.EventA1) + } + + _, err := f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.command, + ) + if err != nil { + t.Fatal(err) + } + + called := false + f.handler.NewFunc = func() *stubs.AggregateRootStub { + r := &stubs.AggregateRootStub{} + r.UnmarshalBinaryFunc = func([]byte) error { + called = true + // Populate AppliedEvents to match what event replay + // would produce, otherwise the controller detects a + // mismatch between the snapshot-based and event-based + // root states. + r.AppliedEvents = []dogma.Event{stubs.EventA1} + return nil + } + return r + } + + _, err = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.command, + ) + if err != nil { + t.Fatal(err) + } - called := false - f.handler.NewFunc = func() *stubs.AggregateRootStub { - return &stubs.AggregateRootStub{ - UnmarshalBinaryFunc: func([]byte) error { - called = true - return nil - }, - } + xtesting.Expect(t, "expected UnmarshalBinary to be called", called, true) + }) } - - _, err = f.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - f.command, - ) - if err != nil { - t.Fatal(err) - } - - xtesting.Expect(t, "expected UnmarshalBinary to be called", called, true) }) } @@ -744,66 +709,6 @@ func seedControllerInstance(t *testing.T, f *controllerTestFixture) { f.handler.HandleCommandFunc = nil } -func mustPanicUnexpectedBehavior(t *testing.T, fn func()) panicx.UnexpectedBehavior { - t.Helper() - - r := recoverPanic(t, fn) - x, ok := r.(panicx.UnexpectedBehavior) - if !ok { - t.Fatalf("expected UnexpectedBehavior panic, got %T", r) - } - - return x -} - -func mustPanicUnexpectedMessage(t *testing.T, fn func()) panicx.UnexpectedMessage { - t.Helper() - - r := recoverPanic(t, fn) - x, ok := r.(panicx.UnexpectedMessage) - if !ok { - t.Fatalf("expected UnexpectedMessage panic, got %T", r) - } - - return x -} - -func recoverPanic(t *testing.T, fn func()) any { - t.Helper() - - var r any - - func() { - defer func() { - r = recover() - }() - - fn() - }() - - if r == nil { - t.Fatal("expected panic") - } - - return r -} - -func expectLocation(t *testing.T, loc location.Location, fileSuffix string) { - t.Helper() - - if loc.Func == "" { - t.Fatal("expected func to be set in location") - } - - if !strings.HasSuffix(loc.File, fileSuffix) { - t.Fatalf("unexpected file in location: got %s, want suffix %s", loc.File, fileSuffix) - } - - if loc.Line == 0 { - t.Fatal("expected line to be set in location") - } -} - func findFact[T any](facts []fact.Fact) (T, bool) { var zero T diff --git a/engine/internal/aggregate/scope.go b/engine/internal/aggregate/scope.go index cf24465..ec7f772 100644 --- a/engine/internal/aggregate/scope.go +++ b/engine/internal/aggregate/scope.go @@ -10,29 +10,34 @@ import ( "github.com/dogmatiq/testkit/engine/internal/panicx" "github.com/dogmatiq/testkit/envelope" "github.com/dogmatiq/testkit/fact" + "github.com/dogmatiq/testkit/internal/compare" "github.com/dogmatiq/testkit/internal/validation" "github.com/dogmatiq/testkit/location" ) // scope is an implementation of dogma.AggregateCommandScope. type scope struct { - instanceID string - config *config.Aggregate - messageIDs *envelope.MessageIDGenerator - observer fact.Observer - root dogma.AggregateRoot - now time.Time - command *envelope.Envelope - streamID string - offset uint64 - events []*envelope.Envelope + instanceID string + config *config.Aggregate + messageIDs *envelope.MessageIDGenerator + observer fact.Observer + root, shadowRoot dogma.AggregateRoot + lastOp string + events []*envelope.Envelope + now time.Time + command *envelope.Envelope + streamID string + offset uint64 } func (s *scope) InstanceID() string { + s.guardAgainstDirectMutation("InstanceID", location.OfCall()) return s.instanceID } func (s *scope) RecordEvent(m dogma.Event) { + s.guardAgainstDirectMutation("RecordEvent", location.OfCall()) + mt := message.TypeOf(m) if !s.config.RouteSet().DirectionOf(mt).Has(config.OutboundDirection) { @@ -79,6 +84,29 @@ func (s *scope) RecordEvent(m dogma.Event) { }, ) + panicx.EnrichUnexpectedMessage( + s.config, + "AggregateRoot", + "ApplyEvent", + s.shadowRoot, + m, + func() { + s.shadowRoot.ApplyEvent(m) + }, + ) + + if !compare.Equal(s.root, s.shadowRoot) { + panic(panicx.UnexpectedBehavior{ + Handler: s.config, + Interface: "AggregateRoot", + Method: "ApplyEvent", + Implementation: s.root, + Message: s.command.Message, + Description: "non-deterministic implementation of ApplyEvent detected", + Location: location.OfMethod(s.root, "ApplyEvent"), + }) + } + env := s.command.NewEvent( s.messageIDs.Next(), m, @@ -105,10 +133,13 @@ func (s *scope) RecordEvent(m dogma.Event) { } func (s *scope) Now() time.Time { + s.guardAgainstDirectMutation("Now", location.OfCall()) return s.now } func (s *scope) Log(f string, v ...any) { + s.guardAgainstDirectMutation("Log", location.OfCall()) + s.observer.Notify(fact.MessageLoggedByAggregate{ Handler: s.config, InstanceID: s.instanceID, @@ -118,3 +149,38 @@ func (s *scope) Log(f string, v ...any) { LogArguments: v, }) } + +// guardAgainstDirectMutation panics if the aggregate root has been modified +// directly (without using RecordEvent), then records the current scope method +// call for use in diagnostic messages. +func (s *scope) guardAgainstDirectMutation(method string, loc location.Location) { + thisOp := "" + if method != "" { + thisOp = fmt.Sprintf("call to %s() at %s", method, loc) + } + + if !compare.Equal(s.root, s.shadowRoot) { + desc := "modified the aggregate root without using RecordEvent()" + + switch { + case s.lastOp != "" && thisOp != "": + desc += ", between " + s.lastOp + " and " + thisOp + case s.lastOp != "": + desc += ", after " + s.lastOp + case thisOp != "": + desc += ", before " + thisOp + } + + panic(panicx.UnexpectedBehavior{ + Handler: s.config, + Interface: "AggregateMessageHandler", + Method: "HandleCommand", + Implementation: s.config.Implementation(), + Message: s.command.Message, + Description: desc, + Location: location.OfCall(), + }) + } + + s.lastOp = thisOp +} diff --git a/engine/internal/aggregate/scope_test.go b/engine/internal/aggregate/scope_test.go index 08de5ab..c57f5d8 100644 --- a/engine/internal/aggregate/scope_test.go +++ b/engine/internal/aggregate/scope_test.go @@ -2,6 +2,7 @@ package aggregate_test import ( "context" + "strings" "testing" "time" @@ -10,6 +11,7 @@ import ( "github.com/dogmatiq/enginekit/config/runtimeconfig" stubs "github.com/dogmatiq/enginekit/enginetest/stubs" "github.com/dogmatiq/testkit/engine/internal/aggregate" + "github.com/dogmatiq/testkit/engine/internal/panicx" "github.com/dogmatiq/testkit/envelope" "github.com/dogmatiq/testkit/fact" "github.com/dogmatiq/testkit/internal/x/xtesting" @@ -177,27 +179,27 @@ func TestScopeRecordEvent(t *testing.T) { s.RecordEvent(stubs.EventX1) } - x := mustPanicUnexpectedBehavior(t, func() { + xtesting.ExpectPanicMatching(t, func() { _, _ = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), f.command, ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleCommand") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) + xtesting.Expect( + t, + "unexpected description", + x.Description, + "recorded an event of type *stubs.EventStub[TypeX], which is not produced by this handler", + ) + xtesting.ExpectLocation(t, x.Location, "/engine/internal/aggregate/scope_test.go") }) - - xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) - xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") - xtesting.Expect(t, "unexpected method", x.Method, "HandleCommand") - xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) - xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) - xtesting.Expect( - t, - "unexpected description", - x.Description, - "recorded an event of type *stubs.EventStub[TypeX], which is not produced by this handler", - ) - expectLocation(t, x.Location, "/engine/internal/aggregate/scope_test.go") }) t.Run("panics if the event is invalid", func(t *testing.T) { @@ -214,27 +216,27 @@ func TestScopeRecordEvent(t *testing.T) { }) } - x := mustPanicUnexpectedBehavior(t, func() { + xtesting.ExpectPanicMatching(t, func() { _, _ = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), f.command, ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleCommand") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) + xtesting.Expect( + t, + "unexpected description", + x.Description, + "recorded an invalid *stubs.EventStub[TypeA] event: ", + ) + xtesting.ExpectLocation(t, x.Location, "/engine/internal/aggregate/scope_test.go") }) - - xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) - xtesting.Expect(t, "unexpected interface", x.Interface, "AggregateMessageHandler") - xtesting.Expect(t, "unexpected method", x.Method, "HandleCommand") - xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) - xtesting.Expect(t, "unexpected message", x.Message, f.command.Message) - xtesting.Expect( - t, - "unexpected description", - x.Description, - "recorded an invalid *stubs.EventStub[TypeA] event: ", - ) - expectLocation(t, x.Location, "/engine/internal/aggregate/scope_test.go") }) } @@ -383,3 +385,288 @@ func seedScopeInstance(t *testing.T, f *scopeTestFixture) { f.messageIDs.Reset() } + +func TestMutationDetection(t *testing.T) { + t.Run("panics if the handler modifies the root before calling RecordEvent", func(t *testing.T) { + f := newScopeTestFixture() + f.handler.HandleCommandFunc = func( + r *stubs.AggregateRootStub, + s dogma.AggregateCommandScope[*stubs.AggregateRootStub], + _ dogma.Command, + ) { + r.AppliedEvents = append(r.AppliedEvents, stubs.EventA1) + s.RecordEvent(stubs.EventA1) + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.command, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the aggregate root without using RecordEvent(), before call to RecordEvent() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics if the handler modifies the root before calling InstanceID", func(t *testing.T) { + f := newScopeTestFixture() + f.handler.HandleCommandFunc = func( + r *stubs.AggregateRootStub, + s dogma.AggregateCommandScope[*stubs.AggregateRootStub], + _ dogma.Command, + ) { + r.AppliedEvents = append(r.AppliedEvents, stubs.EventA1) + s.InstanceID() + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.command, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the aggregate root without using RecordEvent(), before call to InstanceID() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics if the handler modifies the root before calling Now", func(t *testing.T) { + f := newScopeTestFixture() + f.handler.HandleCommandFunc = func( + r *stubs.AggregateRootStub, + s dogma.AggregateCommandScope[*stubs.AggregateRootStub], + _ dogma.Command, + ) { + r.AppliedEvents = append(r.AppliedEvents, stubs.EventA1) + s.Now() + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.command, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the aggregate root without using RecordEvent(), before call to Now() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics if the handler modifies the root before calling Log", func(t *testing.T) { + f := newScopeTestFixture() + f.handler.HandleCommandFunc = func( + r *stubs.AggregateRootStub, + s dogma.AggregateCommandScope[*stubs.AggregateRootStub], + _ dogma.Command, + ) { + r.AppliedEvents = append(r.AppliedEvents, stubs.EventA1) + s.Log("hello") + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.command, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the aggregate root without using RecordEvent(), before call to Log() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics if the handler modifies the root after a scope call", func(t *testing.T) { + f := newScopeTestFixture() + f.handler.HandleCommandFunc = func( + r *stubs.AggregateRootStub, + s dogma.AggregateCommandScope[*stubs.AggregateRootStub], + _ dogma.Command, + ) { + s.InstanceID() + r.AppliedEvents = append(r.AppliedEvents, stubs.EventA1) + s.RecordEvent(stubs.EventA1) + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.command, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the aggregate root without using RecordEvent(), between call to InstanceID() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics at end of handler if the root was modified without a scope call", func(t *testing.T) { + f := newScopeTestFixture() + f.handler.HandleCommandFunc = func( + r *stubs.AggregateRootStub, + _ dogma.AggregateCommandScope[*stubs.AggregateRootStub], + _ dogma.Command, + ) { + r.AppliedEvents = append(r.AppliedEvents, stubs.EventA1) + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.command, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect( + t, + "unexpected panic description", + x.Description, + "modified the aggregate root without using RecordEvent()", + ) + }) + }) +} + +func TestNonDeterministicApplyEvent(t *testing.T) { + t.Run("panics if ApplyEvent produces different state on each call", func(t *testing.T) { + f := newScopeTestFixture() + + // Track the total number of ApplyEvent calls across all roots. + // The first call (on root) appends an extra event; the second + // call (on shadowRoot) does not, making the roots diverge. + applyCount := 0 + f.handler.NewFunc = func() *stubs.AggregateRootStub { + r := &stubs.AggregateRootStub{} + r.ApplyEventFunc = func(dogma.Event) { + applyCount++ + if applyCount == 1 { + r.AppliedEvents = append(r.AppliedEvents, stubs.EventA2) + } + } + return r + } + + f.cfg = runtimeconfig.FromAggregate(f.handler) + f.ctrl = &aggregate.Controller{ + Config: f.cfg, + MessageIDs: &f.messageIDs, + } + + f.handler.HandleCommandFunc = func( + _ *stubs.AggregateRootStub, + s dogma.AggregateCommandScope[*stubs.AggregateRootStub], + _ dogma.Command, + ) { + s.RecordEvent(stubs.EventA1) + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.command, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect( + t, + "unexpected panic description", + x.Description, + "non-deterministic implementation of ApplyEvent detected", + ) + }) + }) +} + +func TestSnapshotDivergence(t *testing.T) { + t.Run("panics if aggregate root state built from snapshot differs from events", func(t *testing.T) { + f := newScopeTestFixture() + + f.handler.NewFunc = func() *stubs.AggregateRootStub { + return &stubs.AggregateRootStub{ + UnmarshalBinaryFunc: func([]byte) error { + // Deliberately does not restore the state that + // MarshalBinary produced. The shadow root will have + // events applied, but the snapshot-based root will be + // empty, causing divergence. + return nil + }, + } + } + + f.cfg = runtimeconfig.FromAggregate(f.handler) + f.ctrl = &aggregate.Controller{ + Config: f.cfg, + MessageIDs: &f.messageIDs, + } + + // Seed an instance that records an event, triggering a snapshot. + f.handler.HandleCommandFunc = func( + _ *stubs.AggregateRootStub, + s dogma.AggregateCommandScope[*stubs.AggregateRootStub], + _ dogma.Command, + ) { + s.RecordEvent(stubs.EventA1) + } + + _, err := f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.command, + ) + if err != nil { + t.Fatal(err) + } + + // Send another command to the same instance. This time the + // controller loads the snapshot via UnmarshalBinary (which is + // broken) and compares against the event-replayed shadow root. + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.command, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect( + t, + "unexpected panic description", + x.Description, + "aggregate root state differs when built from events versus snapshot", + ) + xtesting.Expect( + t, + "unexpected panic interface", + x.Interface, + "AggregateRoot", + ) + xtesting.Expect( + t, + "unexpected panic method", + x.Method, + "UnmarshalBinary", + ) + }) + }) +} diff --git a/engine/internal/process/controller.go b/engine/internal/process/controller.go index 3299810..d78bd39 100644 --- a/engine/internal/process/controller.go +++ b/engine/internal/process/controller.go @@ -89,12 +89,13 @@ func (c *Controller) Handle( return nil, err } - inst, root := c.instanceByID(obs, env, id) + inst, root, shadowRoot := c.instanceByID(obs, env, id) s := &scope{ instanceID: id, instance: inst, root: root, + shadowRoot: shadowRoot, config: c.Config, handleMethod: message.MapByKindOf( env.Message, @@ -112,6 +113,8 @@ func (c *Controller) Handle( return nil, err } + s.guardAgainstDirectMutation("", location.Location{}) + if inst.ended { c.cancelDeadlines(id) return s.commands, nil @@ -142,10 +145,11 @@ func (c *Controller) instanceByID( obs fact.Observer, env *envelope.Envelope, id string, -) (*instance, dogma.ProcessRoot) { - root := c.Config.Source.Get().New() +) (inst *instance, root, shadowRoot dogma.ProcessRoot) { + root = c.Config.Source.Get().New() + shadowRoot = c.Config.Source.Get().New() - if xreflect.IsNil(root) { + if xreflect.IsNil(root) || xreflect.IsNil(shadowRoot) { panic(panicx.UnexpectedBehavior{ Handler: c.Config, Interface: "ProcessMessageHandler", @@ -157,51 +161,65 @@ func (c *Controller) instanceByID( }) } - if inst, ok := c.instances[id]; ok { - if inst.mutated { - if err := root.UnmarshalBinary(inst.data); err != nil { - panic(panicx.UnexpectedBehavior{ - Handler: c.Config, - Interface: "ProcessRoot", - Method: "UnmarshalBinary", - Implementation: root, - Message: env.Message, - Description: fmt.Sprintf("unable to unmarshal the process root: %s", err), - Location: location.OfMethod(root, "UnmarshalBinary"), - }) - } + inst, ok := c.instances[id] + if !ok { + obs.Notify(fact.ProcessInstanceNotFound{ + Handler: c.Config, + InstanceID: id, + Envelope: env, + }) + + if c.instances == nil { + c.instances = map[string]*instance{} } - obs.Notify(fact.ProcessInstanceLoaded{ + inst = &instance{} + c.instances[id] = inst + + obs.Notify(fact.ProcessInstanceBegun{ Handler: c.Config, InstanceID: id, Root: root, Envelope: env, }) - return inst, root + + return inst, root, shadowRoot } - obs.Notify(fact.ProcessInstanceNotFound{ - Handler: c.Config, - InstanceID: id, - Envelope: env, - }) + if inst.mutated { + if err := root.UnmarshalBinary(inst.data); err != nil { + panic(panicx.UnexpectedBehavior{ + Handler: c.Config, + Interface: "ProcessRoot", + Method: "UnmarshalBinary", + Implementation: root, + Message: env.Message, + Description: fmt.Sprintf("unable to unmarshal the process root: %s", err), + Location: location.OfMethod(root, "UnmarshalBinary"), + }) + } - if c.instances == nil { - c.instances = map[string]*instance{} + if err := shadowRoot.UnmarshalBinary(inst.data); err != nil { + panic(panicx.UnexpectedBehavior{ + Handler: c.Config, + Interface: "ProcessRoot", + Method: "UnmarshalBinary", + Implementation: shadowRoot, + Message: env.Message, + Description: fmt.Sprintf("unable to unmarshal the process root: %s", err), + Location: location.OfMethod(shadowRoot, "UnmarshalBinary"), + }) + } } - inst := &instance{} - c.instances[id] = inst - - obs.Notify(fact.ProcessInstanceBegun{ + obs.Notify(fact.ProcessInstanceLoaded{ Handler: c.Config, InstanceID: id, Root: root, Envelope: env, }) - return inst, root + return inst, root, shadowRoot } // Reset clears the state of the controller. diff --git a/engine/internal/process/controller_test.go b/engine/internal/process/controller_test.go index 7073574..67f5707 100644 --- a/engine/internal/process/controller_test.go +++ b/engine/internal/process/controller_test.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "testing" "time" @@ -17,16 +16,15 @@ import ( "github.com/dogmatiq/testkit/envelope" "github.com/dogmatiq/testkit/fact" "github.com/dogmatiq/testkit/internal/x/xtesting" - "github.com/dogmatiq/testkit/location" "github.com/google/go-cmp/cmp/cmpopts" ) func TestController(t *testing.T) { t.Run("HandlerConfig", func(t *testing.T) { - env := newControllerTestFixture() + f := newControllerTestFixture() - if got := env.ctrl.HandlerConfig(); got != env.cfg { - t.Fatalf("unexpected handler config: got %p, want %p", got, env.cfg) + if got := f.ctrl.HandlerConfig(); got != f.cfg { + t.Fatalf("unexpected handler config: got %p, want %p", got, f.cfg) } }) @@ -34,13 +32,13 @@ func TestController(t *testing.T) { setup := func(t *testing.T) (*controllerTestFixture, time.Time, time.Time, time.Time, time.Time) { t.Helper() - env := newControllerTestFixture() + f := newControllerTestFixture() createdTime := time.Now() t1Time := createdTime.Add(1 * time.Hour) t2Time := createdTime.Add(2 * time.Hour) t3Time := createdTime.Add(3 * time.Hour) - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -52,23 +50,23 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, createdTime, - env.event, + f.event, ) expectNoError(t, err) - env.messageIDs.Reset() + f.messageIDs.Reset() - return env, createdTime, t1Time, t2Time, t3Time + return f, createdTime, t1Time, t2Time, t3Time } t.Run("returns deadlines that are ready to be handled", func(t *testing.T) { - env, createdTime, t1Time, t2Time, _ := setup(t) + f, createdTime, t1Time, t2Time, _ := setup(t) - deadlines, err := env.ctrl.Tick( + deadlines, err := f.ctrl.Tick( context.Background(), fact.Ignore, t2Time, @@ -79,24 +77,24 @@ func TestController(t *testing.T) { t, deadlines, []*envelope.Envelope{ - env.event.NewDeadline( + f.event.NewDeadline( "3", DeadlineA1, createdTime, t1Time, envelope.Origin{ - Handler: env.cfg, + Handler: f.cfg, HandlerType: config.ProcessHandlerType, InstanceID: "", }, ), - env.event.NewDeadline( + f.event.NewDeadline( "2", DeadlineA2, createdTime, t2Time, envelope.Origin{ - Handler: env.cfg, + Handler: f.cfg, HandlerType: config.ProcessHandlerType, InstanceID: "", }, @@ -106,9 +104,9 @@ func TestController(t *testing.T) { }) t.Run("does not return the same deadlines multiple times", func(t *testing.T) { - env, _, _, t2Time, _ := setup(t) + f, _, _, t2Time, _ := setup(t) - deadlines, err := env.ctrl.Tick( + deadlines, err := f.ctrl.Tick( context.Background(), fact.Ignore, t2Time, @@ -119,7 +117,7 @@ func TestController(t *testing.T) { t.Fatalf("unexpected deadline count: got %d, want %d", got, want) } - deadlines, err = env.ctrl.Tick( + deadlines, err = f.ctrl.Tick( context.Background(), fact.Ignore, t2Time, @@ -132,7 +130,7 @@ func TestController(t *testing.T) { }) t.Run("does not return deadlines for instances that have been ended", func(t *testing.T) { - env, createdTime, t1Time, t2Time, _ := setup(t) + f, createdTime, t1Time, t2Time, _ := setup(t) secondInstanceEvent := envelope.NewEvent( "3000", @@ -140,7 +138,7 @@ func TestController(t *testing.T) { time.Now(), ) - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, createdTime, @@ -148,7 +146,7 @@ func TestController(t *testing.T) { ) expectNoError(t, err) - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -158,15 +156,15 @@ func TestController(t *testing.T) { return nil } - _, err = env.ctrl.Handle( + _, err = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) - deadlines, err := env.ctrl.Tick( + deadlines, err := f.ctrl.Tick( context.Background(), fact.Ignore, t2Time, @@ -183,7 +181,7 @@ func TestController(t *testing.T) { createdTime, t1Time, envelope.Origin{ - Handler: env.cfg, + Handler: f.cfg, HandlerType: config.ProcessHandlerType, InstanceID: "", }, @@ -194,7 +192,7 @@ func TestController(t *testing.T) { createdTime, t2Time, envelope.Origin{ - Handler: env.cfg, + Handler: f.cfg, HandlerType: config.ProcessHandlerType, InstanceID: "", }, @@ -207,10 +205,10 @@ func TestController(t *testing.T) { t.Run("Handle", func(t *testing.T) { t.Run("handling an event", func(t *testing.T) { t.Run("forwards the message to the handler", func(t *testing.T) { - env := newControllerTestFixture() + f := newControllerTestFixture() called := false - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, _ dogma.ProcessEventScope[*ProcessRootStub], @@ -221,11 +219,11 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -235,10 +233,10 @@ func TestController(t *testing.T) { }) t.Run("propagates handler errors", func(t *testing.T) { - env := newControllerTestFixture() + f := newControllerTestFixture() expected := errors.New("") - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, _ dogma.ProcessEventScope[*ProcessRootStub], @@ -247,21 +245,21 @@ func TestController(t *testing.T) { return expected } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) xtesting.Expect(t, "unexpected error", err, expected) }) t.Run("returns both commands and deadlines", func(t *testing.T) { - env := newControllerTestFixture() + f := newControllerTestFixture() now := time.Now() - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -272,11 +270,11 @@ func TestController(t *testing.T) { return nil } - envelopes, err := env.ctrl.Handle( + envelopes, err := f.ctrl.Handle( context.Background(), fact.Ignore, now, - env.event, + f.event, ) expectNoError(t, err) @@ -284,23 +282,23 @@ func TestController(t *testing.T) { t, envelopes, []*envelope.Envelope{ - env.event.NewCommand( + f.event.NewCommand( "1", CommandA1, now, envelope.Origin{ - Handler: env.cfg, + Handler: f.cfg, HandlerType: config.ProcessHandlerType, InstanceID: "", }, ), - env.event.NewDeadline( + f.event.NewDeadline( "2", DeadlineA1, now, now, envelope.Origin{ - Handler: env.cfg, + Handler: f.cfg, HandlerType: config.ProcessHandlerType, InstanceID: "", }, @@ -310,10 +308,10 @@ func TestController(t *testing.T) { }) t.Run("returns deadlines scheduled in the past", func(t *testing.T) { - env := newControllerTestFixture() + f := newControllerTestFixture() now := time.Now() - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -323,11 +321,11 @@ func TestController(t *testing.T) { return nil } - envelopes, err := env.ctrl.Handle( + envelopes, err := f.ctrl.Handle( context.Background(), fact.Ignore, now, - env.event, + f.event, ) expectNoError(t, err) @@ -337,10 +335,10 @@ func TestController(t *testing.T) { }) t.Run("does not return deadlines scheduled in the future", func(t *testing.T) { - env := newControllerTestFixture() + f := newControllerTestFixture() now := time.Now() - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -350,11 +348,11 @@ func TestController(t *testing.T) { return nil } - envelopes, err := env.ctrl.Handle( + envelopes, err := f.ctrl.Handle( context.Background(), fact.Ignore, now, - env.event, + f.event, ) expectNoError(t, err) @@ -365,15 +363,15 @@ func TestController(t *testing.T) { t.Run("when the event is not routed to an instance", func(t *testing.T) { t.Run("does not forward the message to the handler", func(t *testing.T) { - env := newControllerTestFixture() - env.handler.RouteEventToInstanceFunc = func( + f := newControllerTestFixture() + f.handler.RouteEventToInstanceFunc = func( context.Context, dogma.Event, ) (string, bool, error) { return "", false, nil } - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( context.Context, *ProcessRootStub, dogma.ProcessEventScope[*ProcessRootStub], @@ -383,18 +381,18 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) }) t.Run("records a fact", func(t *testing.T) { - env := newControllerTestFixture() - env.handler.RouteEventToInstanceFunc = func( + f := newControllerTestFixture() + f.handler.RouteEventToInstanceFunc = func( context.Context, dogma.Event, ) (string, bool, error) { @@ -402,11 +400,11 @@ func TestController(t *testing.T) { } buf := &fact.Buffer{} - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), buf, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -415,8 +413,8 @@ func TestController(t *testing.T) { buf.Facts(), []fact.Fact{ fact.ProcessEventIgnored{ - Handler: env.cfg, - Envelope: env.event, + Handler: f.cfg, + Envelope: f.event, }, }, ) @@ -427,8 +425,8 @@ func TestController(t *testing.T) { setup := func(t *testing.T) *controllerTestFixture { t.Helper() - env := newControllerTestFixture() - env.handler.HandleEventFunc = func( + f := newControllerTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -438,21 +436,21 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) - env.messageIDs.Reset() - return env + f.messageIDs.Reset() + return f } t.Run("does not forward the message to the handler", func(t *testing.T) { - env := setup(t) - env.handler.HandleEventFunc = func( + f := setup(t) + f.handler.HandleEventFunc = func( context.Context, *ProcessRootStub, dogma.ProcessEventScope[*ProcessRootStub], @@ -462,24 +460,24 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) }) t.Run("records a fact", func(t *testing.T) { - env := setup(t) + f := setup(t) buf := &fact.Buffer{} - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), buf, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -488,9 +486,9 @@ func TestController(t *testing.T) { buf.Facts(), []fact.Fact{ fact.ProcessEventRoutedToEndedInstance{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", - Envelope: env.event, + Envelope: f.event, }, }, ) @@ -502,8 +500,8 @@ func TestController(t *testing.T) { setup := func(t *testing.T) *controllerTestFixture { t.Helper() - env := newControllerTestFixture() - env.handler.HandleEventFunc = func( + f := newControllerTestFixture() + f.handler.HandleEventFunc = func( context.Context, *ProcessRootStub, dogma.ProcessEventScope[*ProcessRootStub], @@ -512,23 +510,23 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) - env.messageIDs.Reset() - return env + f.messageIDs.Reset() + return f } t.Run("forwards the message to the handler", func(t *testing.T) { - env := setup(t) + f := setup(t) called := false - env.handler.HandleDeadlineFunc = func( + f.handler.HandleDeadlineFunc = func( _ context.Context, _ *ProcessRootStub, _ dogma.ProcessDeadlineScope[*ProcessRootStub], @@ -539,11 +537,11 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.deadline, + f.deadline, ) expectNoError(t, err) @@ -553,10 +551,10 @@ func TestController(t *testing.T) { }) t.Run("propagates handler errors", func(t *testing.T) { - env := setup(t) + f := setup(t) expected := errors.New("") - env.handler.HandleDeadlineFunc = func( + f.handler.HandleDeadlineFunc = func( context.Context, *ProcessRootStub, dogma.ProcessDeadlineScope[*ProcessRootStub], @@ -565,21 +563,21 @@ func TestController(t *testing.T) { return expected } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.deadline, + f.deadline, ) xtesting.Expect(t, "unexpected error", err, expected) }) t.Run("returns both commands and deadlines", func(t *testing.T) { - env := setup(t) + f := setup(t) now := time.Now() - env.handler.HandleDeadlineFunc = func( + f.handler.HandleDeadlineFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessDeadlineScope[*ProcessRootStub], @@ -590,11 +588,11 @@ func TestController(t *testing.T) { return nil } - envelopes, err := env.ctrl.Handle( + envelopes, err := f.ctrl.Handle( context.Background(), fact.Ignore, now, - env.deadline, + f.deadline, ) expectNoError(t, err) @@ -602,23 +600,23 @@ func TestController(t *testing.T) { t, envelopes, []*envelope.Envelope{ - env.deadline.NewCommand( + f.deadline.NewCommand( "1", CommandA1, now, envelope.Origin{ - Handler: env.cfg, + Handler: f.cfg, HandlerType: config.ProcessHandlerType, InstanceID: "", }, ), - env.deadline.NewDeadline( + f.deadline.NewDeadline( "2", DeadlineA1, now, now, envelope.Origin{ - Handler: env.cfg, + Handler: f.cfg, HandlerType: config.ProcessHandlerType, InstanceID: "", }, @@ -628,10 +626,10 @@ func TestController(t *testing.T) { }) t.Run("returns deadlines scheduled in the past", func(t *testing.T) { - env := setup(t) + f := setup(t) now := time.Now() - env.handler.HandleDeadlineFunc = func( + f.handler.HandleDeadlineFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessDeadlineScope[*ProcessRootStub], @@ -641,11 +639,11 @@ func TestController(t *testing.T) { return nil } - envelopes, err := env.ctrl.Handle( + envelopes, err := f.ctrl.Handle( context.Background(), fact.Ignore, now, - env.deadline, + f.deadline, ) expectNoError(t, err) @@ -655,10 +653,10 @@ func TestController(t *testing.T) { }) t.Run("does not return deadlines scheduled in the future", func(t *testing.T) { - env := setup(t) + f := setup(t) now := time.Now() - env.handler.HandleDeadlineFunc = func( + f.handler.HandleDeadlineFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessDeadlineScope[*ProcessRootStub], @@ -668,11 +666,11 @@ func TestController(t *testing.T) { return nil } - envelopes, err := env.ctrl.Handle( + envelopes, err := f.ctrl.Handle( context.Background(), fact.Ignore, now, - env.deadline, + f.deadline, ) expectNoError(t, err) @@ -685,8 +683,8 @@ func TestController(t *testing.T) { setupEnded := func(t *testing.T) *controllerTestFixture { t.Helper() - env := setup(t) - env.handler.HandleEventFunc = func( + f := setup(t) + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -696,21 +694,21 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) - env.messageIDs.Reset() - return env + f.messageIDs.Reset() + return f } t.Run("does not forward the message to the handler", func(t *testing.T) { - env := setupEnded(t) - env.handler.HandleDeadlineFunc = func( + f := setupEnded(t) + f.handler.HandleDeadlineFunc = func( context.Context, *ProcessRootStub, dogma.ProcessDeadlineScope[*ProcessRootStub], @@ -720,24 +718,24 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.deadline, + f.deadline, ) expectNoError(t, err) }) t.Run("records a fact", func(t *testing.T) { - env := setupEnded(t) + f := setupEnded(t) buf := &fact.Buffer{} - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), buf, time.Now(), - env.deadline, + f.deadline, ) expectNoError(t, err) @@ -746,9 +744,9 @@ func TestController(t *testing.T) { buf.Facts(), []fact.Fact{ fact.ProcessDeadlineRoutedToEndedInstance{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", - Envelope: env.deadline, + Envelope: f.deadline, }, }, ) @@ -757,68 +755,64 @@ func TestController(t *testing.T) { }) t.Run("propagates routing errors", func(t *testing.T) { - env := newControllerTestFixture() + f := newControllerTestFixture() expected := errors.New("") - env.handler.RouteEventToInstanceFunc = func( + f.handler.RouteEventToInstanceFunc = func( context.Context, dogma.Event, ) (string, bool, error) { return "", true, expected } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) xtesting.Expect(t, "unexpected error", err, expected) }) t.Run("panics when the handler routes to an empty instance ID", func(t *testing.T) { - env := newControllerTestFixture() + f := newControllerTestFixture() - env.handler.RouteEventToInstanceFunc = func( + f.handler.RouteEventToInstanceFunc = func( context.Context, dogma.Event, ) (string, bool, error) { return "", true, nil } - expectUnexpectedBehavior( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - panicx.UnexpectedBehavior{ - Handler: env.cfg, - Interface: "ProcessMessageHandler", - Method: "RouteEventToInstance", - Implementation: env.cfg.Implementation(), - Message: env.event.Message, - Description: "routed an event of type *stubs.EventStub[TypeA] to an empty ID", - }, - "/stubs/process.go", - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "RouteEventToInstance") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + xtesting.Expect(t, "unexpected description", x.Description, "routed an event of type *stubs.EventStub[TypeA] to an empty ID") + xtesting.ExpectLocation(t, x.Location, "/stubs/process.go") + }) }) t.Run("when the instance does not exist", func(t *testing.T) { t.Run("records facts", func(t *testing.T) { - env := newControllerTestFixture() + f := newControllerTestFixture() buf := &fact.Buffer{} - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), buf, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -827,46 +821,42 @@ func TestController(t *testing.T) { buf.Facts(), []fact.Fact{ fact.ProcessInstanceNotFound{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", - Envelope: env.event, + Envelope: f.event, }, fact.ProcessInstanceBegun{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, + Envelope: f.event, }, }, ) }) t.Run("panics if New returns nil", func(t *testing.T) { - env := newControllerTestFixture() - env.handler.NewFunc = func() *ProcessRootStub { + f := newControllerTestFixture() + f.handler.NewFunc = func() *ProcessRootStub { return nil } - expectUnexpectedBehavior( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - panicx.UnexpectedBehavior{ - Handler: env.cfg, - Interface: "ProcessMessageHandler", - Method: "New", - Implementation: env.cfg.Implementation(), - Message: env.event.Message, - Description: "returned a nil process root", - }, - "/stubs/process.go", - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "New") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + xtesting.Expect(t, "unexpected description", x.Description, "returned a nil process root") + xtesting.ExpectLocation(t, x.Location, "/stubs/process.go") + }) }) }) @@ -874,8 +864,8 @@ func TestController(t *testing.T) { setup := func(t *testing.T) *controllerTestFixture { t.Helper() - env := newControllerTestFixture() - env.handler.HandleEventFunc = func( + f := newControllerTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, _ dogma.ProcessEventScope[*ProcessRootStub], @@ -884,27 +874,27 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) - env.messageIDs.Reset() - return env + f.messageIDs.Reset() + return f } t.Run("records a fact", func(t *testing.T) { - env := setup(t) + f := setup(t) buf := &fact.Buffer{} - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), buf, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -913,19 +903,19 @@ func TestController(t *testing.T) { buf.Facts(), []fact.Fact{ fact.ProcessInstanceLoaded{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, + Envelope: f.event, }, }, ) }) t.Run("provides the root with state from the prior Handle() call", func(t *testing.T) { - env := newControllerTestFixture() + f := newControllerTestFixture() - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, r *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -937,16 +927,16 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) var got string - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, r *ProcessRootStub, _ dogma.ProcessEventScope[*ProcessRootStub], @@ -956,11 +946,11 @@ func TestController(t *testing.T) { return nil } - _, err = env.ctrl.Handle( + _, err = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -970,62 +960,57 @@ func TestController(t *testing.T) { }) t.Run("panics if New() returns nil", func(t *testing.T) { - env := setup(t) - env.handler.NewFunc = func() *ProcessRootStub { + f := setup(t) + f.handler.NewFunc = func() *ProcessRootStub { return nil } - expectUnexpectedBehavior( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - panicx.UnexpectedBehavior{ - Handler: env.cfg, - Interface: "ProcessMessageHandler", - Method: "New", - Implementation: env.cfg.Implementation(), - Message: env.event.Message, - Description: "returned a nil process root", - }, - "/stubs/process.go", - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "New") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + xtesting.Expect(t, "unexpected description", x.Description, "returned a nil process root") + xtesting.ExpectLocation(t, x.Location, "/stubs/process.go") + }) }) }) t.Run("provides more context to UnexpectedMessage panics from RouteEventToInstance", func(t *testing.T) { - env := newControllerTestFixture() - env.handler.RouteEventToInstanceFunc = func( + f := newControllerTestFixture() + f.handler.RouteEventToInstanceFunc = func( context.Context, dogma.Event, ) (string, bool, error) { panic(dogma.UnexpectedMessage) } - expectUnexpectedMessage( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - env.cfg, - "RouteEventToInstance", - env.event.Message, - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedMessage) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "RouteEventToInstance") + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + }) }) t.Run("provides more context to UnexpectedMessage panics from HandleEvent", func(t *testing.T) { - env := newControllerTestFixture() - env.handler.HandleEventFunc = func( + f := newControllerTestFixture() + f.handler.HandleEventFunc = func( context.Context, *ProcessRootStub, dogma.ProcessEventScope[*ProcessRootStub], @@ -1034,25 +1019,24 @@ func TestController(t *testing.T) { panic(dogma.UnexpectedMessage) } - expectUnexpectedMessage( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - env.cfg, - "HandleEvent", - env.event.Message, - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedMessage) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleEvent") + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + }) }) t.Run("provides more context to UnexpectedMessage panics from HandleDeadline", func(t *testing.T) { - env := newControllerTestFixture() - env.handler.HandleEventFunc = func( + f := newControllerTestFixture() + f.handler.HandleEventFunc = func( context.Context, *ProcessRootStub, dogma.ProcessEventScope[*ProcessRootStub], @@ -1061,15 +1045,15 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) - env.handler.HandleDeadlineFunc = func( + f.handler.HandleDeadlineFunc = func( context.Context, *ProcessRootStub, dogma.ProcessDeadlineScope[*ProcessRootStub], @@ -1078,25 +1062,24 @@ func TestController(t *testing.T) { panic(dogma.UnexpectedMessage) } - expectUnexpectedMessage( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.deadline, - ) - }, - env.cfg, - "HandleDeadline", - env.deadline.Message, - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.deadline, + ) + }, func(x panicx.UnexpectedMessage) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleDeadline") + xtesting.Expect(t, "unexpected message", x.Message, f.deadline.Message) + }) }) t.Run("panics if MarshalBinary() fails", func(t *testing.T) { - env := newControllerTestFixture() - env.handler.HandleEventFunc = func( + f := newControllerTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -1114,19 +1097,19 @@ func TestController(t *testing.T) { t, "the '' process message handler behaved unexpectedly in *stubs.ProcessRootStub.MarshalBinary(): unable to marshal the process root: ", func() { - _, _ = env.ctrl.Handle( + _, _ = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) }, ) }) t.Run("panics if UnmarshalBinary fails", func(t *testing.T) { - env := newControllerTestFixture() - env.handler.HandleEventFunc = func( + f := newControllerTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -1137,16 +1120,16 @@ func TestController(t *testing.T) { } // First Handle: creates instance, mutates, marshal succeeds. - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) // Second Handle: loads instance, unmarshal fails. - env.handler.NewFunc = func() *ProcessRootStub { + f.handler.NewFunc = func() *ProcessRootStub { return &ProcessRootStub{ UnmarshalBinaryFunc: func([]byte) error { return errors.New("") @@ -1158,19 +1141,19 @@ func TestController(t *testing.T) { t, "the '' process message handler behaved unexpectedly in *stubs.ProcessRootStub.UnmarshalBinary(): unable to unmarshal the process root: ", func() { - _, _ = env.ctrl.Handle( + _, _ = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) }, ) }) t.Run("calls UnmarshalBinary when MarshalBinary returns nil", func(t *testing.T) { - env := newControllerTestFixture() - env.handler.HandleEventFunc = func( + f := newControllerTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -1184,16 +1167,16 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) called := false - env.handler.NewFunc = func() *ProcessRootStub { + f.handler.NewFunc = func() *ProcessRootStub { return &ProcessRootStub{ UnmarshalBinaryFunc: func([]byte) error { called = true @@ -1202,11 +1185,11 @@ func TestController(t *testing.T) { } } - _, err = env.ctrl.Handle( + _, err = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -1214,8 +1197,8 @@ func TestController(t *testing.T) { }) t.Run("calls UnmarshalBinary when MarshalBinary returns an empty slice", func(t *testing.T) { - env := newControllerTestFixture() - env.handler.HandleEventFunc = func( + f := newControllerTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -1229,16 +1212,16 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) called := false - env.handler.NewFunc = func() *ProcessRootStub { + f.handler.NewFunc = func() *ProcessRootStub { return &ProcessRootStub{ UnmarshalBinaryFunc: func([]byte) error { called = true @@ -1247,11 +1230,11 @@ func TestController(t *testing.T) { } } - _, err = env.ctrl.Handle( + _, err = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -1260,8 +1243,8 @@ func TestController(t *testing.T) { }) t.Run("Reset", func(t *testing.T) { - env := newControllerTestFixture() - env.handler.HandleEventFunc = func( + f := newControllerTestFixture() + f.handler.HandleEventFunc = func( context.Context, *ProcessRootStub, dogma.ProcessEventScope[*ProcessRootStub], @@ -1270,23 +1253,23 @@ func TestController(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) - env.messageIDs.Reset() - env.ctrl.Reset() + f.messageIDs.Reset() + f.ctrl.Reset() buf := &fact.Buffer{} - _, err = env.ctrl.Handle( + _, err = f.ctrl.Handle( context.Background(), buf, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -1295,15 +1278,15 @@ func TestController(t *testing.T) { buf.Facts(), []fact.Fact{ fact.ProcessInstanceNotFound{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", - Envelope: env.event, + Envelope: f.event, }, fact.ProcessInstanceBegun{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, + Envelope: f.event, }, }, ) @@ -1361,21 +1344,21 @@ func newControllerTestFixture() *controllerTestFixture { }, ) - env := &controllerTestFixture{ + f := &controllerTestFixture{ handler: handler, cfg: cfg, event: event, deadline: deadline, } - env.ctrl = &Controller{ + f.ctrl = &Controller{ Config: cfg, - MessageIDs: &env.messageIDs, + MessageIDs: &f.messageIDs, } - env.messageIDs.Reset() + f.messageIDs.Reset() - return env + return f } func expectNoError(t *testing.T, err error) { @@ -1406,74 +1389,3 @@ func expectFacts(t *testing.T, got, want []fact.Fact) { t.Helper() xtesting.Expect(t, "unexpected facts", got, want) } - -func expectUnexpectedBehavior( - t *testing.T, - fn func(), - want panicx.UnexpectedBehavior, - wantFileSuffix string, -) { - t.Helper() - - defer func() { - r := recover() - if r == nil { - t.Fatal("expected panicx.UnexpectedBehavior panic, got nil") - } - - x, ok := r.(panicx.UnexpectedBehavior) - if !ok { - t.Fatalf("expected panicx.UnexpectedBehavior panic, got %T", r) - } - - loc := x.Location - x.Location = location.Location{} - want.Location = location.Location{} - - xtesting.Expect(t, "unexpected panic", x, want) - - if loc.Func == "" { - t.Fatal("unexpected empty panic location func") - } - - if !strings.HasSuffix(loc.File, wantFileSuffix) { - t.Fatalf("unexpected panic location file: %s", loc.File) - } - - if loc.Line == 0 { - t.Fatal("unexpected zero panic location line") - } - }() - - fn() -} - -func expectUnexpectedMessage( - t *testing.T, - fn func(), - handler *config.Process, - method string, - message dogma.Message, -) { - t.Helper() - - defer func() { - r := recover() - if r == nil { - t.Fatal("expected panicx.UnexpectedMessage panic, got nil") - } - - x, ok := r.(panicx.UnexpectedMessage) - if !ok { - t.Fatalf("expected panicx.UnexpectedMessage panic, got %T", r) - } - - xtesting.Expect(t, "unexpected handler", x.Handler, handler) - xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") - xtesting.Expect(t, "unexpected method", x.Method, method) - xtesting.Expect(t, "unexpected implementation", x.Implementation, handler.Implementation()) - xtesting.Expect(t, "unexpected message", x.Message, message) - }() - - fn() -} diff --git a/engine/internal/process/scope.go b/engine/internal/process/scope.go index 6366941..fe1aadf 100644 --- a/engine/internal/process/scope.go +++ b/engine/internal/process/scope.go @@ -10,6 +10,7 @@ import ( "github.com/dogmatiq/testkit/engine/internal/panicx" "github.com/dogmatiq/testkit/envelope" "github.com/dogmatiq/testkit/fact" + "github.com/dogmatiq/testkit/internal/compare" "github.com/dogmatiq/testkit/internal/validation" "github.com/dogmatiq/testkit/location" ) @@ -17,26 +18,30 @@ import ( // scope is an implementation of dogma.ProcessEventScope and // dogma.ProcessDeadlineScope. type scope struct { - instanceID string - instance *instance - root dogma.ProcessRoot - mutated bool - config *config.Process - handleMethod string - messageIDs *envelope.MessageIDGenerator - observer fact.Observer - now time.Time - env *envelope.Envelope // event or deadline - commands []*envelope.Envelope - ready []*envelope.Envelope // deadlines <= now - pending []*envelope.Envelope // deadlines > now + instanceID string + instance *instance + root, shadowRoot dogma.ProcessRoot + mutated bool + lastOp string + config *config.Process + handleMethod string + messageIDs *envelope.MessageIDGenerator + observer fact.Observer + now time.Time + env *envelope.Envelope // event or deadline + commands []*envelope.Envelope + ready []*envelope.Envelope // deadlines <= now + pending []*envelope.Envelope // deadlines > now } func (s *scope) InstanceID() string { + s.guardAgainstDirectMutation("InstanceID", location.OfCall()) return s.instanceID } func (s *scope) End() { + s.guardAgainstDirectMutation("End", location.OfCall()) + if s.instance.ended { return } @@ -52,6 +57,8 @@ func (s *scope) End() { } func (s *scope) Mutate(fn func(r dogma.ProcessRoot)) { + s.guardAgainstDirectMutation("Mutate", location.OfCall()) + if s.instance.ended { panic(panicx.UnexpectedBehavior{ Handler: s.config, @@ -66,9 +73,24 @@ func (s *scope) Mutate(fn func(r dogma.ProcessRoot)) { s.mutated = true fn(s.root) + fn(s.shadowRoot) + + if !compare.Equal(s.root, s.shadowRoot) { + panic(panicx.UnexpectedBehavior{ + Handler: s.config, + Interface: "ProcessMessageHandler", + Method: s.handleMethod, + Implementation: s.config.Implementation(), + Message: s.env.Message, + Description: "non-deterministic implementation of Mutate() callback detected", + Location: location.OfFunc(fn), + }) + } } func (s *scope) ExecuteCommand(m dogma.Command) { + s.guardAgainstDirectMutation("ExecuteCommand", location.OfCall()) + mt := message.TypeOf(m) if !s.config.RouteSet().DirectionOf(mt).Has(config.OutboundDirection) { @@ -130,10 +152,13 @@ func (s *scope) ExecuteCommand(m dogma.Command) { } func (s *scope) RecordedAt() time.Time { + s.guardAgainstDirectMutation("RecordedAt", location.OfCall()) return s.env.CreatedAt } func (s *scope) ScheduleDeadline(m dogma.Deadline, t time.Time) { + s.guardAgainstDirectMutation("ScheduleDeadline", location.OfCall()) + mt := message.TypeOf(m) if !s.config.RouteSet().DirectionOf(mt).Has(config.OutboundDirection) { @@ -200,14 +225,18 @@ func (s *scope) ScheduleDeadline(m dogma.Deadline, t time.Time) { } func (s *scope) ScheduledFor() time.Time { + s.guardAgainstDirectMutation("ScheduledFor", location.OfCall()) return s.env.ScheduledFor } func (s *scope) Now() time.Time { + s.guardAgainstDirectMutation("Now", location.OfCall()) return s.now } func (s *scope) Log(f string, v ...any) { + s.guardAgainstDirectMutation("Log", location.OfCall()) + s.observer.Notify(fact.MessageLoggedByProcess{ Handler: s.config, InstanceID: s.instanceID, @@ -218,3 +247,35 @@ func (s *scope) Log(f string, v ...any) { LogArguments: v, }) } + +func (s *scope) guardAgainstDirectMutation(method string, loc location.Location) { + thisOp := "" + if method != "" { + thisOp = fmt.Sprintf("call to %s() at %s", method, loc) + } + + if !compare.Equal(s.root, s.shadowRoot) { + desc := "modified the process root without using Mutate()" + + switch { + case s.lastOp != "" && thisOp != "": + desc += ", between " + s.lastOp + " and " + thisOp + case s.lastOp != "": + desc += ", after " + s.lastOp + case thisOp != "": + desc += ", before " + thisOp + } + + panic(panicx.UnexpectedBehavior{ + Handler: s.config, + Interface: "ProcessMessageHandler", + Method: s.handleMethod, + Implementation: s.config.Implementation(), + Message: s.env.Message, + Description: desc, + Location: location.OfCall(), + }) + } + + s.lastOp = thisOp +} diff --git a/engine/internal/process/scope_test.go b/engine/internal/process/scope_test.go index fe48894..7ffcc41 100644 --- a/engine/internal/process/scope_test.go +++ b/engine/internal/process/scope_test.go @@ -2,6 +2,7 @@ package process_test import ( "context" + "strings" "testing" "time" @@ -18,10 +19,10 @@ import ( func TestScope(t *testing.T) { t.Run("InstanceID", func(t *testing.T) { - env := newProcessScopeTestEnv() + f := newProcessTestFixture() called := false - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -32,11 +33,11 @@ func TestScope(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -47,8 +48,8 @@ func TestScope(t *testing.T) { t.Run("End", func(t *testing.T) { t.Run("records a fact", func(t *testing.T) { - env := newProcessScopeTestEnv() - env.handler.HandleEventFunc = func( + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -59,11 +60,11 @@ func TestScope(t *testing.T) { } buf := &fact.Buffer{} - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), buf, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -72,29 +73,29 @@ func TestScope(t *testing.T) { buf.Facts(), []fact.Fact{ fact.ProcessInstanceNotFound{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", - Envelope: env.event, + Envelope: f.event, }, fact.ProcessInstanceBegun{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, + Envelope: f.event, }, fact.ProcessInstanceEnded{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, + Envelope: f.event, }, }, ) }) t.Run("does nothing if the instance has already been ended", func(t *testing.T) { - env := newProcessScopeTestEnv() - env.handler.HandleEventFunc = func( + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -106,11 +107,11 @@ func TestScope(t *testing.T) { } buf := &fact.Buffer{} - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), buf, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -119,21 +120,21 @@ func TestScope(t *testing.T) { buf.Facts(), []fact.Fact{ fact.ProcessInstanceNotFound{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", - Envelope: env.event, + Envelope: f.event, }, fact.ProcessInstanceBegun{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, + Envelope: f.event, }, fact.ProcessInstanceEnded{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, + Envelope: f.event, }, }, ) @@ -142,8 +143,8 @@ func TestScope(t *testing.T) { t.Run("ExecuteCommand", func(t *testing.T) { t.Run("records a fact", func(t *testing.T) { - env := newProcessScopeTestEnv() - env.handler.HandleEventFunc = func( + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -155,11 +156,11 @@ func TestScope(t *testing.T) { buf := &fact.Buffer{} now := time.Now() - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), buf, now, - env.event, + f.event, ) expectNoError(t, err) @@ -168,27 +169,27 @@ func TestScope(t *testing.T) { buf.Facts(), []fact.Fact{ fact.ProcessInstanceNotFound{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", - Envelope: env.event, + Envelope: f.event, }, fact.ProcessInstanceBegun{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, + Envelope: f.event, }, fact.CommandExecutedByProcess{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, - CommandEnvelope: env.event.NewCommand( + Envelope: f.event, + CommandEnvelope: f.event.NewCommand( "1", CommandA1, now, envelope.Origin{ - Handler: env.cfg, + Handler: f.cfg, HandlerType: config.ProcessHandlerType, InstanceID: "", }, @@ -199,8 +200,8 @@ func TestScope(t *testing.T) { }) t.Run("panics if the command type is not configured to be produced", func(t *testing.T) { - env := newProcessScopeTestEnv() - env.handler.HandleEventFunc = func( + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -210,31 +211,27 @@ func TestScope(t *testing.T) { return nil } - expectUnexpectedBehavior( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - panicx.UnexpectedBehavior{ - Handler: env.cfg, - Interface: "ProcessMessageHandler", - Method: "HandleEvent", - Implementation: env.cfg.Implementation(), - Message: env.event.Message, - Description: "executed a command of type *stubs.CommandStub[TypeX], which is not produced by this handler", - }, - "/engine/internal/process/scope_test.go", - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleEvent") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + xtesting.Expect(t, "unexpected description", x.Description, "executed a command of type *stubs.CommandStub[TypeX], which is not produced by this handler") + xtesting.ExpectLocation(t, x.Location, "/engine/internal/process/scope_test.go") + }) }) t.Run("panics if the command is invalid", func(t *testing.T) { - env := newProcessScopeTestEnv() - env.handler.HandleEventFunc = func( + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -246,31 +243,27 @@ func TestScope(t *testing.T) { return nil } - expectUnexpectedBehavior( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - panicx.UnexpectedBehavior{ - Handler: env.cfg, - Interface: "ProcessMessageHandler", - Method: "HandleEvent", - Implementation: env.cfg.Implementation(), - Message: env.event.Message, - Description: "executed an invalid *stubs.CommandStub[TypeA] command: ", - }, - "/engine/internal/process/scope_test.go", - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleEvent") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + xtesting.Expect(t, "unexpected description", x.Description, "executed an invalid *stubs.CommandStub[TypeA] command: ") + xtesting.ExpectLocation(t, x.Location, "/engine/internal/process/scope_test.go") + }) }) t.Run("panics if the process has ended", func(t *testing.T) { - env := newProcessScopeTestEnv() - env.handler.HandleEventFunc = func( + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -281,34 +274,30 @@ func TestScope(t *testing.T) { return nil } - expectUnexpectedBehavior( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - panicx.UnexpectedBehavior{ - Handler: env.cfg, - Interface: "ProcessMessageHandler", - Method: "HandleEvent", - Implementation: env.cfg.Implementation(), - Message: env.event.Message, - Description: "executed a command of type *stubs.CommandStub[TypeA] on an ended process", - }, - "/engine/internal/process/scope_test.go", - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleEvent") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + xtesting.Expect(t, "unexpected description", x.Description, "executed a command of type *stubs.CommandStub[TypeA] on an ended process") + xtesting.ExpectLocation(t, x.Location, "/engine/internal/process/scope_test.go") + }) }) }) t.Run("ScheduleDeadline", func(t *testing.T) { t.Run("records a fact", func(t *testing.T) { - env := newProcessScopeTestEnv() + f := newProcessTestFixture() scheduledFor := time.Now().Add(10 * time.Second) - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -320,11 +309,11 @@ func TestScope(t *testing.T) { buf := &fact.Buffer{} now := time.Now() - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), buf, now, - env.event, + f.event, ) expectNoError(t, err) @@ -333,28 +322,28 @@ func TestScope(t *testing.T) { buf.Facts(), []fact.Fact{ fact.ProcessInstanceNotFound{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", - Envelope: env.event, + Envelope: f.event, }, fact.ProcessInstanceBegun{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, + Envelope: f.event, }, fact.DeadlineScheduledByProcess{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, - DeadlineEnvelope: env.event.NewDeadline( + Envelope: f.event, + DeadlineEnvelope: f.event.NewDeadline( "1", DeadlineA1, now, scheduledFor, envelope.Origin{ - Handler: env.cfg, + Handler: f.cfg, HandlerType: config.ProcessHandlerType, InstanceID: "", }, @@ -365,8 +354,8 @@ func TestScope(t *testing.T) { }) t.Run("panics if the deadline type is not configured to be scheduled", func(t *testing.T) { - env := newProcessScopeTestEnv() - env.handler.HandleEventFunc = func( + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -376,31 +365,27 @@ func TestScope(t *testing.T) { return nil } - expectUnexpectedBehavior( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - panicx.UnexpectedBehavior{ - Handler: env.cfg, - Interface: "ProcessMessageHandler", - Method: "HandleEvent", - Implementation: env.cfg.Implementation(), - Message: env.event.Message, - Description: "scheduled a deadline of type *stubs.DeadlineStub[TypeX], which is not produced by this handler", - }, - "/engine/internal/process/scope_test.go", - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleEvent") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + xtesting.Expect(t, "unexpected description", x.Description, "scheduled a deadline of type *stubs.DeadlineStub[TypeX], which is not produced by this handler") + xtesting.ExpectLocation(t, x.Location, "/engine/internal/process/scope_test.go") + }) }) t.Run("panics if the deadline is invalid", func(t *testing.T) { - env := newProcessScopeTestEnv() - env.handler.HandleEventFunc = func( + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -415,32 +400,28 @@ func TestScope(t *testing.T) { return nil } - expectUnexpectedBehavior( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - panicx.UnexpectedBehavior{ - Handler: env.cfg, - Interface: "ProcessMessageHandler", - Method: "HandleEvent", - Implementation: env.cfg.Implementation(), - Message: env.event.Message, - Description: "scheduled an invalid *stubs.DeadlineStub[TypeA] deadline: ", - }, - "/engine/internal/process/scope_test.go", - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleEvent") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + xtesting.Expect(t, "unexpected description", x.Description, "scheduled an invalid *stubs.DeadlineStub[TypeA] deadline: ") + xtesting.ExpectLocation(t, x.Location, "/engine/internal/process/scope_test.go") + }) }) t.Run("panics if the process has ended", func(t *testing.T) { - env := newProcessScopeTestEnv() + f := newProcessTestFixture() scheduledFor := time.Now().Add(10 * time.Second) - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -451,53 +432,49 @@ func TestScope(t *testing.T) { return nil } - expectUnexpectedBehavior( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - panicx.UnexpectedBehavior{ - Handler: env.cfg, - Interface: "ProcessMessageHandler", - Method: "HandleEvent", - Implementation: env.cfg.Implementation(), - Message: env.event.Message, - Description: "scheduled a deadline of type *stubs.DeadlineStub[TypeA] on an ended process", - }, - "/engine/internal/process/scope_test.go", - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleEvent") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + xtesting.Expect(t, "unexpected description", x.Description, "scheduled a deadline of type *stubs.DeadlineStub[TypeA] on an ended process") + xtesting.ExpectLocation(t, x.Location, "/engine/internal/process/scope_test.go") + }) }) }) t.Run("ScheduledFor", func(t *testing.T) { - env := newProcessScopeTestEnv() + f := newProcessTestFixture() - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) - deadline := env.event.NewDeadline( + deadline := f.event.NewDeadline( "2000", DeadlineA1, time.Now(), time.Now().Add(10*time.Second), envelope.Origin{ - Handler: env.cfg, + Handler: f.cfg, HandlerType: config.ProcessHandlerType, InstanceID: "", }, ) - env.handler.HandleDeadlineFunc = func( + f.handler.HandleDeadlineFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessDeadlineScope[*ProcessRootStub], @@ -513,7 +490,7 @@ func TestScope(t *testing.T) { return nil } - _, err = env.ctrl.Handle( + _, err = f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), @@ -524,10 +501,10 @@ func TestScope(t *testing.T) { t.Run("Mutate", func(t *testing.T) { t.Run("calls the function with the instance root", func(t *testing.T) { - env := newProcessScopeTestEnv() + f := newProcessTestFixture() called := false - env.handler.HandleEventFunc = func( + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -540,11 +517,11 @@ func TestScope(t *testing.T) { return nil } - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), fact.Ignore, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -554,8 +531,8 @@ func TestScope(t *testing.T) { }) t.Run("panics if the process has ended", func(t *testing.T) { - env := newProcessScopeTestEnv() - env.handler.HandleEventFunc = func( + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -566,32 +543,28 @@ func TestScope(t *testing.T) { return nil } - expectUnexpectedBehavior( - t, - func() { - _, _ = env.ctrl.Handle( - context.Background(), - fact.Ignore, - time.Now(), - env.event, - ) - }, - panicx.UnexpectedBehavior{ - Handler: env.cfg, - Interface: "ProcessMessageHandler", - Method: "HandleEvent", - Implementation: env.cfg.Implementation(), - Message: env.event.Message, - Description: "mutated an ended process instance", - }, - "/engine/internal/process/scope_test.go", - ) + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected handler", x.Handler, f.cfg) + xtesting.Expect(t, "unexpected interface", x.Interface, "ProcessMessageHandler") + xtesting.Expect(t, "unexpected method", x.Method, "HandleEvent") + xtesting.Expect(t, "unexpected implementation", x.Implementation, f.cfg.Implementation()) + xtesting.Expect(t, "unexpected message", x.Message, f.event.Message) + xtesting.Expect(t, "unexpected description", x.Description, "mutated an ended process instance") + xtesting.ExpectLocation(t, x.Location, "/engine/internal/process/scope_test.go") + }) }) }) t.Run("Log", func(t *testing.T) { - env := newProcessScopeTestEnv() - env.handler.HandleEventFunc = func( + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( _ context.Context, _ *ProcessRootStub, s dogma.ProcessEventScope[*ProcessRootStub], @@ -602,11 +575,11 @@ func TestScope(t *testing.T) { } buf := &fact.Buffer{} - _, err := env.ctrl.Handle( + _, err := f.ctrl.Handle( context.Background(), buf, time.Now(), - env.event, + f.event, ) expectNoError(t, err) @@ -615,21 +588,21 @@ func TestScope(t *testing.T) { buf.Facts(), []fact.Fact{ fact.ProcessInstanceNotFound{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", - Envelope: env.event, + Envelope: f.event, }, fact.ProcessInstanceBegun{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, + Envelope: f.event, }, fact.MessageLoggedByProcess{ - Handler: env.cfg, + Handler: f.cfg, InstanceID: "", Root: &ProcessRootStub{}, - Envelope: env.event, + Envelope: f.event, LogFormat: "", LogArguments: []any{ "", @@ -641,7 +614,293 @@ func TestScope(t *testing.T) { }) } -type processScopeTestEnv struct { +func TestMutationDetection(t *testing.T) { + t.Run("panics if the handler modifies the root before calling ExecuteCommand", func(t *testing.T) { + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( + _ context.Context, + r *ProcessRootStub, + s dogma.ProcessEventScope[*ProcessRootStub], + _ dogma.Event, + ) error { + r.Value = "" + s.ExecuteCommand(CommandA1) + return nil + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the process root without using Mutate(), before call to ExecuteCommand() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics if the handler modifies the root before calling InstanceID", func(t *testing.T) { + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( + _ context.Context, + r *ProcessRootStub, + s dogma.ProcessEventScope[*ProcessRootStub], + _ dogma.Event, + ) error { + r.Value = "" + s.InstanceID() + return nil + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the process root without using Mutate(), before call to InstanceID() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics if the handler modifies the root before calling Now", func(t *testing.T) { + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( + _ context.Context, + r *ProcessRootStub, + s dogma.ProcessEventScope[*ProcessRootStub], + _ dogma.Event, + ) error { + r.Value = "" + s.Now() + return nil + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the process root without using Mutate(), before call to Now() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics if the handler modifies the root before calling Log", func(t *testing.T) { + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( + _ context.Context, + r *ProcessRootStub, + s dogma.ProcessEventScope[*ProcessRootStub], + _ dogma.Event, + ) error { + r.Value = "" + s.Log("hello") + return nil + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the process root without using Mutate(), before call to Log() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics if the handler modifies the root before calling End", func(t *testing.T) { + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( + _ context.Context, + r *ProcessRootStub, + s dogma.ProcessEventScope[*ProcessRootStub], + _ dogma.Event, + ) error { + r.Value = "" + s.End() + return nil + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the process root without using Mutate(), before call to End() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics if the handler modifies the root before calling Mutate", func(t *testing.T) { + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( + _ context.Context, + r *ProcessRootStub, + s dogma.ProcessEventScope[*ProcessRootStub], + _ dogma.Event, + ) error { + r.Value = "" + s.Mutate(func(*ProcessRootStub) {}) + return nil + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the process root without using Mutate(), before call to Mutate() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics if the handler modifies the root before calling ScheduleDeadline", func(t *testing.T) { + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( + _ context.Context, + r *ProcessRootStub, + s dogma.ProcessEventScope[*ProcessRootStub], + _ dogma.Event, + ) error { + r.Value = "" + s.ScheduleDeadline(DeadlineA1, time.Now()) + return nil + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the process root without using Mutate(), before call to ScheduleDeadline() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics if the handler modifies the root between two scope calls", func(t *testing.T) { + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( + _ context.Context, + r *ProcessRootStub, + s dogma.ProcessEventScope[*ProcessRootStub], + _ dogma.Event, + ) error { + s.InstanceID() + r.Value = "" + s.ExecuteCommand(CommandA1) + return nil + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + wantPrefix := "modified the process root without using Mutate(), between call to InstanceID() at" + if !strings.HasPrefix(x.Description, wantPrefix) { + t.Fatalf("unexpected panic description: %s", x.Description) + } + }) + }) + + t.Run("panics at end of handler if the root was modified without a scope call", func(t *testing.T) { + f := newProcessTestFixture() + f.handler.HandleEventFunc = func( + _ context.Context, + r *ProcessRootStub, + _ dogma.ProcessEventScope[*ProcessRootStub], + _ dogma.Event, + ) error { + r.Value = "" + return nil + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected description", x.Description, "modified the process root without using Mutate()") + }) + }) +} + +func TestNonDeterministicMutate(t *testing.T) { + t.Run("panics if Mutate callback produces different state on each call", func(t *testing.T) { + f := newProcessTestFixture() + + callCount := 0 + f.handler.HandleEventFunc = func( + _ context.Context, + _ *ProcessRootStub, + s dogma.ProcessEventScope[*ProcessRootStub], + _ dogma.Event, + ) error { + s.Mutate(func(r *ProcessRootStub) { + callCount++ + if callCount == 1 { + r.Value = "" + } else { + r.Value = "" + } + }) + return nil + } + + xtesting.ExpectPanicMatching(t, func() { + _, _ = f.ctrl.Handle( + context.Background(), + fact.Ignore, + time.Now(), + f.event, + ) + }, func(x panicx.UnexpectedBehavior) { + xtesting.Expect(t, "unexpected description", x.Description, "non-deterministic implementation of Mutate() callback detected") + }) + }) +} + +type processTestFixture struct { messageIDs envelope.MessageIDGenerator handler *ProcessMessageHandlerStub[*ProcessRootStub] cfg *config.Process @@ -649,7 +908,7 @@ type processScopeTestEnv struct { event *envelope.Envelope } -func newProcessScopeTestEnv() *processScopeTestEnv { +func newProcessTestFixture() *processTestFixture { event := envelope.NewEvent( "1000", EventA1, @@ -679,18 +938,18 @@ func newProcessScopeTestEnv() *processScopeTestEnv { } cfg := runtimeconfig.FromProcess(handler) - env := &processScopeTestEnv{ + f := &processTestFixture{ handler: handler, cfg: cfg, event: event, } - env.ctrl = &Controller{ + f.ctrl = &Controller{ Config: cfg, - MessageIDs: &env.messageIDs, + MessageIDs: &f.messageIDs, } - env.messageIDs.Reset() + f.messageIDs.Reset() - return env + return f } diff --git a/fact/aggregate.go b/fact/aggregate.go index 8c9b33a..cc87c81 100644 --- a/fact/aggregate.go +++ b/fact/aggregate.go @@ -9,10 +9,11 @@ import ( // AggregateInstanceLoaded indicates that an aggregate message handler has // loaded an existing instance in order to handle a command. type AggregateInstanceLoaded struct { - Handler *config.Aggregate - InstanceID string - Root dogma.AggregateRoot - Envelope *envelope.Envelope + Handler *config.Aggregate + InstanceID string + Root dogma.AggregateRoot + Envelope *envelope.Envelope + SnapshotOffset int } // AggregateInstanceNotFound indicates that an aggregate message handler was diff --git a/internal/compare/compare.go b/internal/compare/compare.go new file mode 100644 index 0000000..3cb38db --- /dev/null +++ b/internal/compare/compare.go @@ -0,0 +1,136 @@ +package compare + +import ( + "reflect" + + "github.com/dogmatiq/testkit/internal/compare/internal/unsafereflect" + "github.com/dogmatiq/testkit/location" + "google.golang.org/protobuf/proto" +) + +// Equal returns true if a and b are considered equal. +// +// If both a and b implement [proto.Message], they are compared using +// [proto.Equal]. +// +// Otherwise, they are compared using semantics equivalent to +// [reflect.DeepEqual], except that function values are compared by their +// definition site rather than by pointer identity. +// +// This non-standard behavior for functions is necessary to support comparison +// of handler state that contains function values, which are commonly used for +// stubbing in tests. [Equal] is used throughout testkit for comparison of +// [dogma.Message], [dogma.AggregateRoot] and [dogma.ProcessRoot] state; none of +// which are expected to contain function value in production implementations. +func Equal(a, b any) bool { + if pa, ok := a.(proto.Message); ok { + if pb, ok := b.(proto.Message); ok { + return proto.Equal(pa, pb) + } + } + + return deepEqual( + reflect.ValueOf(a), + reflect.ValueOf(b), + ) +} + +func deepEqual(a, b reflect.Value) bool { + if !a.IsValid() || !b.IsValid() { + return a.IsValid() == b.IsValid() + } + + if a.Type() != b.Type() { + return false + } + + switch a.Kind() { + case reflect.Func, reflect.Pointer, reflect.Interface, reflect.Slice, reflect.Map: + if a.IsNil() != b.IsNil() { + return false + } + + if a.IsNil() { + return true + } + } + + switch a.Kind() { + case reflect.Func: + return funcEqual(a, b) + case reflect.Pointer, reflect.Interface: + return deepEqual(a.Elem(), b.Elem()) + case reflect.Array, reflect.Slice: + return sliceEqual(a, b) + case reflect.Map: + return mapEqual(a, b) + case reflect.Struct: + return structEqual(a, b) + default: + return reflect.DeepEqual(a.Interface(), b.Interface()) + } +} + +func funcEqual(a, b reflect.Value) bool { + if a.Pointer() == b.Pointer() { + return true + } + + la := location.OfFunc(a.Interface()) + lb := location.OfFunc(b.Interface()) + + return la.File == lb.File && la.Line == lb.Line +} + +func sliceEqual(a, b reflect.Value) bool { + if a.Len() != b.Len() { + return false + } + + for i := range a.Len() { + if !deepEqual(a.Index(i), b.Index(i)) { + return false + } + } + + return true +} + +func mapEqual(a, b reflect.Value) bool { + if a.Len() != b.Len() { + return false + } + + for _, k := range a.MapKeys() { + va := a.MapIndex(k) + vb := b.MapIndex(k) + + if !vb.IsValid() { + return false + } + + if !deepEqual(va, vb) { + return false + } + } + + return true +} + +func structEqual(a, b reflect.Value) bool { + for i := range a.NumField() { + fa := a.Field(i) + fb := b.Field(i) + + if !fa.CanInterface() { + fa = unsafereflect.MakeMutable(fa) + fb = unsafereflect.MakeMutable(fb) + } + + if !deepEqual(fa, fb) { + return false + } + } + + return true +} diff --git a/internal/compare/compare_test.go b/internal/compare/compare_test.go new file mode 100644 index 0000000..35fa580 --- /dev/null +++ b/internal/compare/compare_test.go @@ -0,0 +1,129 @@ +package compare_test + +import ( + "testing" + + . "github.com/dogmatiq/testkit/internal/compare" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +type ExampleType struct { + Value string + CallFunc func() +} + +type nestedFuncType struct { + Value string + Inner ExampleType +} + +type unexportedFieldType struct { + value string +} + +type unexportedWithFuncType struct { + value string + CallFunc func() +} + +func TestEqual(t *testing.T) { + factory := func() func() { return func() {} } + + // fn1 and fn2 are separate instantiations from the same factory, + // so they share the same closure definition site. + fn1 := factory() + fn2 := factory() + + // differentFn is defined at a different source location. + differentFn := func() {} + + cases := []struct { + Name string + A, B any + Equal bool + }{ + // Scalar values. + {"equal strings", "foo", "foo", true}, + {"non-equal strings", "foo", "bar", false}, + {"equal ints", 42, 42, true}, + {"non-equal ints", 42, 43, false}, + + // Proto messages. + {"equal proto messages", wrapperspb.String("foo"), wrapperspb.String("foo"), true}, + {"non-equal proto messages", wrapperspb.String("foo"), wrapperspb.String("bar"), false}, + + // Functions compared by definition site. + {"same definition funcs", fn1, fn2, true}, + {"different definition funcs", fn1, differentFn, false}, + {"nil vs non-nil func", (func())(nil), fn1, false}, + {"both nil funcs", (func())(nil), (func())(nil), true}, + + // Pointers. + {"equal pointers", &ExampleType{Value: "x", CallFunc: fn1}, &ExampleType{Value: "x", CallFunc: fn2}, true}, + {"non-equal pointers", &ExampleType{Value: "x"}, &ExampleType{Value: "y"}, false}, + {"nil and non-nil pointer", (*ExampleType)(nil), &ExampleType{}, false}, + {"both nil pointers", (*ExampleType)(nil), (*ExampleType)(nil), true}, + + // Structs with func fields compared by location. + {"same definition func fields", ExampleType{Value: "x", CallFunc: fn1}, ExampleType{Value: "x", CallFunc: fn2}, true}, + {"different definition func fields", ExampleType{Value: "x", CallFunc: fn1}, ExampleType{Value: "x", CallFunc: differentFn}, false}, + {"non-equal data fields", ExampleType{Value: "x"}, ExampleType{Value: "y"}, false}, + + // Structs without func fields (includes unexported fields). + {"unexported fields equal", unexportedFieldType{value: "x"}, unexportedFieldType{value: "x"}, true}, + {"unexported fields non-equal", unexportedFieldType{value: "x"}, unexportedFieldType{value: "y"}, false}, + + // Structs with both unexported fields and func fields. + {"unexported with func equal", unexportedWithFuncType{value: "x", CallFunc: fn1}, unexportedWithFuncType{value: "x", CallFunc: fn2}, true}, + {"unexported with func non-equal value", unexportedWithFuncType{value: "x", CallFunc: fn1}, unexportedWithFuncType{value: "y", CallFunc: fn2}, false}, + {"unexported with func non-equal func", unexportedWithFuncType{value: "x", CallFunc: fn1}, unexportedWithFuncType{value: "x", CallFunc: differentFn}, false}, + + // Structs with nested func fields. + {"nested same definition func fields", nestedFuncType{Value: "x", Inner: ExampleType{CallFunc: fn1}}, nestedFuncType{Value: "x", Inner: ExampleType{CallFunc: fn2}}, true}, + {"nested different definition func fields", nestedFuncType{Value: "x", Inner: ExampleType{CallFunc: fn1}}, nestedFuncType{Value: "x", Inner: ExampleType{CallFunc: differentFn}}, false}, + {"nested non-equal data fields", nestedFuncType{Value: "x"}, nestedFuncType{Value: "y"}, false}, + + // Slices. + {"equal slices", []ExampleType{{Value: "a", CallFunc: fn1}}, []ExampleType{{Value: "a", CallFunc: fn2}}, true}, + {"non-equal slices", []ExampleType{{Value: "a"}}, []ExampleType{{Value: "b"}}, false}, + {"different length slices", []ExampleType{{Value: "a"}}, []ExampleType{}, false}, + {"nil vs non-nil slice", []ExampleType(nil), []ExampleType{}, false}, + {"both nil slices", []ExampleType(nil), []ExampleType(nil), true}, + + // Slices of pointers. + {"equal slices of pointers", []*ExampleType{{Value: "a", CallFunc: fn1}}, []*ExampleType{{Value: "a", CallFunc: fn2}}, true}, + {"non-equal slices of pointers", []*ExampleType{{Value: "a"}}, []*ExampleType{{Value: "b"}}, false}, + + // Maps. + {"equal maps", map[string]ExampleType{"k": {Value: "v", CallFunc: fn1}}, map[string]ExampleType{"k": {Value: "v", CallFunc: fn2}}, true}, + {"non-equal map values", map[string]ExampleType{"k": {Value: "a"}}, map[string]ExampleType{"k": {Value: "b"}}, false}, + {"different map keys", map[string]ExampleType{"a": {}}, map[string]ExampleType{"b": {}}, false}, + {"different map lengths", map[string]ExampleType{"a": {}}, map[string]ExampleType{}, false}, + {"nil vs non-nil map", map[string]ExampleType(nil), map[string]ExampleType{}, false}, + {"both nil maps", map[string]ExampleType(nil), map[string]ExampleType(nil), true}, + + // Arrays. + {"equal arrays", [2]ExampleType{{Value: "a", CallFunc: fn1}, {Value: "b"}}, [2]ExampleType{{Value: "a", CallFunc: fn2}, {Value: "b"}}, true}, + {"non-equal arrays", [1]ExampleType{{Value: "a"}}, [1]ExampleType{{Value: "b"}}, false}, + + // Interfaces (via []any). + {"equal interface elements", []any{&ExampleType{Value: "x", CallFunc: fn1}}, []any{&ExampleType{Value: "x", CallFunc: fn2}}, true}, + {"non-equal interface elements", []any{&ExampleType{Value: "x"}}, []any{&ExampleType{Value: "y"}}, false}, + {"nil interface element", []any{nil}, []any{nil}, true}, + {"nil vs non-nil interface element", []any{nil}, []any{&ExampleType{}}, false}, + + // Nil (untyped). + {"both nil", nil, nil, true}, + {"nil vs non-nil", nil, "x", false}, + {"non-nil vs nil", "x", nil, false}, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + got := Equal(c.A, c.B) + if got != c.Equal { + t.Fatalf("Equal() = %v, want %v", got, c.Equal) + } + }) + } +} diff --git a/internal/compare/doc.go b/internal/compare/doc.go new file mode 100644 index 0000000..4d967e0 --- /dev/null +++ b/internal/compare/doc.go @@ -0,0 +1,3 @@ +// Package compare provides a shared equality comparison function with support +// for protocol buffers messages. +package compare diff --git a/internal/compare/internal/unsafereflect/LICENSE.credits b/internal/compare/internal/unsafereflect/LICENSE.credits new file mode 100644 index 0000000..025b8a7 --- /dev/null +++ b/internal/compare/internal/unsafereflect/LICENSE.credits @@ -0,0 +1,18 @@ +Portions of the "unsafereflect" package were heavily influenced by Dave Collin's +Spew package. + +https://github.com/davecgh/go-spew + +Copyright (c) 2012-2016 Dave Collins + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, +OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, +DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS +SOFTWARE. diff --git a/internal/compare/internal/unsafereflect/value.go b/internal/compare/internal/unsafereflect/value.go new file mode 100644 index 0000000..0a288e1 --- /dev/null +++ b/internal/compare/internal/unsafereflect/value.go @@ -0,0 +1,138 @@ +package unsafereflect + +import ( + "errors" + "fmt" + "reflect" + "unsafe" +) + +// MakeMutable returns a copy of v with read-only restrictions removed. +// +// This allows invocation of methods on the value. Care must be taken not to +// call methods that modify the returned value. +// +// It panics if the value can not be made mutable. +func MakeMutable(v reflect.Value) reflect.Value { + if v.CanInterface() { + return v + } + + if flagsErr != nil { + // CODE COVERAGE: This branch is never executed unless the internals of + // the reflect package have changed in some incompatible way. + panic(fmt.Errorf("cannot make value %v mutable: %w", v, flagsErr)) + } + + f := flags(&v) + *f &^= flagRO // clear the read-only flag + + return v +} + +// flag is defined equivalently to the unexported reflect.flag type. +type flag uintptr + +// The following constants are defined equivalently to their respective +// counterparts in the reflect package. +const ( + flagStickyRO flag = 1 << 5 + flagEmbedRO flag = 1 << 6 + flagRO flag = flagStickyRO | flagEmbedRO +) + +var ( + // flagOffset is the offset of the "flag" field within the reflect.Value type. + flagOffset uintptr + + // flagsErr is non-nil if there is a problem verifying the values of + // internal reflection flags. + flagsErr error +) + +// flags returns a pointer to the "flag" field of *v. +func flags(v *reflect.Value) *flag { + return (*flag)( + unsafe.Pointer( + uintptr(unsafe.Pointer(v)) + flagOffset, + ), + ) +} + +// computeFlagOffset checks for the presence of the "flag" field within the +// reflect.Value type and returns its offset. +func computeFlagOffset() (uintptr, error) { + rt := reflect.TypeOf(reflect.Value{}) + + // Ensure that reflect.Value even has a "flag" field. + f, ok := rt.FieldByName("flag") + if !ok { + // CODE COVERAGE: This branch is never executed unless the internals of + // the reflect package have changed in some incompatible way. + return 0, errors.New("reflect.Value has no flag field") + } + + // Ensure that the type of "reflect.flag" is compatible with our local + // definition. + k := reflect.TypeOf(flagRO).Kind() + if f.Type.Kind() != k { + // CODE COVERAGE: This branch is never executed unless the internals of + // the reflect package have changed in some incompatible way. + return 0, fmt.Errorf("reflect.Value flag is not a %s", k) + } + + return f.Offset, nil +} + +// checkFlagValues verifies that the locally defined flag values match those +// produced by the reflect package. +func checkFlagValues() error { + // Create a test type containing a combination of exported, unexported and + // embedded fields. These are used to guess the flag values to ensure or + // local definitions are correct. + type t struct{} + var v struct { + Exported t + unexported t // unexported, flagStickyRO will be set + t // embedded, flagEmbedRO will be set + } + + rv := reflect.ValueOf(v) + + var ( + exported = rv.FieldByName("Exported") + exportedFlags = *flags(&exported) + unexported = rv.FieldByName("unexported") + unexportedFlags = *flags(&unexported) + embedded = rv.FieldByName("t") + embeddedFlags = *flags(&embedded) + ) + + // Take the difference between the flags of the exported field, and the + // flags of the fields that are known to be "read-only" to deduce the value + // of the "flagRO" constant. + deducedFlagRO := exportedFlags ^ (unexportedFlags | embeddedFlags) + + if flagRO != deducedFlagRO { + // CODE COVERAGE: This branch is never executed unless the internals of + // the reflect package have changed in some incompatible way. + return fmt.Errorf( + "flagRO is defined as %v, but the actual value is likely %v", + flagRO, + deducedFlagRO, + ) + } + + return nil +} + +func init() { + flagOffset, flagsErr = computeFlagOffset() + if flagsErr != nil { + // CODE COVERAGE: This branch is never executed unless the internals of + // the reflect package have changed in some incompatible way. + return + } + + flagsErr = checkFlagValues() +} diff --git a/internal/compare/internal/unsafereflect/value_test.go b/internal/compare/internal/unsafereflect/value_test.go new file mode 100644 index 0000000..4b7666c --- /dev/null +++ b/internal/compare/internal/unsafereflect/value_test.go @@ -0,0 +1,38 @@ +package unsafereflect + +import ( + "reflect" + "testing" +) + +func TestMakeMutable_exported_field(t *testing.T) { + var v struct { + F int + } + + rv := reflect.ValueOf(v) + rf := rv.FieldByName("F") + + mv := MakeMutable(rf) + mv.Interface() // will panic if restrictions are not removed +} + +func TestMakeMutable_unexported_field(t *testing.T) { + var v struct { + f int + } + + rv := reflect.ValueOf(v) + rf := rv.FieldByName("f") + + mv := MakeMutable(rf) + mv.Interface() // will panic if restrictions are not removed +} + +// This test will fail if the internals of the reflect package have changed such +// that the "read-only" flag value is no longer known. +func TestFlags(t *testing.T) { + if flagsErr != nil { + t.Fatal(flagsErr) + } +} diff --git a/internal/x/xtesting/expect.go b/internal/x/xtesting/expect.go index 5d51b7b..d4652da 100644 --- a/internal/x/xtesting/expect.go +++ b/internal/x/xtesting/expect.go @@ -8,6 +8,7 @@ import ( "github.com/dogmatiq/enginekit/config" "github.com/dogmatiq/enginekit/enginetest/stubs" "github.com/dogmatiq/enginekit/message" + "github.com/dogmatiq/testkit/location" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/protobuf/testing/protocmp" @@ -138,3 +139,61 @@ func ExpectPanic( fn() } + +// ExpectPanicMatching asserts that fn panics with a value of type T, then +// calls match to make further assertions about the panic value. +func ExpectPanicMatching[T any]( + t TestingT, + fn func(), + match func(T), +) { + t.Helper() + + defer func() { + t.Helper() + + r := recover() + if r == nil { + t.Fatal("expected a panic") + return + } + + v, ok := r.(T) + if !ok { + t.Fatal( + fmt.Sprintf( + "expected a panic of type %s, got %T", + reflect.TypeFor[T](), + r, + ), + ) + return + } + + match(v) + }() + + fn() +} + +// ExpectLocation asserts that loc has a non-empty Func, a non-zero Line, and +// that its File ends with the given suffix. +func ExpectLocation( + t TestingT, + loc location.Location, + fileSuffix string, +) { + t.Helper() + + if loc.Func == "" { + t.Fatal("expected func to be set in location") + } + + if !strings.HasSuffix(loc.File, fileSuffix) { + t.Fatal(fmt.Sprintf("unexpected file in location: got %s, want suffix %s", loc.File, fileSuffix)) + } + + if loc.Line == 0 { + t.Fatal("expected line to be set in location") + } +} diff --git a/test.go b/test.go index 592db8e..74b7b83 100644 --- a/test.go +++ b/test.go @@ -15,6 +15,7 @@ import ( "github.com/dogmatiq/iago/must" "github.com/dogmatiq/testkit/engine" "github.com/dogmatiq/testkit/fact" + "github.com/dogmatiq/testkit/internal/compare" "golang.org/x/text/cases" "golang.org/x/text/language" ) @@ -145,7 +146,7 @@ func (t *Test) Expect(act Action, e Expectation) *Test { return "" } - if !equal(a.Value, v.Value.Interface()) { + if !compare.Equal(a.Value, v.Value.Interface()) { return "" }