Skip to content

Commit

Permalink
Add InterceptEventRecorder() option.
Browse files Browse the repository at this point in the history
  • Loading branch information
jmalloc committed Apr 26, 2021
1 parent 07b0e55 commit 9869150
Show file tree
Hide file tree
Showing 6 changed files with 328 additions and 11 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The format is based on [Keep a Changelog], and this project adheres to

### Added

- Add `InterceptCommandExecutor()` option
- Add `InterceptCommandExecutor()` and `InterceptEventRecorder()` options
- Add functional options to `Call()`, see `CallOption`

## [0.13.4] - 2021-04-22
Expand Down
16 changes: 8 additions & 8 deletions action.call.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type callAction struct {
fn func()
loc location.Location
onExecute CommandExecutorInterceptor
onRecord EventRecorderInterceptor
}

func (a callAction) Caption() string {
Expand All @@ -71,15 +72,14 @@ func (a callAction) Do(ctx context.Context, s ActionScope) error {
defer s.Executor.Intercept(prev)
}

s.Recorder.Engine = s.Engine
s.Recorder.Options = s.OperationOptions
// Setup the event recorder for use during this action.
s.Recorder.Bind(s.Engine, s.OperationOptions)
defer s.Recorder.Unbind()

defer func() {
// Reset the engine and options to nil so that the executor and recorder
// can not be used after this Call() action ends.
s.Recorder.Engine = nil
s.Recorder.Options = nil
}()
if a.onRecord != nil {
prev := s.Recorder.Intercept(a.onRecord)
defer s.Recorder.Intercept(prev)
}

// Execute the user-supplied function.
a.fn()
Expand Down
2 changes: 1 addition & 1 deletion action.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ type ActionScope struct {

// Recorder is the event recorder returned by the Test's EventRecorder()
// method.
Recorder *engine.EventRecorder
Recorder *EventRecorder

// OperationOptions is the set of options that should be used with calling
// Engine.Dispatch() or Engine.Tick().
Expand Down
128 changes: 128 additions & 0 deletions recorder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package testkit

import (
"context"
"sync"

"github.com/dogmatiq/dogma"
"github.com/dogmatiq/testkit/engine"
)

// EventRecorder is an implementation of dogma.EventRecorder that records events
// within the context of a Test.
//
// Each instance is bound to a particular Test. Use Test.EventRecorder() to
// obtain an instance.
type EventRecorder struct {
m sync.RWMutex
next engine.EventRecorder
interceptor EventRecorderInterceptor
}

// RecordEvent records the event message m.
//
// It panics unless it is called during an Action, such as when calling
// Test.Prepare() or Test.Expect().
func (r *EventRecorder) RecordEvent(ctx context.Context, m dogma.Message) error {
r.m.RLock()
defer r.m.RUnlock()

if r.next.Engine == nil {
panic("RecordEvent(): can not be called outside of a test")
}

if r.interceptor != nil {
return r.interceptor(ctx, m, r.next)
}

return r.next.RecordEvent(ctx, m)
}

// Bind sets the engine and options used to record events.
//
// It is intended for use within Action implementations that support recording
// events outside of a Dogma handler, such as Call().
//
// It must be called before RecordEvent(), otherwise RecordEvent() panics.
//
// It must be accompanied by a call to Unbind() upon completion of the Action.
func (r *EventRecorder) Bind(eng *engine.Engine, options []engine.OperationOption) {
r.m.Lock()
defer r.m.Unlock()

r.next.Engine = eng
r.next.Options = options
}

// Unbind removes the engine and options configured by a prior call to Bind().
//
// Calls to RecordEvent() on an unbound recorder will cause a panic.
func (r *EventRecorder) Unbind() {
r.m.Lock()
defer r.m.Unlock()

r.next.Engine = nil
r.next.Options = nil
}

// Intercept installs an interceptor function that is invoked whenever
// RecordEvent() is called.
//
// If fn is nil the interceptor is removed.
//
// It returns the previous interceptor, if any.
func (r *EventRecorder) Intercept(fn EventRecorderInterceptor) EventRecorderInterceptor {
r.m.Lock()
defer r.m.Unlock()

prev := r.interceptor
r.interceptor = fn

return prev
}

// EventRecorderInterceptor is used by the InterceptEventRecorder() option to
// specify custom behavior for the dogma.EventRecorder returned by
// Test.EventRecorder().
//
// m is the event being recorded.
//
// e can be used to record the event as it would be recorded without this
// interceptor installed.
type EventRecorderInterceptor func(
ctx context.Context,
m dogma.Message,
r dogma.EventRecorder,
) error

// InterceptEventRecorder returns an option that causes fn to be called
// whenever an event is recorded via the dogma.EventRecorder returned by
// Test.EventRecorder().
//
// Intercepting calls to the event recorder allows the user to simulate
// failures (or any other behavior) in the event recorder.
func InterceptEventRecorder(fn EventRecorderInterceptor) interface {
TestOption
CallOption
} {
if fn == nil {
panic("InterceptEventRecorder(<nil>): function must not be nil")
}

return interceptEventRecorderOption{fn}
}

// interceptEventRecorderOption is an implementation of both TestOption and
// CallOption that allows the InterceptEventRecorder() option to be used with
// both Test.Begin() and Call().
type interceptEventRecorderOption struct {
fn EventRecorderInterceptor
}

func (o interceptEventRecorderOption) applyTestOption(t *Test) {
t.recorder.Intercept(o.fn)
}

func (o interceptEventRecorderOption) applyCallOption(a *callAction) {
a.onRecord = o.fn
}
189 changes: 189 additions & 0 deletions recorder_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package testkit_test

import (
"context"
"errors"

"github.com/dogmatiq/dogma"
. "github.com/dogmatiq/dogma/fixtures"
. "github.com/dogmatiq/testkit"
"github.com/dogmatiq/testkit/internal/testingmock"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)

var _ = Describe("func InterceptEventRecorder()", func() {
var (
testingT *testingmock.T
app dogma.Application
doNothing EventRecorderInterceptor
recordEventAndReturnError EventRecorderInterceptor
)

BeforeEach(func() {
testingT = &testingmock.T{}

app = &Application{
ConfigureFunc: func(c dogma.ApplicationConfigurer) {
c.Identity("<app>", "<app-key>")

c.RegisterProcess(&ProcessMessageHandler{
ConfigureFunc: func(c dogma.ProcessConfigurer) {
c.Identity("<handler-name>", "<handler-key>")
c.ConsumesEventType(MessageE{})
c.ProducesCommandType(MessageC{})
},
RouteEventToInstanceFunc: func(
context.Context,
dogma.Message,
) (string, bool, error) {
return "<instance>", true, nil
},
HandleEventFunc: func(
_ context.Context,
_ dogma.ProcessRoot,
s dogma.ProcessEventScope,
_ dogma.Message,
) error {
s.ExecuteCommand(MessageC1)
return nil
},
})
},
}

doNothing = func(
context.Context,
dogma.Message,
dogma.EventRecorder,
) error {
return nil
}

recordEventAndReturnError = func(
ctx context.Context,
m dogma.Message,
e dogma.EventRecorder,
) error {
Expect(m).To(Equal(MessageE1))

err := e.RecordEvent(ctx, m)
Expect(err).ShouldNot(HaveOccurred())

return errors.New("<error>")
}
})

It("panics if the interceptor function is nil", func() {
Expect(func() {
InterceptEventRecorder(nil)
}).To(PanicWith("InterceptEventRecorder(<nil>): function must not be nil"))
})

When("used as a TestOption", func() {
It("intercepts calls to RecordEvent()", func() {
test := Begin(
testingT,
app,
InterceptEventRecorder(recordEventAndReturnError),
)

test.EnableHandlers("<handler-name>")

test.Expect(
Call(func() {
err := test.EventRecorder().RecordEvent(
context.Background(),
MessageE1,
)
Expect(err).To(MatchError("<error>"))
}),
ToExecuteCommand(MessageC1),
)
})
})

When("used as a CallOption", func() {
It("intercepts calls to RecordEvent()", func() {
test := Begin(
&testingmock.T{},
app,
)

test.EnableHandlers("<handler-name>")

test.Expect(
Call(
func() {
err := test.EventRecorder().RecordEvent(
context.Background(),
MessageE1,
)
Expect(err).To(MatchError("<error>"))
},
InterceptEventRecorder(recordEventAndReturnError),
),
ToExecuteCommand(MessageC1),
)
})

It("uninstalls the interceptor upon completion of the Call() action", func() {
test := Begin(
&testingmock.T{},
app,
)

test.Prepare(
Call(
func() {
err := test.EventRecorder().RecordEvent(
context.Background(),
MessageE1,
)
Expect(err).To(MatchError("<error>"))
},
InterceptEventRecorder(recordEventAndReturnError),
),
Call(
func() {
err := test.EventRecorder().RecordEvent(
context.Background(),
MessageE1,
)
Expect(err).ShouldNot(HaveOccurred())
},
),
)
})

It("re-installs the test-level interceptor upon completion of the Call() action", func() {
test := Begin(
&testingmock.T{},
app,
InterceptEventRecorder(recordEventAndReturnError),
)

test.Prepare(
Call(
func() {
err := test.EventRecorder().RecordEvent(
context.Background(),
MessageE1,
)
Expect(err).ShouldNot(HaveOccurred())
},
InterceptEventRecorder(doNothing),
),
Call(
func() {
err := test.EventRecorder().RecordEvent(
context.Background(),
MessageE1,
)
Expect(err).To(MatchError("<error>"))
},
),
)
})
})
})
2 changes: 1 addition & 1 deletion test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type Test struct {
virtualClock time.Time
engine *engine.Engine
executor CommandExecutor
recorder engine.EventRecorder
recorder EventRecorder
predicateOptions PredicateOptions
operationOptions []engine.OperationOption
}
Expand Down

0 comments on commit 9869150

Please sign in to comment.