Skip to content

Commit

Permalink
chore: tests refactoring with fixtures to make them more manageable
Browse files Browse the repository at this point in the history
Big steps towards #63.

Also remove the need to maintain "in memory" repositories and consolidate
assertion functions.
  • Loading branch information
YuukanOO committed Sep 13, 2024
1 parent ec5bf83 commit 67fb23e
Show file tree
Hide file tree
Showing 108 changed files with 4,849 additions and 3,808 deletions.
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ COPY go.* ./
RUN go mod download
COPY . .
COPY --from=front_builder /app/build ./cmd/serve/front/build
RUN go build -ldflags="-s -w" -o seelf
RUN make build-back

FROM alpine:3.16
LABEL org.opencontainers.image.authors="[email protected]" \
Expand Down
14 changes: 11 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,28 @@ serve-docs: # Launch the docs dev server
serve-back: # Launch the backend API and creates an admin user if needed
[email protected] ADMIN_PASSWORD=admin LOG_LEVEL=debug go run main.go serve

test: # Launch every tests
test-front: # Launch the frontend tests
cd cmd/serve/front && npm i && npm test && cd ../../..

test-back: # Launch the backend tests
go vet ./...
go test ./... --cover

test: test-front test-back # Launch every tests

ts: # Print the current timestamp, useful for migrations
@date +%s

outdated: # Print direct dependencies and their latest version
go list -v -u -m -f '{{if not .Indirect}}{{.}}{{end}}' all

build: # Build the final binary for the current platform
build-front: # Build the frontend
cd cmd/serve/front && npm i && npm run build && cd ../../..
go build -ldflags="-s -w" -o seelf

build-back: # Build the backend
go build -tags release -ldflags="-s -w" -o seelf

build: build-front build-back # Build the final binary for the current platform

build-docs: # Build the docs
npm i && npm run docs:build
Expand Down
6 changes: 6 additions & 0 deletions cmd/config/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/YuukanOO/seelf/pkg/log"
"github.com/YuukanOO/seelf/pkg/monad"
"github.com/YuukanOO/seelf/pkg/must"
"github.com/YuukanOO/seelf/pkg/ostools"
"github.com/YuukanOO/seelf/pkg/validate"
"github.com/YuukanOO/seelf/pkg/validate/numbers"
)
Expand Down Expand Up @@ -139,6 +140,11 @@ func (c *configuration) Initialize(logger log.ConfigurableLogger, path string) e
return err
}

// Make sure the data path exists
if err = ostools.MkdirAll(c.Data.Path); err != nil {
return err
}

