Skip to content

Commit

Permalink
chore(allsrv): provide test suite for SVC behavior and fill gaps in b…
Browse files Browse the repository at this point in the history
…ehavior

This test suite provides the rest of the codebase super powers. When we have
new requirements to add, we can extend the service's test suite and we can use
that implementation across any number of implementation details. However,
the super power shows up when we start to integrate multiple service
implementations.

Here's a thought exercise:

Perhaps there is a `Bar` service that integrates the `Foo` service as part of a modular
monolith design. Now you have scale/requirements hitting you that force you
to scale `Foo` independent of `Bar` or vice versa. Perhaps we pull out the foo
svc into its own deployment. Now our `Bar` service needs to access the `Foo` service
via some RPC channel (HTTP|REST/gRPC/etc.).

We then create a remote SVC implementation, perhaps an HTTP client, that
implements the SVC behavior (interface). How do we verify this adheres to
the service behavior? Simple enough, just create another test with the
service's test suite, initilaize the necessary components, and excute them...
pretty simple and guaranteed to be in line with the previous implementation.

Question: Now that we have a remote SVC implementation via an HTTP client, what else
might we want to provide?
Question: How about a CLI that can integrate with the `Foo`?
Question: How would we test a CLI to validate it satisfies the `SVC` interface?
  • Loading branch information
jsteenb2 committed Jul 8, 2024
1 parent 8ace6f7 commit 6c865eb
Show file tree
Hide file tree
Showing 6 changed files with 544 additions and 29 deletions.
33 changes: 27 additions & 6 deletions allsrv/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,17 @@ const (
errTypeInternal
)

var errTypeStrs = [...]string{
errTypeUnknown: "unknown",
errTypeExists: "exists",
errTypeInvalid: "invalid",
errTypeUnAuthed: "unauthed",
errTypeNotFound: "not found",
errTypeInternal: "internal",
}

// Err provides a lightly structured error that we can attach behavior. Additionally,
// the use of fields makes it possible for us to enrich our logging infra without
// the use of options makes it possible for us to enrich our logging infra without
// blowing up the message cardinality.
type Err struct {
Type int
Expand All @@ -36,6 +45,14 @@ func ExistsErr(msg string, fields ...any) error {
}
}

func InvalidErr(msg string, fields ...any) error {
return Err{
Type: errTypeInvalid,
Msg: msg,
Fields: fields,
}
}

// NotFoundErr creates a not found error.
func NotFoundErr(msg string, fields ...any) error {
return Err{
Expand All @@ -48,17 +65,21 @@ func NotFoundErr(msg string, fields ...any) error {
func errFields(err error) []any {
var aErr Err
errors.As(err, &aErr)
return aErr.Fields
}

func IsNotFoundErr(err error) bool {
return isErrType(err, errTypeNotFound)
return append(aErr.Fields, "err_type", errTypeStrs[aErr.Type])
}

func IsExistsErr(err error) bool {
return isErrType(err, errTypeExists)
}

func IsInvalidErr(err error) bool {
return isErrType(err, errTypeInvalid)
}

func IsNotFoundErr(err error) bool {
return isErrType(err, errTypeNotFound)
}

func isErrType(err error, want int) bool {
var aErr Err
return errors.As(err, &aErr) && aErr.Type == want
Expand Down
6 changes: 1 addition & 5 deletions allsrv/server_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,7 @@ func TestServerV2(t *testing.T) {
tt.prepare(t, db)
}

defaultSVCOpts := []func(*allsrv.Service){
allsrv.WithSVCIDFn(newIDGen(1, 1)),
allsrv.WithSVCNowFn(nowFn(start, time.Hour)),
}
svcOpts := append(defaultSVCOpts, tt.svcOpts...)
svcOpts := append(defaultSVCOpts(start), tt.svcOpts...)
svc := allsrv.NewService(db, svcOpts...)

defaultSvrOpts := []allsrv.SvrOptFn{allsrv.WithMetrics(newTestMetrics(t))}
Expand Down
39 changes: 25 additions & 14 deletions allsrv/svc.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,29 @@ import (
"github.com/gofrs/uuid"
)

// Foo domain types.
type (
Foo struct {
ID string
Name string
Note string
CreatedAt time.Time
UpdatedAt time.Time
}
// Foo represents the foo domain entity.
type Foo struct {
ID string
Name string
Note string
CreatedAt time.Time
UpdatedAt time.Time
}

FooUpd struct {
ID string
Name *string
Note *string
// OK validates the fields are provided.
func (f Foo) OK() error {
if f.Name == "" {
return InvalidErr("name is required")
}
)
return nil
}

// FooUpd is a record for updating an existing foo.
type FooUpd struct {
ID string
Name *string
Note *string
}

// SVC defines the service behavior.
type SVC interface {
Expand Down Expand Up @@ -78,6 +85,10 @@ func NewService(db DB, opts ...func(*Service)) *Service {
}

func (s *Service) CreateFoo(ctx context.Context, f Foo) (Foo, error) {
if err := f.OK(); err != nil {
return Foo{}, err
}

now := s.nowFn()
f.ID, f.CreatedAt, f.UpdatedAt = s.idFn(), now, now

Expand Down
10 changes: 6 additions & 4 deletions allsrv/svc_mw_logging.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,13 @@ func (s *svcMWLogger) DelFoo(ctx context.Context, id string) error {
func (s *svcMWLogger) logFn(fields ...any) func(error) *slog.Logger {
start := time.Now()
return func(err error) *slog.Logger {
fields = append(fields, "took_ms", time.Since(start).Round(time.Millisecond).String())
logger := s.logger.
With(fields...).
With("took_ms", time.Since(start).Round(time.Millisecond).String())
if err != nil {
fields = append(fields, errFields(err)...)
fields = append(fields, "err", err.Error())
logger = logger.With("err", err.Error())
logger = logger.WithGroup("err_fields").With(errFields(err)...)
}
return s.logger.With(fields...)
return logger
}
}
Loading

0 comments on commit 6c865eb

Please sign in to comment.