Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Test refactorings #77

Merged
merged 1 commit into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading