Skip to content

Commit

Permalink
feat: update ftltest for injected verb clients
Browse files Browse the repository at this point in the history
  • Loading branch information
worstell committed Sep 27, 2024
1 parent 68aa44b commit b3fc9a1
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 37 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ go-runtime/schema/testdata/test/test.go
**/testdata/**/_ftl
**/examples/**/_ftl
**/types.ftl.go
!go-runtime/ftl/ftltest/testdata/go/verbtypes/types.ftl.go
**/testdata/**/.ftl
**/examples/**/.ftl
/.ftl
Expand Down
8 changes: 4 additions & 4 deletions go-runtime/compile/build-template/types.ftl.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ package {{$name}}
{{ if .Imports -}}
import (
{{- range .Imports }}
{{.}}
{{.}}
{{- end }}
)
{{- end }}
{{ end }}

{{ range $verbs -}}
{{- range $verbs -}}
{{ $req := .Request.LocalTypeName -}}
{{ $resp := .Response.LocalTypeName -}}

Expand Down Expand Up @@ -47,7 +47,7 @@ func init() {
{{ trimModuleQualifier $moduleName .TypeName }},
{{- range .Resources}}
{{- with getVerbClient . }}
{{ $verb := trimModuleQualifier $moduleName .TypeName -}}
{{- $verb := trimModuleQualifier $moduleName .TypeName -}}

{{ if and (eq .Request.TypeName "ftl.Unit") (eq .Response.TypeName "ftl.Unit")}}
server.EmptyClient[{{$verb}}](),
Expand Down
78 changes: 66 additions & 12 deletions go-runtime/ftl/ftltest/ftltest.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"sort"
"strings"

"github.com/TBD54566975/ftl/go-runtime/server"
_ "github.com/jackc/pgx/v5/stdlib" // SQL driver

"github.com/TBD54566975/ftl/backend/schema"
Expand Down Expand Up @@ -277,16 +278,16 @@ func WithDatabase(dbHandle ftl.Database) Option {
// To be used when setting up a context for a test:
//
// ctx := ftltest.Context(
// ftltest.WhenVerb(Example.Verb, func(ctx context.Context, req Example.Req) (Example.Resp, error) {
// ftltest.WhenVerb[example.VerbClient](func(ctx context.Context, req example.Req) (example.Resp, error) {
// // ...
// }),
// // ... other options
// )
func WhenVerb[Req any, Resp any](verb ftl.Verb[Req, Resp], fake ftl.Verb[Req, Resp]) Option {
func WhenVerb[VerbClient, Req, Resp any](fake ftl.Verb[Req, Resp]) Option {
return Option{
rank: other,
apply: func(ctx context.Context, state *OptionsState) error {
ref := reflection.FuncRef(verb)
ref := reflection.ClientRef[VerbClient]()
state.mockVerbs[schema.RefKey(ref)] = func(ctx context.Context, req any) (resp any, err error) {
request, ok := req.(Req)
if !ok {
Expand All @@ -304,16 +305,16 @@ func WhenVerb[Req any, Resp any](verb ftl.Verb[Req, Resp], fake ftl.Verb[Req, Re
// To be used when setting up a context for a test:
//
// ctx := ftltest.Context(
// ftltest.WhenSource(example.Source, func(ctx context.Context) (example.Resp, error) {
// ftltest.WhenSource[example.SourceClient](func(ctx context.Context) (example.Resp, error) {
// // ...
// }),
// // ... other options
// )
func WhenSource[Resp any](source ftl.Source[Resp], fake func(ctx context.Context) (resp Resp, err error)) Option {
func WhenSource[SourceClient, Resp any](fake ftl.Source[Resp]) Option {
return Option{
rank: other,
apply: func(ctx context.Context, state *OptionsState) error {
ref := reflection.FuncRef(source)
ref := reflection.ClientRef[SourceClient]()
state.mockVerbs[schema.RefKey(ref)] = func(ctx context.Context, req any) (resp any, err error) {
return fake(ctx)
}
Expand All @@ -327,16 +328,16 @@ func WhenSource[Resp any](source ftl.Source[Resp], fake func(ctx context.Context
// To be used when setting up a context for a test:
//
// ctx := ftltest.Context(
// ftltest.WhenSink(example.Sink, func(ctx context.Context, req example.Req) error {
// ftltest.WhenSink[example.SinkClient](func(ctx context.Context, req example.Req) error {
// ...
// }),
// // ... other options
// )
func WhenSink[Req any](sink ftl.Sink[Req], fake func(ctx context.Context, req Req) error) Option {
func WhenSink[SinkClient, Req any](fake ftl.Sink[Req]) Option {
return Option{
rank: other,
apply: func(ctx context.Context, state *OptionsState) error {
ref := reflection.FuncRef(sink)
ref := reflection.ClientRef[SinkClient]()
state.mockVerbs[schema.RefKey(ref)] = func(ctx context.Context, req any) (resp any, err error) {
request, ok := req.(Req)
if !ok {
Expand All @@ -354,15 +355,15 @@ func WhenSink[Req any](sink ftl.Sink[Req], fake func(ctx context.Context, req Re
// To be used when setting up a context for a test:
//
// ctx := ftltest.Context(
// ftltest.WhenEmpty(Example.Empty, func(ctx context.Context) error {
// ftltest.WhenEmpty[example.EmptyClient](func(ctx context.Context) error {
// ...
// }),
// )
func WhenEmpty(empty ftl.Empty, fake func(ctx context.Context) (err error)) Option {
func WhenEmpty[EmptyClient any](fake ftl.Empty) Option {
return Option{
rank: other,
apply: func(ctx context.Context, state *OptionsState) error {
ref := reflection.FuncRef(empty)
ref := reflection.ClientRef[EmptyClient]()
state.mockVerbs[schema.RefKey(ref)] = func(ctx context.Context, req any) (resp any, err error) {
return ftl.Unit{}, fake(ctx)
}
Expand Down Expand Up @@ -497,3 +498,56 @@ func WaitForSubscriptionsToComplete(ctx context.Context) {
fftl := internal.FromContext(ctx).(*fakeFTL) //nolint:forcetypeassert
fftl.pubSub.waitForSubscriptionsToComplete(ctx)
}

// Call a Verb inline, applying resources and test behavior.
func Call[VerbClient, Req, Resp any](ctx context.Context, req Req) (Resp, error) {
return call[VerbClient, Req, Resp](ctx, req)
}

// CallSource calls a Source inline, applying resources and test behavior.
func CallSource[VerbClient, Resp any](ctx context.Context) (Resp, error) {
return call[VerbClient, ftl.Unit, Resp](ctx, ftl.Unit{})
}

// CallSink calls a Sink inline, applying resources and test behavior.
func CallSink[VerbClient, Req any](ctx context.Context, req Req) error {
_, err := call[VerbClient, Req, ftl.Unit](ctx, req)
return err
}

// CallEmpty calls an Empty inline, applying resources and test behavior.
func CallEmpty[VerbClient any](ctx context.Context) error {
_, err := call[VerbClient, ftl.Unit, ftl.Unit](ctx, ftl.Unit{})
return err
}

func call[VerbClient, Req, Resp any](ctx context.Context, req Req) (resp Resp, err error) {
ref := reflection.ClientRef[VerbClient]()
inline := server.Call[Req, Resp](ref)
moduleCtx := modulecontext.FromContext(ctx).CurrentContext()
override, err := moduleCtx.BehaviorForVerb(schema.Ref{Module: ref.Module, Name: ref.Name})
if err != nil {
return resp, fmt.Errorf("test harness failed to retrieve behavior for verb %s: %w", ref, err)
}
if behavior, ok := override.Get(); ok {
uncheckedResp, err := behavior.Call(ctx, modulecontext.Verb(widenVerb(inline)), req)
if err != nil {
return resp, fmt.Errorf("test harness failed to call verb %s: %w", ref, err)
}
if r, ok := uncheckedResp.(Resp); ok {
return r, nil
}
return resp, fmt.Errorf("%s: overridden verb had invalid response type %T, expected %v", ref, uncheckedResp, reflect.TypeFor[Resp]())
}
return inline(ctx, req)
}

func widenVerb[Req, Resp any](verb ftl.Verb[Req, Resp]) ftl.Verb[any, any] {
return func(ctx context.Context, uncheckedReq any) (any, error) {
req, ok := uncheckedReq.(Req)
if !ok {
return nil, fmt.Errorf("invalid request type %T for %v, expected %v", uncheckedReq, reflection.FuncRef(verb), reflect.TypeFor[Req]())
}
return verb(ctx, req)
}
}
2 changes: 0 additions & 2 deletions go-runtime/ftl/ftltest/testdata/go/verbtypes/types.ftl.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions go-runtime/ftl/ftltest/testdata/go/verbtypes/verbtypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,13 @@ func Sink(ctx context.Context, req Request) error {
func Empty(ctx context.Context) error {
return nil
}

//ftl:verb
func CallerVerb(ctx context.Context, req Request, c CalleeVerbClient) (Response, error) {
return c(ctx, req)
}

//ftl:verb
func CalleeVerb(ctx context.Context, req Request) (Response, error) {
return Response{Output: "from callee: " + req.Input}, nil
}
73 changes: 58 additions & 15 deletions go-runtime/ftl/ftltest/testdata/go/verbtypes/verbtypes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,44 @@ func TestVerbs(t *testing.T) {
knockOnEffects := map[string]string{}

ctx := ftltest.Context(
ftltest.WhenVerb(Verb, func(ctx context.Context, req Request) (Response, error) {
ftltest.WhenVerb[VerbClient](func(ctx context.Context, req Request) (Response, error) {
return Response{Output: fmt.Sprintf("fake: %s", req.Input)}, nil
}),
ftltest.WhenSource(Source, func(ctx context.Context) (Response, error) {
ftltest.WhenSource[SourceClient](func(ctx context.Context) (Response, error) {
return Response{Output: "fake"}, nil
}),
ftltest.WhenSink(Sink, func(ctx context.Context, req Request) error {
ftltest.WhenSink[SinkClient](func(ctx context.Context, req Request) error {
knockOnEffects["sink"] = req.Input
return nil
}),
ftltest.WhenEmpty(Empty, func(ctx context.Context) error {
ftltest.WhenEmpty[EmptyClient](func(ctx context.Context) error {
knockOnEffects["empty"] = "test"
return nil
}),
)

verbResp, err := ftl.Call(ctx, Verb, Request{Input: "test"})
verbResp, err := ftltest.Call[VerbClient, Request, Response](ctx, Request{Input: "test"})
assert.NoError(t, err)
assert.Equal(t, Response{Output: "fake: test"}, verbResp)

sourceResp, err := ftl.CallSource(ctx, Source)
sourceResp, err := ftltest.CallSource[SourceClient, Response](ctx)
assert.NoError(t, err)
assert.Equal(t, Response{Output: "fake"}, sourceResp)

err = ftltest.CallSink[SinkClient](ctx, Request{Input: "testsink"})
assert.NoError(t, err)
assert.Equal(t, knockOnEffects["sink"], "testsink")

err = ftltest.CallEmpty[EmptyClient](ctx)
assert.NoError(t, err)
assert.Equal(t, knockOnEffects["empty"], "test")

// TODO: remove after refactor
verbResp, err = ftl.Call(ctx, Verb, Request{Input: "test"})
assert.NoError(t, err)
assert.Equal(t, Response{Output: "fake: test"}, verbResp)

sourceResp, err = ftl.CallSource(ctx, Source)
assert.NoError(t, err)
assert.Equal(t, Response{Output: "fake"}, sourceResp)

Expand All @@ -49,44 +66,57 @@ func TestVerbs(t *testing.T) {

func TestContextExtension(t *testing.T) {
ctx1 := ftltest.Context(
ftltest.WhenSource(Source, func(ctx context.Context) (Response, error) {
ftltest.WhenSource[SourceClient](func(ctx context.Context) (Response, error) {
return Response{Output: "fake"}, nil
}),
)

ctx2 := ftltest.SubContext(
ctx1,
ftltest.WhenSource(Source, func(ctx context.Context) (Response, error) {
ftltest.WhenSource[SourceClient](func(ctx context.Context) (Response, error) {
return Response{Output: "another fake"}, nil
}),
)

sourceResp, err := ftl.CallSource(ctx1, Source)
sourceResp, err := ftltest.CallSource[SourceClient, Response](ctx1)
assert.NoError(t, err)
assert.Equal(t, Response{Output: "fake"}, sourceResp)

sourceResp, err = ftl.CallSource(ctx2, Source)
sourceResp, err = ftltest.CallSource[SourceClient, Response](ctx2)
assert.NoError(t, err)
assert.Equal(t, Response{Output: "another fake"}, sourceResp)
}

func TestVerbErrors(t *testing.T) {
ctx := ftltest.Context(
ftltest.WhenVerb(Verb, func(ctx context.Context, req Request) (Response, error) {
ftltest.WhenVerb[VerbClient](func(ctx context.Context, req Request) (Response, error) {
return Response{}, fmt.Errorf("fake: %s", req.Input)
}),
ftltest.WhenSource(Source, func(ctx context.Context) (Response, error) {
ftltest.WhenSource[SourceClient](func(ctx context.Context) (Response, error) {
return Response{}, fmt.Errorf("fake-source")
}),
ftltest.WhenSink(Sink, func(ctx context.Context, req Request) error {
ftltest.WhenSink[SinkClient](func(ctx context.Context, req Request) error {
return fmt.Errorf("fake: %s", req.Input)
}),
ftltest.WhenEmpty(Empty, func(ctx context.Context) error {
ftltest.WhenEmpty[EmptyClient](func(ctx context.Context) error {
return fmt.Errorf("fake-empty")
}),
)

_, err := ftl.Call(ctx, Verb, Request{Input: "test"})
_, err := ftltest.Call[VerbClient, Request, Response](ctx, Request{Input: "test"})
assert.EqualError(t, err, "verbtypes.verb: fake: test")

_, err = ftltest.CallSource[SourceClient, Response](ctx)
assert.EqualError(t, err, "verbtypes.source: fake-source")

err = ftltest.CallSink[SinkClient](ctx, Request{Input: "test-sink"})
assert.EqualError(t, err, "verbtypes.sink: fake: test-sink")

err = ftltest.CallEmpty[EmptyClient](ctx)
assert.EqualError(t, err, "verbtypes.empty: fake-empty")

// TODO: remove after refactor
_, err = ftl.Call(ctx, Verb, Request{Input: "test"})
assert.EqualError(t, err, "verbtypes.verb: fake: test")

_, err = ftl.CallSource(ctx, Source)
Expand All @@ -98,3 +128,16 @@ func TestVerbErrors(t *testing.T) {
err = ftl.CallEmpty(ctx, Empty)
assert.EqualError(t, err, "verbtypes.empty: fake-empty")
}

func TestTransitiveVerbMock(t *testing.T) {
ctx := ftltest.Context(
ftltest.WithCallsAllowedWithinModule(),
ftltest.WhenVerb[CalleeVerbClient](func(ctx context.Context, req Request) (Response, error) {
return Response{Output: fmt.Sprintf("mocked: %s", req.Input)}, nil
}),
)

verbResp, err := ftltest.Call[CallerVerbClient, Request, Response](ctx, Request{Input: "test"})
assert.NoError(t, err)
assert.Equal(t, Response{Output: "mocked: test"}, verbResp)
}
8 changes: 4 additions & 4 deletions go-runtime/ftl/ftltest/testdata/go/wrapped/wrapped_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestWrappedWithConfigEnvar(t *testing.T) {
options: []ftltest.Option{
ftltest.WithDefaultProjectFile(),
ftltest.WithCallsAllowedWithinModule(),
ftltest.WhenVerb(time.Time, func(ctx context.Context, req time.TimeRequest) (time.TimeResponse, error) {
ftltest.WhenVerb[time.TimeClient, time.TimeRequest, time.TimeResponse](func(ctx context.Context, req time.TimeRequest) (time.TimeResponse, error) {
return time.TimeResponse{Time: stdtime.Date(2024, 1, 1, 0, 0, 0, 0, stdtime.UTC)}, nil
}),
},
Expand Down Expand Up @@ -95,7 +95,7 @@ func TestWrapped(t *testing.T) {
ftltest.WithConfig(myConfig, "helloworld"),
ftltest.WithSecret(mySecret, "shhhhh"),
ftltest.WithCallsAllowedWithinModule(),
ftltest.WhenVerb(time.Time, func(ctx context.Context, req time.TimeRequest) (time.TimeResponse, error) {
ftltest.WhenVerb[time.TimeClient, time.TimeRequest, time.TimeResponse](func(ctx context.Context, req time.TimeRequest) (time.TimeResponse, error) {
return time.TimeResponse{Time: stdtime.Date(2024, 1, 1, 0, 0, 0, 0, stdtime.UTC)}, nil
}),
},
Expand All @@ -107,7 +107,7 @@ func TestWrapped(t *testing.T) {
options: []ftltest.Option{
ftltest.WithProjectFile("ftl-project-test-1.toml"),
ftltest.WithCallsAllowedWithinModule(),
ftltest.WhenVerb(time.Time, func(ctx context.Context, req time.TimeRequest) (time.TimeResponse, error) {
ftltest.WhenVerb[time.TimeClient, time.TimeRequest, time.TimeResponse](func(ctx context.Context, req time.TimeRequest) (time.TimeResponse, error) {
return time.TimeResponse{Time: stdtime.Date(2024, 1, 1, 0, 0, 0, 0, stdtime.UTC)}, nil
}),
},
Expand All @@ -118,7 +118,7 @@ func TestWrapped(t *testing.T) {
options: []ftltest.Option{
ftltest.WithDefaultProjectFile(),
ftltest.WithCallsAllowedWithinModule(),
ftltest.WhenVerb(time.Time, func(ctx context.Context, req time.TimeRequest) (time.TimeResponse, error) {
ftltest.WhenVerb[time.TimeClient, time.TimeRequest, time.TimeResponse](func(ctx context.Context, req time.TimeRequest) (time.TimeResponse, error) {
return time.TimeResponse{Time: stdtime.Date(2024, 1, 1, 0, 0, 0, 0, stdtime.UTC)}, nil
}),
},
Expand Down
Loading

0 comments on commit b3fc9a1

Please sign in to comment.