// Update logger based on loaded configuration
if err = logger.Configure(c.logFormat, c.logLevel); err != nil {
return err
Expand Down
3 changes: 2 additions & 1 deletion docs/contributing/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The **seelf** backend is written in the [Golang](https://go.dev/) language for i
### Packages overview

- `cmd/`: contains application commands such as the `serve` one
- `internal/`: contains internal package representing the **core features** of this application organized by bounded contexts and `app`, `domain` and `infra` folders (see [The Domain](#the-domain))
- `internal/`: contains internal package representing the **core features** of this application organized by bounded contexts and `app`, `domain`, `infra` and `fixture` folders (see [The Domain](#the-domain))
- `pkg/`: contains reusable stuff not tied to seelf which can be reused if needed

### The Domain {#the-domain}
Expand All @@ -19,6 +19,7 @@ The `internal/` follows a classic DDD structure with:
- `app`: commands and queries to orchestrate the domain logic
- `domain`: core stuff, entities and values objects, as pure as possible to be easily testable
- `infra`: implementation of domain specific interfaces for the current context
- `fixture`: test helpers, mostly for generating correct and random aggregates satisfying needed state

In Go, it's common to see entities as structs with every field exposed. In this project, I have decided to try something else to prevent unwanted mutations from happening and making things more explicit.

Expand Down
82 changes: 47 additions & 35 deletions internal/auth/app/create_first_account/create_first_account_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,71 +6,83 @@ import (

"github.com/YuukanOO/seelf/internal/auth/app/create_first_account"
"github.com/YuukanOO/seelf/internal/auth/domain"
"github.com/YuukanOO/seelf/internal/auth/fixture"
"github.com/YuukanOO/seelf/internal/auth/infra/crypto"
"github.com/YuukanOO/seelf/internal/auth/infra/memory"
"github.com/YuukanOO/seelf/pkg/assert"
"github.com/YuukanOO/seelf/pkg/bus"
"github.com/YuukanOO/seelf/pkg/must"
"github.com/YuukanOO/seelf/pkg/testutil"
"github.com/YuukanOO/seelf/pkg/bus/spy"
"github.com/YuukanOO/seelf/pkg/validate"
)

func Test_CreateFirstAccount(t *testing.T) {
ctx := context.Background()
hasher := crypto.NewBCryptHasher()
keygen := crypto.NewKeyGenerator()

sut := func(existingUsers ...*domain.User) bus.RequestHandler[string, create_first_account.Command] {
store := memory.NewUsersStore(existingUsers...)
return create_first_account.Handler(store, store, hasher, keygen)
arrange := func(tb testing.TB, seed ...fixture.SeedBuilder) (
bus.RequestHandler[string, create_first_account.Command],
spy.Dispatcher,
) {
context := fixture.PrepareDatabase(tb, seed...)
return create_first_account.Handler(context.UsersStore, context.UsersStore, crypto.NewBCryptHasher(), crypto.NewKeyGenerator()), context.Dispatcher
}

t.Run("should returns the existing user id if a user already exists", func(t *testing.T) {
usr := must.Panic(domain.NewUser(domain.NewEmailRequirement("[email protected]", true), "password", "apikey"))
uc := sut(&usr)
existingUser := fixture.User()
handler, dispatcher := arrange(t, fixture.WithUsers(&existingUser))

uid, err := uc(ctx, create_first_account.Command{})
uid, err := handler(context.Background(), create_first_account.Command{})

testutil.IsNil(t, err)
testutil.Equals(t, string(usr.ID()), uid)
assert.Nil(t, err)
assert.Equal(t, string(existingUser.ID()), uid)
assert.HasLength(t, 0, dispatcher.Signals())
})

t.Run("should require both email and password or fail with ErrAdminAccountRequired", func(t *testing.T) {
uc := sut()
uid, err := uc(ctx, create_first_account.Command{})
handler, _ := arrange(t)
uid, err := handler(context.Background(), create_first_account.Command{})

testutil.ErrorIs(t, create_first_account.ErrAdminAccountRequired, err)
testutil.Equals(t, "", uid)
assert.ErrorIs(t, create_first_account.ErrAdminAccountRequired, err)
assert.Equal(t, "", uid)

uid, err = uc(ctx, create_first_account.Command{Email: "[email protected]"})
testutil.ErrorIs(t, create_first_account.ErrAdminAccountRequired, err)
testutil.Equals(t, "", uid)

uid, err = uc(ctx, create_first_account.Command{Password: "admin"})
testutil.ErrorIs(t, create_first_account.ErrAdminAccountRequired, err)
testutil.Equals(t, "", uid)
uid, err = handler(context.Background(), create_first_account.Command{Email: "[email protected]"})
assert.ErrorIs(t, create_first_account.ErrAdminAccountRequired, err)
assert.Equal(t, "", uid)

uid, err = handler(context.Background(), create_first_account.Command{Password: "admin"})
assert.ErrorIs(t, create_first_account.ErrAdminAccountRequired, err)
assert.Equal(t, "", uid)
})

t.Run("should require valid inputs", func(t *testing.T) {
uc := sut()
uid, err := uc(ctx, create_first_account.Command{
Email: "notanemail",
handler, _ := arrange(t)
uid, err := handler(context.Background(), create_first_account.Command{
Email: "not_an_email",
Password: "admin",
})

testutil.ErrorIs(t, validate.ErrValidationFailed, err)
testutil.Equals(t, "", uid)

assert.Equal(t, "", uid)
assert.ValidationError(t, validate.FieldErrors{
"email": domain.ErrInvalidEmail,
}, err)
})

t.Run("should creates the first user account if everything is good", func(t *testing.T) {
uc := sut()
uid, err := uc(ctx, create_first_account.Command{
handler, dispatcher := arrange(t)
uid, err := handler(context.Background(), create_first_account.Command{
Email: "[email protected]",
Password: "admin",
})

testutil.IsNil(t, err)
testutil.NotEquals(t, "", uid)
assert.Nil(t, err)
assert.NotEqual(t, "", uid)

assert.HasLength(t, 1, dispatcher.Signals())
registered := assert.Is[domain.UserRegistered](t, dispatcher.Signals()[0])

assert.Equal(t, domain.UserRegistered{
ID: domain.UserID(uid),
Email: "[email protected]",
Password: assert.NotZero(t, registered.Password),
RegisteredAt: assert.NotZero(t, registered.RegisteredAt),
Key: assert.NotZero(t, registered.Key),
}, registered)
})
}
75 changes: 45 additions & 30 deletions internal/auth/app/login/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,66 +6,81 @@ import (

"github.com/YuukanOO/seelf/internal/auth/app/login"
"github.com/YuukanOO/seelf/internal/auth/domain"
"github.com/YuukanOO/seelf/internal/auth/fixture"
"github.com/YuukanOO/seelf/internal/auth/infra/crypto"
"github.com/YuukanOO/seelf/internal/auth/infra/memory"
"github.com/YuukanOO/seelf/pkg/apperr"
"github.com/YuukanOO/seelf/pkg/assert"
"github.com/YuukanOO/seelf/pkg/bus"
"github.com/YuukanOO/seelf/pkg/must"
"github.com/YuukanOO/seelf/pkg/testutil"
"github.com/YuukanOO/seelf/pkg/bus/spy"
"github.com/YuukanOO/seelf/pkg/validate"
"github.com/YuukanOO/seelf/pkg/validate/strings"
)

func Test_Login(t *testing.T) {
hasher := crypto.NewBCryptHasher()
password := must.Panic(hasher.Hash("password")) // Sample password hash for the string "password" for tests
existingUser := must.Panic(domain.NewUser(domain.NewEmailRequirement("[email protected]", true), password, "apikey"))

sut := func(existingUsers ...*domain.User) bus.RequestHandler[string, login.Command] {
store := memory.NewUsersStore(existingUsers...)
return login.Handler(store, hasher)
arrange := func(tb testing.TB, seed ...fixture.SeedBuilder) (
bus.RequestHandler[string, login.Command],
spy.Dispatcher,
) {
context := fixture.PrepareDatabase(tb, seed...)
return login.Handler(context.UsersStore, hasher), context.Dispatcher
}

t.Run("should require valid inputs", func(t *testing.T) {
uc := sut()
_, err := uc(context.Background(), login.Command{})
handler, _ := arrange(t)
_, err := handler(context.Background(), login.Command{})

testutil.ErrorIs(t, validate.ErrValidationFailed, err)
assert.ValidationError(t, validate.FieldErrors{
"email": domain.ErrInvalidEmail,
"password": strings.ErrRequired,
}, err)
})

t.Run("should complains if email does not exists", func(t *testing.T) {
uc := sut()
_, err := uc(context.Background(), login.Command{
handler, _ := arrange(t)
_, err := handler(context.Background(), login.Command{
Email: "[email protected]",
Password: "nobodycares",
Password: "no_body_cares",
})

validationErr, ok := apperr.As[validate.FieldErrors](err)
testutil.IsTrue(t, ok)
testutil.ErrorIs(t, domain.ErrInvalidEmailOrPassword, validationErr["email"])
testutil.ErrorIs(t, domain.ErrInvalidEmailOrPassword, validationErr["password"])
assert.ValidationError(t, validate.FieldErrors{
"email": domain.ErrInvalidEmailOrPassword,
"password": domain.ErrInvalidEmailOrPassword,
}, err)
})

t.Run("should complains if password does not match", func(t *testing.T) {
uc := sut(&existingUser)
_, err := uc(context.Background(), login.Command{
existingUser := fixture.User(
fixture.WithEmail("[email protected]"),
fixture.WithPassword("raw_password_hash", hasher),
)
handler, _ := arrange(t, fixture.WithUsers(&existingUser))

_, err := handler(context.Background(), login.Command{
Email: "[email protected]",
Password: "nobodycares",
Password: "no_body_cares",
})

validationErr, ok := apperr.As[validate.FieldErrors](err)
testutil.IsTrue(t, ok)
testutil.ErrorIs(t, domain.ErrInvalidEmailOrPassword, validationErr["email"])
testutil.ErrorIs(t, domain.ErrInvalidEmailOrPassword, validationErr["password"])
assert.ValidationError(t, validate.FieldErrors{
"email": domain.ErrInvalidEmailOrPassword,
"password": domain.ErrInvalidEmailOrPassword,
}, err)
})

t.Run("should returns a valid user id if it succeeds", func(t *testing.T) {
uc := sut(&existingUser)
uid, err := uc(context.Background(), login.Command{
existingUser := fixture.User(
fixture.WithEmail("[email protected]"),
fixture.WithPassword("password", hasher),
)
handler, dispatcher := arrange(t, fixture.WithUsers(&existingUser))

uid, err := handler(context.Background(), login.Command{
Email: "[email protected]",
Password: "password",
})

testutil.IsNil(t, err)
testutil.Equals(t, string(existingUser.ID()), uid)
assert.Nil(t, err)
assert.Equal(t, string(existingUser.ID()), uid)
assert.HasLength(t, 0, dispatcher.Signals())
})
}
42 changes: 24 additions & 18 deletions internal/auth/app/refresh_api_key/refresh_api_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,43 +6,49 @@ import (

"github.com/YuukanOO/seelf/internal/auth/app/refresh_api_key"
"github.com/YuukanOO/seelf/internal/auth/domain"
"github.com/YuukanOO/seelf/internal/auth/fixture"
"github.com/YuukanOO/seelf/internal/auth/infra/crypto"
"github.com/YuukanOO/seelf/internal/auth/infra/memory"
"github.com/YuukanOO/seelf/pkg/apperr"
"github.com/YuukanOO/seelf/pkg/assert"
"github.com/YuukanOO/seelf/pkg/bus"
"github.com/YuukanOO/seelf/pkg/must"
"github.com/YuukanOO/seelf/pkg/testutil"
"github.com/YuukanOO/seelf/pkg/bus/spy"
)

func Test_RefreshApiKey(t *testing.T) {
sut := func(existingUsers ...*domain.User) bus.RequestHandler[string, refresh_api_key.Command] {
store := memory.NewUsersStore(existingUsers...)

return refresh_api_key.Handler(store, store, crypto.NewKeyGenerator())
arrange := func(tb testing.TB, seed ...fixture.SeedBuilder) (
bus.RequestHandler[string, refresh_api_key.Command],
spy.Dispatcher,
) {
context := fixture.PrepareDatabase(tb, seed...)
return refresh_api_key.Handler(context.UsersStore, context.UsersStore, crypto.NewKeyGenerator()), context.Dispatcher
}

t.Run("should fail if the user does not exists", func(t *testing.T) {
uc := sut()
handler, _ := arrange(t)

_, err := uc(context.Background(), refresh_api_key.Command{})
_, err := handler(context.Background(), refresh_api_key.Command{})

testutil.ErrorIs(t, apperr.ErrNotFound, err)
assert.ErrorIs(t, apperr.ErrNotFound, err)
})

t.Run("should refresh the user's API key if everything is good", func(t *testing.T) {
user := must.Panic(domain.NewUser(domain.NewEmailRequirement("[email protected]", true), "someHashedPassword", "apikey"))
uc := sut(&user)
existingUser := fixture.User()
handler, dispatcher := arrange(t, fixture.WithUsers(&existingUser))

key, err := uc(context.Background(), refresh_api_key.Command{
ID: string(user.ID())},
key, err := handler(context.Background(), refresh_api_key.Command{
ID: string(existingUser.ID())},
)

testutil.IsNil(t, err)
testutil.NotEquals(t, "", key)
assert.Nil(t, err)
assert.NotEqual(t, "", key)

evt := testutil.EventIs[domain.UserAPIKeyChanged](t, &user, 1)
assert.HasLength(t, 1, dispatcher.Signals())
keyChanged := assert.Is[domain.UserAPIKeyChanged](t, dispatcher.Signals()[0])

testutil.Equals(t, user.ID(), evt.ID)
testutil.Equals(t, key, string(evt.Key))
assert.Equal(t, domain.UserAPIKeyChanged{
ID: existingUser.ID(),
Key: domain.APIKey(key),
}, keyChanged)
})
}
Loading

0 comments on commit 67fb23e

Please sign in to comment.