From ef82c2b75b42b34eb86e393030051275a53b083b Mon Sep 17 00:00:00 2001 From: Robert Laszczak Date: Wed, 15 Jul 2020 20:51:06 +0000 Subject: [PATCH] Refactoring to Repository pattern --- .env | 8 +- Makefile | 6 + README.md | 3 +- docker-compose.yml | 10 + internal/common/logs/logrus.go | 2 +- internal/common/server/grpc.go | 2 +- internal/trainer/domain/hour/availability.go | 33 ++- .../trainer/domain/hour/availability_test.go | 37 ++- internal/trainer/domain/hour/hour.go | 182 ++++++++++--- internal/trainer/domain/hour/hour_test.go | 185 ++++++++++--- internal/trainer/domain/hour/repository.go | 2 +- internal/trainer/go.mod | 3 + internal/trainer/go.sum | 8 + ...sitory.go => hour_firestore_repository.go} | 19 +- internal/trainer/hour_memory_repository.go | 69 +++++ internal/trainer/hour_mysql_repository.go | 180 +++++++++++++ internal/trainer/hour_repository_test.go | 243 +++++++++++++++--- internal/trainer/main.go | 20 +- internal/users/firestore.go | 15 +- sql/schema.sql | 6 + 20 files changed, 918 insertions(+), 115 deletions(-) rename internal/trainer/{hour_repository.go => hour_firestore_repository.go} (88%) create mode 100644 internal/trainer/hour_memory_repository.go create mode 100644 internal/trainer/hour_mysql_repository.go create mode 100644 sql/schema.sql diff --git a/.env b/.env index 93183bf..fc18871 100644 --- a/.env +++ b/.env @@ -15,4 +15,10 @@ CORS_ALLOWED_ORIGINS=http://localhost:8080 #SERVICE_ACCOUNT_FILE=/service-account-file.json MOCK_AUTH=true -LOCAL_ENV=true \ No newline at end of file +LOCAL_ENV=true + +MYSQL_ADDR=localhost +MYSQL_DATABASE=db +MYSQL_USER=user +MYSQL_PASSWORD=password +MYSQL_RANDOM_ROOT_PASSWORD=true \ No newline at end of file diff --git a/Makefile b/Makefile index e7c929e..6ae5a71 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +include .env + .PHONY: openapi openapi: openapi_http openapi_js @@ -39,3 +41,7 @@ lint: @./scripts/lint.sh trainer @./scripts/lint.sh trainings @./scripts/lint.sh users + +.PHONY: mycli +mycli: + mycli -u ${MYSQL_USER} -p ${MYSQL_PASSWORD} ${MYSQL_DATABASE} \ No newline at end of file diff --git a/README.md b/README.md index 358b3a2..7d082e6 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ No application is perfect from the beginning. With over a dozen coming articles, 4. [**You should not build your own authentication. Let Firebase do it for you.**](https://threedots.tech/post/firebase-cloud-run-authentication/?utm_source=github.com) 5. [**Business Applications in Go: Things to know about DRY**](https://threedots.tech/post/things-to-know-about-dry/?utm_source=github.com) 6. [**When microservices in Go are not enough: introduction to DDD Lite**](https://threedots.tech/post/ddd-lite-in-go-introduction/?utm_source=github.com) -7. *More articles are on the way!* +7. [**Repository pattern: painless way to simplify your Go service logic**](https://threedots.tech/post/repository-pattern-in-go/?utm_source=github.com) +8. *More articles are on the way!* ### Directories diff --git a/docker-compose.yml b/docker-compose.yml index 54995e2..2401c47 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -97,3 +97,13 @@ services: - "127.0.0.1:8787:8787" - "127.0.0.1:4000:4000" restart: unless-stopped + + mysql: + image: mysql:8 + env_file: + - .env + volumes: + - ./sql/schema.sql:/docker-entrypoint-initdb.d/schema.sql + ports: + - "127.0.0.1:3306:3306" + restart: unless-stopped diff --git a/internal/common/logs/logrus.go b/internal/common/logs/logrus.go index 275d699..d3638b3 100644 --- a/internal/common/logs/logrus.go +++ b/internal/common/logs/logrus.go @@ -5,7 +5,7 @@ import ( "strconv" "github.com/sirupsen/logrus" - "github.com/x-cray/logrus-prefixed-formatter" + prefixed "github.com/x-cray/logrus-prefixed-formatter" ) func Init() { diff --git a/internal/common/server/grpc.go b/internal/common/server/grpc.go index 2e96d13..ffc8b07 100644 --- a/internal/common/server/grpc.go +++ b/internal/common/server/grpc.go @@ -6,7 +6,7 @@ import ( "os" grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" - "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus" + grpc_logrus "github.com/grpc-ecosystem/go-grpc-middleware/logging/logrus" grpc_ctxtags "github.com/grpc-ecosystem/go-grpc-middleware/tags" "github.com/sirupsen/logrus" "google.golang.org/grpc" diff --git a/internal/trainer/domain/hour/availability.go b/internal/trainer/domain/hour/availability.go index 0ae0f24..70112ef 100644 --- a/internal/trainer/domain/hour/availability.go +++ b/internal/trainer/domain/hour/availability.go @@ -2,6 +2,18 @@ package hour import "github.com/pkg/errors" +var ( + Available = Availability{"available"} + NotAvailable = Availability{"not_available"} + TrainingScheduled = Availability{"training_scheduled"} +) + +var availabilityValues = []Availability{ + Available, + NotAvailable, + TrainingScheduled, +} + // Availability is enum. // // Using struct instead of `type Availability string` for enums allows us to ensure, @@ -11,17 +23,24 @@ type Availability struct { a string } +func NewAvailabilityFromString(availabilityStr string) (Availability, error) { + for _, availability := range availabilityValues { + if availability.String() == availabilityStr { + return availability, nil + } + } + return Availability{}, errors.Errorf("unknown '%s' availability", availabilityStr) +} + // Every type in Go have zero value. In that case it's `Availability{}`. // It's always a good idea to check if provided value is not zero! func (h Availability) IsZero() bool { return h == Availability{} } -var ( - Available = Availability{"available"} - NotAvailable = Availability{"not_available"} - TrainingScheduled = Availability{"training_scheduled"} -) +func (h Availability) String() string { + return h.a +} var ( ErrTrainingScheduled = errors.New("unable to modify hour, because scheduled training") @@ -29,6 +48,10 @@ var ( ErrHourNotAvailable = errors.New("hour is not available") ) +func (h Hour) Availability() Availability { + return h.availability +} + func (h Hour) IsAvailable() bool { return h.availability == Available } diff --git a/internal/trainer/domain/hour/availability_test.go b/internal/trainer/domain/hour/availability_test.go index 421cfb2..0374b10 100644 --- a/internal/trainer/domain/hour/availability_test.go +++ b/internal/trainer/domain/hour/availability_test.go @@ -9,7 +9,7 @@ import ( ) func TestHour_MakeNotAvailable(t *testing.T) { - h, err := hour.NewAvailableHour(validTrainingHour()) + h, err := testHourFactory.NewAvailableHour(validTrainingHour()) require.NoError(t, err) require.NoError(t, h.MakeNotAvailable()) @@ -23,7 +23,7 @@ func TestHour_MakeNotAvailable_with_scheduled_training(t *testing.T) { } func TestHour_MakeAvailable(t *testing.T) { - h, err := hour.NewAvailableHour(validTrainingHour()) + h, err := testHourFactory.NewAvailableHour(validTrainingHour()) require.NoError(t, err) require.NoError(t, h.MakeNotAvailable()) @@ -39,7 +39,7 @@ func TestHour_MakeAvailable_with_scheduled_training(t *testing.T) { } func TestHour_ScheduleTraining(t *testing.T) { - h, err := hour.NewAvailableHour(validTrainingHour()) + h, err := testHourFactory.NewAvailableHour(validTrainingHour()) require.NoError(t, err) require.NoError(t, h.ScheduleTraining()) @@ -63,14 +63,39 @@ func TestHour_CancelTraining(t *testing.T) { } func TestHour_CancelTraining_no_training_scheduled(t *testing.T) { - h, err := hour.NewAvailableHour(validTrainingHour()) + h, err := testHourFactory.NewAvailableHour(validTrainingHour()) require.NoError(t, err) assert.Equal(t, hour.ErrNoTrainingScheduled, h.CancelTraining()) } +func TestNewAvailabilityFromString(t *testing.T) { + testCases := []hour.Availability{ + hour.Available, + hour.NotAvailable, + hour.TrainingScheduled, + } + + for _, expectedAvailability := range testCases { + t.Run(expectedAvailability.String(), func(t *testing.T) { + availability, err := hour.NewAvailabilityFromString(expectedAvailability.String()) + require.NoError(t, err) + + assert.Equal(t, expectedAvailability, availability) + }) + } +} + +func TestNewAvailabilityFromString_invalid(t *testing.T) { + _, err := hour.NewAvailabilityFromString("invalid_value") + assert.Error(t, err) + + _, err = hour.NewAvailabilityFromString("") + assert.Error(t, err) +} + func newHourWithScheduledTraining(t *testing.T) *hour.Hour { - h, err := hour.NewAvailableHour(validTrainingHour()) + h, err := testHourFactory.NewAvailableHour(validTrainingHour()) require.NoError(t, err) require.NoError(t, h.ScheduleTraining()) @@ -79,7 +104,7 @@ func newHourWithScheduledTraining(t *testing.T) *hour.Hour { } func newNotAvailableHour(t *testing.T) *hour.Hour { - h, err := hour.NewAvailableHour(validTrainingHour()) + h, err := testHourFactory.NewAvailableHour(validTrainingHour()) require.NoError(t, err) require.NoError(t, h.MakeNotAvailable()) diff --git a/internal/trainer/domain/hour/hour.go b/internal/trainer/domain/hour/hour.go index cb2d145..f5fb345 100644 --- a/internal/trainer/domain/hour/hour.go +++ b/internal/trainer/domain/hour/hour.go @@ -1,9 +1,11 @@ package hour import ( + "fmt" "time" "github.com/pkg/errors" + "go.uber.org/multierr" ) type Hour struct { @@ -12,26 +14,89 @@ type Hour struct { availability Availability } -var ( - ErrNotFullHour = errors.New("hour should be a full hour") - ErrTooDistantDate = errors.Errorf("schedule can be only set for next %d weeks", MaxWeeksInTheFutureToSet) - ErrPastHour = errors.New("cannot create hour from past") - ErrTooEarlyHour = errors.Errorf("too early hour, min UTC hour: %d", MinUtcHour) - ErrTooLateHour = errors.Errorf("too late hour, max UTC hour: %d", MaxUtcHour) -) +type FactoryConfig struct { + MaxWeeksInTheFutureToSet int + MinUtcHour int + MaxUtcHour int +} + +func (f FactoryConfig) Validate() error { + var err error + + if f.MaxWeeksInTheFutureToSet < 1 { + err = multierr.Append( + err, + errors.Errorf( + "MaxWeeksInTheFutureToSet should be greater than 1, but is %d", + f.MaxWeeksInTheFutureToSet, + ), + ) + } + if f.MinUtcHour < 0 || f.MinUtcHour > 24 { + err = multierr.Append( + err, + errors.Errorf( + "MinUtcHour should be value between 0 and 24, but is %d", + f.MinUtcHour, + ), + ) + } + if f.MaxUtcHour < 0 || f.MaxUtcHour > 24 { + err = multierr.Append( + err, + errors.Errorf( + "MinUtcHour should be value between 0 and 24, but is %d", + f.MaxUtcHour, + ), + ) + } -const ( - // in theory it may be in some config, but let's dont overcomplicate, YAGNI! - MaxWeeksInTheFutureToSet = 6 - MinUtcHour = 12 - MaxUtcHour = 20 + if f.MinUtcHour > f.MaxUtcHour { + err = multierr.Append( + err, + errors.Errorf( + "MaxUtcHour (%d) can't be after MinUtcHour (%d)", + f.MaxUtcHour, f.MinUtcHour, + ), + ) + } - day = time.Hour * 24 - week = day * 7 -) + return err +} + +type Factory struct { + // it's better to keep FactoryConfig as a private attributte, + // thanks to that we are always sure that our configuration is not changed in the not allowed way + fc FactoryConfig +} + +func NewFactory(fc FactoryConfig) (Factory, error) { + if err := fc.Validate(); err != nil { + return Factory{}, errors.Wrap(err, "invalid config passed to factory") + } + + return Factory{fc: fc}, nil +} + +func MustNewFactory(fc FactoryConfig) Factory { + f, err := NewFactory(fc) + if err != nil { + panic(err) + } + + return f +} -func NewAvailableHour(hour time.Time) (*Hour, error) { - if err := validateTime(hour); err != nil { +func (f Factory) Config() FactoryConfig { + return f.fc +} + +func (f Factory) IsZero() bool { + return f == Factory{} +} + +func (f Factory) NewAvailableHour(hour time.Time) (*Hour, error) { + if err := f.validateTime(hour); err != nil { return nil, err } @@ -41,8 +106,8 @@ func NewAvailableHour(hour time.Time) (*Hour, error) { }, nil } -func NewNotAvailableHour(hour time.Time) (*Hour, error) { - if err := validateTime(hour); err != nil { +func (f Factory) NewNotAvailableHour(hour time.Time) (*Hour, error) { + if err := f.validateTime(hour); err != nil { return nil, err } @@ -52,12 +117,12 @@ func NewNotAvailableHour(hour time.Time) (*Hour, error) { }, nil } -// UnmarshalHourFromRepository unmarshals Hour from the database. +// UnmarshalHourFromDatabase unmarshals Hour from the database. // // It should be used only for unmarshalling from the database! -// You can't use UnmarshalHourFromRepository as constructor - It may put domain into the invalid state! -func UnmarshalHourFromRepository(hour time.Time, availability Availability) (*Hour, error) { - if err := validateTime(hour); err != nil { +// You can't use UnmarshalHourFromDatabase as constructor - It may put domain into the invalid state! +func (f Factory) UnmarshalHourFromDatabase(hour time.Time, availability Availability) (*Hour, error) { + if err := f.validateTime(hour); err != nil { return nil, err } @@ -71,24 +136,81 @@ func UnmarshalHourFromRepository(hour time.Time, availability Availability) (*Ho }, nil } -func validateTime(hour time.Time) error { +var ( + ErrNotFullHour = errors.New("hour should be a full hour") + ErrPastHour = errors.New("cannot create hour from past") +) + +// If you have the error with a more complex context, +// it's a good idea to define it as a separate type. +// There is nothing worst, than error "invalid date" without knowing what date was passed and what is the valid value! +type TooDistantDateError struct { + MaxWeeksInTheFutureToSet int + ProvidedDate time.Time +} + +func (e TooDistantDateError) Error() string { + return fmt.Sprintf( + "schedule can be only set for next %d weeks, provided date: %s", + e.MaxWeeksInTheFutureToSet, + e.ProvidedDate, + ) +} + +type TooEarlyHourError struct { + MinUtcHour int + ProvidedTime time.Time +} + +func (e TooEarlyHourError) Error() string { + return fmt.Sprintf( + "too early hour, min UTC hour: %d, provided time: %s", + e.MinUtcHour, + e.ProvidedTime, + ) +} + +type TooLateHourError struct { + MaxUtcHour int + ProvidedTime time.Time +} + +func (e TooLateHourError) Error() string { + return fmt.Sprintf( + "too late hour, min UTC hour: %d, provided time: %s", + e.MaxUtcHour, + e.ProvidedTime, + ) +} + +func (f Factory) validateTime(hour time.Time) error { if !hour.Round(time.Hour).Equal(hour) { return ErrNotFullHour } - if hour.After(time.Now().Add(week * MaxWeeksInTheFutureToSet)) { - return ErrTooDistantDate + // AddDate is better than Add for adding days, because not every day have 24h! + if hour.After(time.Now().AddDate(0, 0, f.fc.MaxWeeksInTheFutureToSet*7)) { + return TooDistantDateError{ + MaxWeeksInTheFutureToSet: f.fc.MaxWeeksInTheFutureToSet, + ProvidedDate: hour, + } } currentHour := time.Now().Truncate(time.Hour) if hour.Before(currentHour) || hour.Equal(currentHour) { return ErrPastHour } - if hour.UTC().Hour() > MaxUtcHour { - return ErrTooLateHour + if hour.UTC().Hour() > f.fc.MaxUtcHour { + return TooLateHourError{ + MaxUtcHour: f.fc.MaxUtcHour, + ProvidedTime: hour, + } } - if hour.UTC().Hour() < MinUtcHour { - return ErrTooEarlyHour + if hour.UTC().Hour() < f.fc.MinUtcHour { + return TooEarlyHourError{ + MinUtcHour: f.fc.MinUtcHour, + ProvidedTime: hour, + } } return nil diff --git a/internal/trainer/domain/hour/hour_test.go b/internal/trainer/domain/hour/hour_test.go index 1426015..8a41edd 100644 --- a/internal/trainer/domain/hour/hour_test.go +++ b/internal/trainer/domain/hour/hour_test.go @@ -9,13 +9,14 @@ import ( "github.com/stretchr/testify/require" ) -const ( - day = time.Hour * 24 - week = day * 7 -) +var testHourFactory = hour.MustNewFactory(hour.FactoryConfig{ + MaxWeeksInTheFutureToSet: 100, + MinUtcHour: 0, + MaxUtcHour: 24, +}) func TestNewAvailableHour(t *testing.T) { - h, err := hour.NewAvailableHour(validTrainingHour()) + h, err := testHourFactory.NewAvailableHour(validTrainingHour()) require.NoError(t, err) assert.True(t, h.IsAvailable()) @@ -24,86 +25,212 @@ func TestNewAvailableHour(t *testing.T) { func TestNewAvailableHour_not_full_hour(t *testing.T) { constructorTime := trainingHourWithMinutes(13) - _, err := hour.NewAvailableHour(constructorTime) + _, err := testHourFactory.NewAvailableHour(constructorTime) assert.Equal(t, hour.ErrNotFullHour, err) } func TestNewAvailableHour_too_distant_date(t *testing.T) { - constructorTime := time.Now().Truncate(day).Add(week * hour.MaxWeeksInTheFutureToSet).Add(day) - - _, err := hour.NewAvailableHour(constructorTime) - assert.Equal(t, hour.ErrTooDistantDate, err) + maxWeeksInFuture := 1 + + factory := hour.MustNewFactory(hour.FactoryConfig{ + MaxWeeksInTheFutureToSet: maxWeeksInFuture, + MinUtcHour: 0, + MaxUtcHour: 0, + }) + + constructorTime := time.Now().Truncate(time.Hour*24).AddDate(0, 0, maxWeeksInFuture*7+1) + + _, err := factory.NewAvailableHour(constructorTime) + assert.Equal( + t, + hour.TooDistantDateError{ + MaxWeeksInTheFutureToSet: maxWeeksInFuture, + ProvidedDate: constructorTime, + }, + err, + ) } func TestNewAvailableHour_past_date(t *testing.T) { pastHour := time.Now().Truncate(time.Hour).Add(-time.Hour) - _, err := hour.NewAvailableHour(pastHour) + _, err := testHourFactory.NewAvailableHour(pastHour) assert.Equal(t, hour.ErrPastHour, err) currentHour := time.Now().Truncate(time.Hour) - _, err = hour.NewAvailableHour(currentHour) + _, err = testHourFactory.NewAvailableHour(currentHour) assert.Equal(t, hour.ErrPastHour, err) } func TestNewAvailableHour_too_early_hour(t *testing.T) { - currentTime := time.Now().Add(day) - constructorTime := time.Date( + factory := hour.MustNewFactory(hour.FactoryConfig{ + MaxWeeksInTheFutureToSet: 10, + MinUtcHour: 12, + MaxUtcHour: 18, + }) + + // we are using next day, to be sure that provided hour is not in the past + currentTime := time.Now().AddDate(0, 0, 1) + + tooEarlyHour := time.Date( currentTime.Year(), currentTime.Month(), currentTime.Day(), - hour.MinUtcHour-1, 0, 0, 0, + factory.Config().MinUtcHour-1, 0, 0, 0, time.UTC, ) - _, err := hour.NewAvailableHour(constructorTime) - assert.Equal(t, hour.ErrTooEarlyHour, err) + _, err := factory.NewAvailableHour(tooEarlyHour) + assert.Equal( + t, + hour.TooEarlyHourError{ + MinUtcHour: factory.Config().MinUtcHour, + ProvidedTime: tooEarlyHour, + }, + err, + ) } func TestNewAvailableHour_too_late_hour(t *testing.T) { - currentTime := time.Now() - constructorTime := time.Date( + factory := hour.MustNewFactory(hour.FactoryConfig{ + MaxWeeksInTheFutureToSet: 10, + MinUtcHour: 12, + MaxUtcHour: 18, + }) + + // we are using next day, to be sure that provided hour is not in the past + currentTime := time.Now().AddDate(0, 0, 1) + + tooEarlyHour := time.Date( currentTime.Year(), currentTime.Month(), currentTime.Day(), - hour.MaxUtcHour+1, 0, 0, 0, + factory.Config().MaxUtcHour+1, 0, 0, 0, time.UTC, ) - _, err := hour.NewAvailableHour(constructorTime) - assert.Equal(t, hour.ErrTooLateHour, err) + _, err := factory.NewAvailableHour(tooEarlyHour) + assert.Equal( + t, + hour.TooLateHourError{ + MaxUtcHour: factory.Config().MaxUtcHour, + ProvidedTime: tooEarlyHour, + }, + err, + ) } func TestHour_Time(t *testing.T) { expectedTime := validTrainingHour() - h, err := hour.NewAvailableHour(expectedTime) + h, err := testHourFactory.NewAvailableHour(expectedTime) require.NoError(t, err) assert.Equal(t, expectedTime, h.Time()) } -func TestUnmarshalHourFromRepository(t *testing.T) { +func TestUnmarshalHourFromDatabase(t *testing.T) { trainingTime := validTrainingHour() - h, err := hour.UnmarshalHourFromRepository(trainingTime, hour.TrainingScheduled) + h, err := testHourFactory.UnmarshalHourFromDatabase(trainingTime, hour.TrainingScheduled) require.NoError(t, err) assert.Equal(t, trainingTime, h.Time()) assert.True(t, h.HasTrainingScheduled()) } +func TestFactoryConfig_Validate(t *testing.T) { + testCases := []struct { + Name string + Config hour.FactoryConfig + ExpectedErr string + }{ + { + Name: "valid", + Config: hour.FactoryConfig{ + MaxWeeksInTheFutureToSet: 10, + MinUtcHour: 10, + MaxUtcHour: 12, + }, + ExpectedErr: "", + }, + { + Name: "equal_min_and_max_hour", + Config: hour.FactoryConfig{ + MaxWeeksInTheFutureToSet: 10, + MinUtcHour: 12, + MaxUtcHour: 12, + }, + ExpectedErr: "", + }, + { + Name: "min_hour_after_max_hour", + Config: hour.FactoryConfig{ + MaxWeeksInTheFutureToSet: 10, + MinUtcHour: 13, + MaxUtcHour: 12, + }, + ExpectedErr: "MaxUtcHour (12) can't be after MinUtcHour (13)", + }, + { + Name: "zero_max_weeks", + Config: hour.FactoryConfig{ + MaxWeeksInTheFutureToSet: 0, + MinUtcHour: 10, + MaxUtcHour: 12, + }, + ExpectedErr: "MaxWeeksInTheFutureToSet should be greater than 1, but is 0", + }, + { + Name: "sub_zero_min_hour", + Config: hour.FactoryConfig{ + MaxWeeksInTheFutureToSet: 10, + MinUtcHour: -1, + MaxUtcHour: 12, + }, + ExpectedErr: "MinUtcHour should be value between 0 and 24, but is -1", + }, + { + Name: "sub_zero_max_hour", + Config: hour.FactoryConfig{ + MaxWeeksInTheFutureToSet: 10, + MinUtcHour: 10, + MaxUtcHour: -1, + }, + ExpectedErr: "MinUtcHour should be value between 0 and 24, but is -1; MaxUtcHour (-1) can't be after MinUtcHour (10)", + }, + } + + for _, c := range testCases { + t.Run(c.Name, func(t *testing.T) { + err := c.Config.Validate() + + if c.ExpectedErr != "" { + assert.EqualError(t, err, c.ExpectedErr) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNewFactory_invalid_config(t *testing.T) { + f, err := hour.NewFactory(hour.FactoryConfig{}) + assert.Error(t, err) + assert.Zero(t, f) +} + func validTrainingHour() time.Time { - tomorrow := time.Now().Add(day) + tomorrow := time.Now().Add(time.Hour * 24) return time.Date( tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), - hour.MinUtcHour, 0, 0, 0, + testHourFactory.Config().MinUtcHour, 0, 0, 0, time.UTC, ) } func trainingHourWithMinutes(minute int) time.Time { - tomorrow := time.Now().Add(day) + tomorrow := time.Now().Add(time.Hour * 24) return time.Date( tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), - hour.MinUtcHour, minute, 0, 0, + testHourFactory.Config().MaxUtcHour, minute, 0, 0, time.UTC, ) } diff --git a/internal/trainer/domain/hour/repository.go b/internal/trainer/domain/hour/repository.go index b07f570..a31dc81 100644 --- a/internal/trainer/domain/hour/repository.go +++ b/internal/trainer/domain/hour/repository.go @@ -6,7 +6,7 @@ import ( ) type Repository interface { - GetOrCreateHour(ctx context.Context, time time.Time) (*Hour, error) + GetOrCreateHour(ctx context.Context, hourTime time.Time) (*Hour, error) UpdateHour( ctx context.Context, hourTime time.Time, diff --git a/internal/trainer/go.mod b/internal/trainer/go.mod index 24a639f..df8ef8c 100644 --- a/internal/trainer/go.mod +++ b/internal/trainer/go.mod @@ -8,10 +8,13 @@ require ( github.com/deepmap/oapi-codegen v1.3.6 github.com/go-chi/chi v4.1.0+incompatible github.com/go-chi/render v1.0.1 + github.com/go-sql-driver/mysql v1.4.0 github.com/golang/protobuf v1.3.5 + github.com/jmoiron/sqlx v1.2.0 github.com/pkg/errors v0.9.1 github.com/sirupsen/logrus v1.5.0 github.com/stretchr/testify v1.4.0 + go.uber.org/multierr v1.1.0 golang.org/x/sys v0.0.0-20200331124033-c3d80250170d // indirect google.golang.org/api v0.21.0 google.golang.org/genproto v0.0.0-20200403120447-c50568487044 // indirect diff --git a/internal/trainer/go.sum b/internal/trainer/go.sum index 5045aac..8bc6c3e 100644 --- a/internal/trainer/go.sum +++ b/internal/trainer/go.sum @@ -69,6 +69,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -114,6 +116,8 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= +github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= @@ -130,6 +134,7 @@ github.com/labstack/echo/v4 v4.1.11 h1:z0BZoArY4FqdpUEl+wlHp4hnr/oSR6MTmQmv8OHSo github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g= github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/matryer/moq v0.0.0-20190312154309-6cfb0558e1bd/go.mod h1:9ELz6aaclSIGnZBoaSLZ3NAl1VTufbOrXBPvtcy6WiQ= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= @@ -142,6 +147,7 @@ github.com/mattn/go-isatty v0.0.10 h1:qxFzApOv4WsAL965uUPIsXzAKCZxN2p9UqdhFS4ZW1 github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -179,7 +185,9 @@ go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3 h1:8sGtKOrtQqkN1bp2AtX+misvLIlOmsEsNd+9NIcPEm8= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/internal/trainer/hour_repository.go b/internal/trainer/hour_firestore_repository.go similarity index 88% rename from internal/trainer/hour_repository.go rename to internal/trainer/hour_firestore_repository.go index 8a43dc4..5f5abd0 100644 --- a/internal/trainer/hour_repository.go +++ b/internal/trainer/hour_firestore_repository.go @@ -14,14 +14,18 @@ import ( type FirestoreHourRepository struct { firestoreClient *firestore.Client + hourFactory hour.Factory } -func NewFirestoreHourRepository(firestoreClient *firestore.Client) *FirestoreHourRepository { +func NewFirestoreHourRepository(firestoreClient *firestore.Client, hourFactory hour.Factory) *FirestoreHourRepository { if firestoreClient == nil { panic("missing firestoreClient") } + if hourFactory.IsZero() { + panic("missing hourFactory") + } - return &FirestoreHourRepository{firestoreClient: firestoreClient} + return &FirestoreHourRepository{firestoreClient, hourFactory} } func (f FirestoreHourRepository) GetOrCreateHour(ctx context.Context, time time.Time) (*hour.Hour, error) { @@ -37,7 +41,7 @@ func (f FirestoreHourRepository) GetOrCreateHour(ctx context.Context, time time. return nil, err } - hourFromDb, err := f.domainHourFromDateModel(date, time) + hourFromDb, err := f.domainHourFromDateDTO(date, time) if err != nil { return nil, err } @@ -65,7 +69,7 @@ func (f FirestoreHourRepository) UpdateHour( return err } - hourFromDB, err := f.domainHourFromDateModel(firebaseDate, hourTime) + hourFromDB, err := f.domainHourFromDateDTO(firebaseDate, hourTime) if err != nil { return err } @@ -113,10 +117,11 @@ func (f FirestoreHourRepository) getDateDTO( // for now we are keeping backward comparability, because of that it's a bit messy and overcomplicated // todo - we will clean it up later with CQRS :-) -func (f FirestoreHourRepository) domainHourFromDateModel(date Date, hourTime time.Time) (*hour.Hour, error) { +func (f FirestoreHourRepository) domainHourFromDateDTO(date Date, hourTime time.Time) (*hour.Hour, error) { firebaseHour, found := findHourInDateDTO(date, hourTime) if !found { - return hour.NewNotAvailableHour(hourTime) + // in reality this date exists, even if it's not persisted + return f.hourFactory.NewNotAvailableHour(hourTime) } availability, err := mapAvailabilityFromDTO(firebaseHour) @@ -124,7 +129,7 @@ func (f FirestoreHourRepository) domainHourFromDateModel(date Date, hourTime tim return nil, err } - return hour.UnmarshalHourFromRepository(firebaseHour.Hour.Local(), availability) + return f.hourFactory.UnmarshalHourFromDatabase(firebaseHour.Hour.Local(), availability) } // for now we are keeping backward comparability, because of that it's a bit messy and overcomplicated diff --git a/internal/trainer/hour_memory_repository.go b/internal/trainer/hour_memory_repository.go new file mode 100644 index 0000000..396f1cf --- /dev/null +++ b/internal/trainer/hour_memory_repository.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "sync" + "time" + + "github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainer/domain/hour" +) + +type MemoryHourRepository struct { + hours map[time.Time]hour.Hour + lock *sync.RWMutex + + hourFactory hour.Factory +} + +func NewMemoryHourRepository(hourFactory hour.Factory) *MemoryHourRepository { + if hourFactory.IsZero() { + panic("missing hourFactory") + } + + return &MemoryHourRepository{ + hours: map[time.Time]hour.Hour{}, + lock: &sync.RWMutex{}, + hourFactory: hourFactory, + } +} + +func (m MemoryHourRepository) GetOrCreateHour(_ context.Context, hourTime time.Time) (*hour.Hour, error) { + m.lock.RLock() + defer m.lock.RUnlock() + + return m.getOrCreateHour(hourTime) +} + +func (m MemoryHourRepository) getOrCreateHour(hourTime time.Time) (*hour.Hour, error) { + currentHour, ok := m.hours[hourTime] + if !ok { + return m.hourFactory.NewNotAvailableHour(hourTime) + } + + // we don't store hours as pointers, but as values + // thanks to that, we are sure that nobody can modify Hour without using UpdateHour + return ¤tHour, nil +} + +func (m *MemoryHourRepository) UpdateHour( + _ context.Context, + hourTime time.Time, + updateFn func(h *hour.Hour) (*hour.Hour, error), +) error { + m.lock.Lock() + defer m.lock.Unlock() + + currentHour, err := m.getOrCreateHour(hourTime) + if err != nil { + return err + } + + updatedHour, err := updateFn(currentHour) + if err != nil { + return err + } + + m.hours[hourTime] = *updatedHour + + return nil +} diff --git a/internal/trainer/hour_mysql_repository.go b/internal/trainer/hour_mysql_repository.go new file mode 100644 index 0000000..49967fb --- /dev/null +++ b/internal/trainer/hour_mysql_repository.go @@ -0,0 +1,180 @@ +package main + +import ( + "context" + "database/sql" + "os" + "time" + + "github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainer/domain/hour" + "github.com/go-sql-driver/mysql" + _ "github.com/go-sql-driver/mysql" + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + "go.uber.org/multierr" +) + +type mysqlHour struct { + ID string `db:"id"` + Hour time.Time `db:"hour"` + Availability string `db:"availability"` +} + +type MySQLHourRepository struct { + db *sqlx.DB + hourFactory hour.Factory +} + +func NewMySQLHourRepository(db *sqlx.DB, hourFactory hour.Factory) *MySQLHourRepository { + if db == nil { + panic("missing db") + } + if hourFactory.IsZero() { + panic("missing hourFactory") + } + + return &MySQLHourRepository{db: db, hourFactory: hourFactory} +} + +// sqlContextGetter is an interface provided both by transaction and standard db connection +type sqlContextGetter interface { + GetContext(ctx context.Context, dest interface{}, query string, args ...interface{}) error +} + +func (m MySQLHourRepository) GetOrCreateHour(ctx context.Context, time time.Time) (*hour.Hour, error) { + return m.getOrCreateHour(ctx, m.db, time, false) +} + +func (m MySQLHourRepository) getOrCreateHour( + ctx context.Context, + db sqlContextGetter, + hourTime time.Time, + forUpdate bool, +) (*hour.Hour, error) { + dbHour := mysqlHour{} + + query := "SELECT * FROM `hours` WHERE `hour` = ?" + if forUpdate { + query += " FOR UPDATE" + } + + err := db.GetContext(ctx, &dbHour, query, hourTime.UTC()) + if errors.Is(err, sql.ErrNoRows) { + // in reality this date exists, even if it's not persisted + return m.hourFactory.NewNotAvailableHour(hourTime) + } else if err != nil { + return nil, errors.Wrap(err, "unable to get hour from db") + } + + availability, err := hour.NewAvailabilityFromString(dbHour.Availability) + if err != nil { + return nil, err + } + + domainHour, err := m.hourFactory.UnmarshalHourFromDatabase(dbHour.Hour.Local(), availability) + if err != nil { + return nil, err + } + + return domainHour, nil +} + +func (m MySQLHourRepository) UpdateHour( + ctx context.Context, + hourTime time.Time, + updateFn func(h *hour.Hour) (*hour.Hour, error), +) (err error) { + tx, err := m.db.Beginx() + if err != nil { + return errors.Wrap(err, "unable to start transaction") + } + + // Defer is executed on function just before exit. + // With defer, we are always sure that we will close our transaction properly. + defer func() { + // In `UpdateHour` we are using named return - `(err error)`. + // Thanks to that, that can check if function exits with error. + // + // Even if function exits without error, commit still can return error. + // In that case we can override nil to err `err = m.finish...`. + err = m.finishTransaction(err, tx) + }() + + existingHour, err := m.getOrCreateHour(ctx, tx, hourTime, true) + if err != nil { + return err + } + + updatedHour, err := updateFn(existingHour) + if err != nil { + return err + } + + if err := m.upsertHour(tx, updatedHour); err != nil { + return err + } + + return nil +} + +// upsertHour updates hour if hour already exists in the database. +// If your doesn't exists, it's inserted. +func (m MySQLHourRepository) upsertHour(tx *sqlx.Tx, hourToUpdate *hour.Hour) error { + updatedDbHour := mysqlHour{ + Hour: hourToUpdate.Time().UTC(), + Availability: hourToUpdate.Availability().String(), + } + + _, err := tx.NamedExec( + `INSERT INTO + hours (hour, availability) + VALUES + (:hour, :availability) + ON DUPLICATE KEY UPDATE + availability = :availability`, + updatedDbHour, + ) + if err != nil { + return errors.Wrap(err, "unable to upsert hour") + } + + return nil +} + +// finishTransaction rollbacks transaction if error is provided. +// If err is nil transaction is committed. +// +// If the rollback fails, we are using multierr library to add error about rollback failure. +// If the commit fails, commit error is returned. +func (m MySQLHourRepository) finishTransaction(err error, tx *sqlx.Tx) error { + if err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + return multierr.Combine(err, rollbackErr) + } + + return err + } else { + if commitErr := tx.Commit(); commitErr != nil { + return errors.Wrap(err, "failed to commit tx") + } + + return nil + } +} + +func NewMySQLConnection() (*sqlx.DB, error) { + config := mysql.Config{ + Addr: os.Getenv("MYSQL_ADDR"), + User: os.Getenv("MYSQL_USER"), + Passwd: os.Getenv("MYSQL_PASSWORD"), + DBName: os.Getenv("MYSQL_DATABASE"), + ParseTime: true, // with that parameter, we can use time.Time in mysqlHour.Hour + } + + db, err := sqlx.Connect("mysql", config.FormatDSN()) + if err != nil { + return nil, errors.Wrap(err, "cannot connect to MySQL") + } + + return db, nil +} diff --git a/internal/trainer/hour_repository_test.go b/internal/trainer/hour_repository_test.go index 19d3701..2bf0bb9 100644 --- a/internal/trainer/hour_repository_test.go +++ b/internal/trainer/hour_repository_test.go @@ -2,7 +2,10 @@ package main_test import ( "context" + "errors" + "math/rand" "os" + "sync" "testing" "time" @@ -13,9 +16,67 @@ import ( "github.com/stretchr/testify/require" ) -func TestFirestoreHourRepository(t *testing.T) { +func TestRepository(t *testing.T) { + rand.Seed(time.Now().UTC().UnixNano()) + + repositories := createRepositories(t) + + for i := range repositories { + // When you are looping over slice and later using iterated value in goroutine (here because of t.Parallel()), + // you need to always create variable scoped in loop body! + // More info here: https://github.com/golang/go/wiki/CommonMistakes#using-goroutines-on-loop-iterator-variables + r := repositories[i] + + t.Run(r.Name, func(t *testing.T) { + // It's always a good idea to build all non-unit tests to be able to work in parallel. + // Thanks to that, your tests will be always fast and you will not be afraid to add more tests because of slowdown. + t.Parallel() + + t.Run("testUpdateHour", func(t *testing.T) { + t.Parallel() + testUpdateHour(t, r.Repository) + }) + t.Run("testUpdateHour_parallel", func(t *testing.T) { + t.Parallel() + testUpdateHour_parallel(t, r.Repository) + }) + t.Run("testHourRepository_update_existing", func(t *testing.T) { + t.Parallel() + testHourRepository_update_existing(t, r.Repository) + }) + t.Run("testUpdateHour_rollback", func(t *testing.T) { + t.Parallel() + testUpdateHour_rollback(t, r.Repository) + }) + }) + } +} + +type Repository struct { + Name string + Repository hour.Repository +} + +func createRepositories(t *testing.T) []Repository { + return []Repository{ + { + Name: "Firebase", + Repository: newFirebaseRepository(t, context.Background()), + }, + { + Name: "MySQL", + Repository: newMySQLRepository(t), + }, + { + Name: "memory", + Repository: main.NewMemoryHourRepository(testHourFactory), + }, + } +} + +func testUpdateHour(t *testing.T, repository hour.Repository) { + t.Helper() ctx := context.Background() - repo := newFirebaseRepository(t, ctx) testCases := []struct { Name string @@ -24,13 +85,13 @@ func TestFirestoreHourRepository(t *testing.T) { { Name: "available_hour", CreateHour: func(t *testing.T) *hour.Hour { - return newValidAvailableHour(t, 1) + return newValidAvailableHour(t) }, }, { Name: "not_available_hour", CreateHour: func(t *testing.T) *hour.Hour { - h := newValidAvailableHour(t, 2) + h := newValidAvailableHour(t) require.NoError(t, h.MakeNotAvailable()) return h @@ -39,7 +100,7 @@ func TestFirestoreHourRepository(t *testing.T) { { Name: "hour_with_training", CreateHour: func(t *testing.T) *hour.Hour { - h := newValidAvailableHour(t, 3) + h := newValidAvailableHour(t) require.NoError(t, h.ScheduleTraining()) return h @@ -51,7 +112,7 @@ func TestFirestoreHourRepository(t *testing.T) { t.Run(tc.Name, func(t *testing.T) { newHour := tc.CreateHour(t) - err := repo.UpdateHour(ctx, newHour.Time(), func(_ *hour.Hour) (*hour.Hour, error) { + err := repository.UpdateHour(ctx, newHour.Time(), func(_ *hour.Hour) (*hour.Hour, error) { // UpdateHour provides us existing/new *hour.Hour, // but we are ignoring this hour and persisting result of `CreateHour` // we can assert this hour later in assertHourInRepository @@ -59,26 +120,120 @@ func TestFirestoreHourRepository(t *testing.T) { }) require.NoError(t, err) - assertHourInRepository(ctx, t, repo, newHour) + assertHourInRepository(ctx, t, repository, newHour) }) } } -//TestNewFirestoreHourRepository_update_existing is testing path of creating a new hour and updating this hour. -func TestNewFirestoreHourRepository_update_existing(t *testing.T) { +func testUpdateHour_parallel(t *testing.T, repository hour.Repository) { + if _, ok := repository.(*main.FirestoreHourRepository); ok { + // todo - enable after fix of https://github.com/googleapis/google-cloud-go/issues/2604 + t.Skip("because of emulator bug, it's not working in Firebase") + } + + t.Helper() + ctx := context.Background() + + hourTime := newValidHourTime() + + // we are adding available hour + err := repository.UpdateHour(ctx, hourTime, func(h *hour.Hour) (*hour.Hour, error) { + if err := h.MakeAvailable(); err != nil { + return nil, err + } + return h, nil + }) + require.NoError(t, err) + + workersCount := 10 + workersDone := sync.WaitGroup{} + workersDone.Add(workersCount) + + startWorkers := make(chan struct{}) + trainingsScheduled := make(chan int, workersCount) + + // we are trying to do race condition, in practice only one worker should be able to finish transaction + for worker := 0; worker < workersCount; worker++ { + workerNum := worker + + go func() { + defer workersDone.Done() + <-startWorkers + + schedulingTraining := false + + err := repository.UpdateHour(ctx, hourTime, func(h *hour.Hour) (*hour.Hour, error) { + if h.HasTrainingScheduled() { + return h, nil + } + if err := h.ScheduleTraining(); err != nil { + return nil, err + } + + schedulingTraining = true + + return h, nil + }) + + if schedulingTraining && err == nil { + trainingsScheduled <- workerNum + } + }() + } + + close(startWorkers) + workersDone.Wait() + close(trainingsScheduled) + + var workersScheduledTraining []int + + for workerNum := range trainingsScheduled { + workersScheduledTraining = append(workersScheduledTraining, workerNum) + } + + assert.Len(t, workersScheduledTraining, 1, "only one worker should schedule training") +} + +func testUpdateHour_rollback(t *testing.T, repository hour.Repository) { + t.Helper() ctx := context.Background() - repo := newFirebaseRepository(t, ctx) - testHour := newValidAvailableHour(t, 5) + hourTime := newValidHourTime() + + err := repository.UpdateHour(ctx, hourTime, func(h *hour.Hour) (*hour.Hour, error) { + require.NoError(t, h.MakeAvailable()) + return h, nil + }) + + err = repository.UpdateHour(ctx, hourTime, func(h *hour.Hour) (*hour.Hour, error) { + assert.True(t, h.IsAvailable()) + require.NoError(t, h.MakeNotAvailable()) + + return h, errors.New("something went wrong") + }) + require.Error(t, err) + + persistedHour, err := repository.GetOrCreateHour(ctx, hourTime) + require.NoError(t, err) - err := repo.UpdateHour(ctx, testHour.Time(), func(_ *hour.Hour) (*hour.Hour, error) { + assert.True(t, persistedHour.IsAvailable(), "availability change was persisted, not rolled back") +} + +// testHourRepository_update_existing is testing path of creating a new hour and updating this hour. +func testHourRepository_update_existing(t *testing.T, repository hour.Repository) { + t.Helper() + ctx := context.Background() + + testHour := newValidAvailableHour(t) + + err := repository.UpdateHour(ctx, testHour.Time(), func(_ *hour.Hour) (*hour.Hour, error) { return testHour, nil }) require.NoError(t, err) - assertHourInRepository(ctx, t, repo, testHour) + assertHourInRepository(ctx, t, repository, testHour) var expectedHour *hour.Hour - err = repo.UpdateHour(ctx, testHour.Time(), func(h *hour.Hour) (*hour.Hour, error) { + err = repository.UpdateHour(ctx, testHour.Time(), func(h *hour.Hour) (*hour.Hour, error) { if err := h.ScheduleTraining(); err != nil { return nil, err } @@ -86,11 +241,11 @@ func TestNewFirestoreHourRepository_update_existing(t *testing.T) { return h, nil }) require.NoError(t, err) - assertHourInRepository(ctx, t, repo, expectedHour) + + assertHourInRepository(ctx, t, repository, expectedHour) } func TestNewDateDTO(t *testing.T) { - testCases := []struct { Time time.Time ExpectedDateTime time.Time @@ -115,35 +270,65 @@ func TestNewDateDTO(t *testing.T) { } } +// in general global state is not the best idea, but sometimes rules have some exceptions! +// in tests it's just simpler to re-use one instance of the factory +var testHourFactory = hour.MustNewFactory(hour.FactoryConfig{ + // 500 weeks gives us enough entropy to avoid duplicated dates + // (even if duplicate dates should be not a problem) + MaxWeeksInTheFutureToSet: 500, + MinUtcHour: 0, + MaxUtcHour: 24, +}) + func newFirebaseRepository(t *testing.T, ctx context.Context) *main.FirestoreHourRepository { firebaseClient, err := firestore.NewClient(ctx, os.Getenv("GCP_PROJECT")) require.NoError(t, err) - repo := main.NewFirestoreHourRepository(firebaseClient) - return repo + return main.NewFirestoreHourRepository(firebaseClient, testHourFactory) } -func newValidAvailableHour(t *testing.T, hourAfterMinHour int) *hour.Hour { - hourTime := newValidHourTime(hourAfterMinHour) +func newMySQLRepository(t *testing.T) *main.MySQLHourRepository { + db, err := main.NewMySQLConnection() + require.NoError(t, err) - hour, err := hour.NewAvailableHour(hourTime) + return main.NewMySQLHourRepository(db, testHourFactory) +} + +func newValidAvailableHour(t *testing.T) *hour.Hour { + hourTime := newValidHourTime() + + hour, err := testHourFactory.NewAvailableHour(hourTime) require.NoError(t, err) return hour } -func newValidHourTime(hourAfterMinHour int) time.Time { - tomorrow := time.Now().Add(time.Hour * 24) +// usedHours is storing hours used during the test, +// to ensure that within one test run we are not using the same hour +// (it should be not a problem between test runs) +var usedHours = sync.Map{} + +func newValidHourTime() time.Time { + for { + minTime := time.Now().AddDate(0, 0, 1) + + minTimestamp := minTime.Unix() + maxTimestamp := minTime.AddDate(0, 0, testHourFactory.Config().MaxWeeksInTheFutureToSet*7).Unix() + + t := time.Unix(rand.Int63n(maxTimestamp-minTimestamp)+minTimestamp, 0).Truncate(time.Hour).Local() - return time.Date( - tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), - hour.MinUtcHour+hourAfterMinHour, 0, 0, 0, - time.UTC, - ).Local() + _, alreadyUsed := usedHours.LoadOrStore(t.Unix(), true) + if !alreadyUsed { + return t + } + } } -func assertHourInRepository(ctx context.Context, t *testing.T, repo *main.FirestoreHourRepository, hour *hour.Hour) { +func assertHourInRepository(ctx context.Context, t *testing.T, repo hour.Repository, hour *hour.Hour) { + require.NotNil(t, hour) + hourFromRepo, err := repo.GetOrCreateHour(ctx, hour.Time()) require.NoError(t, err) + assert.Equal(t, hour, hourFromRepo) } diff --git a/internal/trainer/main.go b/internal/trainer/main.go index 9fcb2dd..99a0055 100644 --- a/internal/trainer/main.go +++ b/internal/trainer/main.go @@ -11,6 +11,7 @@ import ( "github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/common/genproto/trainer" "github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/common/logs" "github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/common/server" + "github.com/ThreeDotsLabs/wild-workouts-go-ddd-example/internal/trainer/domain/hour" "github.com/go-chi/chi" "google.golang.org/grpc" ) @@ -26,17 +27,32 @@ func main() { firebaseDB := db{firestoreClient} + hourFactory, err := hour.NewFactory(hour.FactoryConfig{ + MaxWeeksInTheFutureToSet: 6, + MinUtcHour: 12, + MaxUtcHour: 20, + }) + if err != nil { + panic(err) + } + serverType := strings.ToLower(os.Getenv("SERVER_TO_RUN")) switch serverType { case "http": go loadFixtures(firebaseDB) server.RunHTTPServer(func(router chi.Router) http.Handler { - return HandlerFromMux(HttpServer{firebaseDB, NewFirestoreHourRepository(firestoreClient)}, router) + return HandlerFromMux( + HttpServer{ + firebaseDB, + NewFirestoreHourRepository(firestoreClient, hourFactory), + }, + router, + ) }) case "grpc": server.RunGRPCServer(func(server *grpc.Server) { - svc := GrpcServer{NewFirestoreHourRepository(firestoreClient)} + svc := GrpcServer{NewFirestoreHourRepository(firestoreClient, hourFactory)} trainer.RegisterTrainerServiceServer(server, svc) }) default: diff --git a/internal/users/firestore.go b/internal/users/firestore.go index 670bfaa..c2e0b0b 100644 --- a/internal/users/firestore.go +++ b/internal/users/firestore.go @@ -76,14 +76,25 @@ func (d db) UpdateBalance(ctx context.Context, userID string, amountChange int) }) } +const lastIPField = "LastIP" + func (d db) UpdateLastIP(ctx context.Context, userID string, lastIP string) error { updates := []firestore.Update{ { - Path: "LastIP", + Path: lastIPField, Value: lastIP, }, } - _, err := d.UserDocumentRef(userID).Update(ctx, updates) + docRef := d.UserDocumentRef(userID) + + _, err := docRef.Update(ctx, updates) + userNotExist := status.Code(err) == codes.NotFound + + if userNotExist { + _, err := docRef.Set(ctx, map[string]string{lastIPField: lastIP}) + return err + } + return err } diff --git a/sql/schema.sql b/sql/schema.sql new file mode 100644 index 0000000..9adeca3 --- /dev/null +++ b/sql/schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE `hours` +( + hour TIMESTAMP NOT NULL, + availability ENUM ('available', 'not_available', 'training_scheduled') NOT NULL, + PRIMARY KEY (hour) +); \ No newline at end of file