From 484bb8281d688aaf5c3eaa78de58e27e2f8a5b15 Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 18:12:43 -0300 Subject: [PATCH 01/67] chore: :rocket: start app --- cmd/main.go | 1 + go.mod | 3 +++ 2 files changed, 4 insertions(+) create mode 100644 cmd/main.go create mode 100644 go.mod diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1 @@ +package main diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b2facff --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/cassiusbessa/backend-test + +go 1.24.1 From 8d21c06bb040583286cb5b169895f1b6342ffada Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 18:55:40 -0300 Subject: [PATCH 02/67] feat: :sparkles: ensure create email with valid values --- internal/domain/object-values/email.go | 23 ++++++++++++++++++ internal/domain/object-values/email_test.go | 27 +++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 internal/domain/object-values/email.go create mode 100644 internal/domain/object-values/email_test.go diff --git a/internal/domain/object-values/email.go b/internal/domain/object-values/email.go new file mode 100644 index 0000000..fd198e7 --- /dev/null +++ b/internal/domain/object-values/email.go @@ -0,0 +1,23 @@ +package object_values + +import ( + "errors" + "regexp" +) + +type Email struct { + value string +} + +var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + +func NewEmail(email string) (*Email, error) { + if !emailRegex.MatchString(email) { + return nil, errors.New("email inválido") + } + return &Email{value: email}, nil +} + +func (e *Email) Value() string { + return e.value +} diff --git a/internal/domain/object-values/email_test.go b/internal/domain/object-values/email_test.go new file mode 100644 index 0000000..7a6b39b --- /dev/null +++ b/internal/domain/object-values/email_test.go @@ -0,0 +1,27 @@ +package object_values_test + +import ( + "testing" + + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" +) + +func TestNewEmail(t *testing.T) { + tests := []struct { + email string + expectError bool + }{ + {"test@example.com", false}, + {"invalidemail", true}, + {"@nouser.com", true}, + {"nodomain@", true}, + {"", true}, + } + + for _, tt := range tests { + _, err := object_values.NewEmail(tt.email) + if (err != nil) != tt.expectError { + t.Errorf("NewEmail(%s) expected error: %v, got: %v", tt.email, tt.expectError, err) + } + } +} From 3f15a0a27845b02c2511e89f03d19f9eeda6d9dd Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 18:56:58 -0300 Subject: [PATCH 03/67] refactor: :recycle: make email imutable --- internal/domain/object-values/email.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/internal/domain/object-values/email.go b/internal/domain/object-values/email.go index fd198e7..d0c7aec 100644 --- a/internal/domain/object-values/email.go +++ b/internal/domain/object-values/email.go @@ -9,15 +9,18 @@ type Email struct { value string } -var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) +var ( + emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + errInvalidEmail = errors.New("invalid email format") +) -func NewEmail(email string) (*Email, error) { +func NewEmail(email string) (Email, error) { if !emailRegex.MatchString(email) { - return nil, errors.New("email inválido") + return Email{}, errInvalidEmail } - return &Email{value: email}, nil + return Email{value: email}, nil } -func (e *Email) Value() string { +func (e Email) Value() string { return e.value } From 3b1ec596210aed777fe65ee97a5b044195e0622e Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 18:59:32 -0300 Subject: [PATCH 04/67] refactor: :recycle: use shared errors --- internal/domain/object-values/email.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/domain/object-values/email.go b/internal/domain/object-values/email.go index d0c7aec..f29ea4d 100644 --- a/internal/domain/object-values/email.go +++ b/internal/domain/object-values/email.go @@ -1,8 +1,9 @@ package object_values import ( - "errors" "regexp" + + "github.com/cassiusbessa/backend-test/internal/domain/shared" ) type Email struct { @@ -10,13 +11,12 @@ type Email struct { } var ( - emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) - errInvalidEmail = errors.New("invalid email format") + emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) ) func NewEmail(email string) (Email, error) { if !emailRegex.MatchString(email) { - return Email{}, errInvalidEmail + return Email{}, shared.ErrValidation } return Email{value: email}, nil } From ea10fb75106f0b8e94914a780c72abe72f4ea0d6 Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 19:40:13 -0300 Subject: [PATCH 05/67] feat: :sparkles: ensure create phone number with valid values --- internal/domain/object-values/phone-number.go | 24 +++++++++++++++ .../domain/object-values/phone-number_test.go | 29 +++++++++++++++++++ internal/domain/shared/errors.go | 12 ++++++++ 3 files changed, 65 insertions(+) create mode 100644 internal/domain/object-values/phone-number.go create mode 100644 internal/domain/object-values/phone-number_test.go create mode 100644 internal/domain/shared/errors.go diff --git a/internal/domain/object-values/phone-number.go b/internal/domain/object-values/phone-number.go new file mode 100644 index 0000000..f3dfc3e --- /dev/null +++ b/internal/domain/object-values/phone-number.go @@ -0,0 +1,24 @@ +package object_values + +import ( + "regexp" + + "github.com/cassiusbessa/backend-test/internal/domain/shared" +) + +type PhoneNumber struct { + value string +} + +var phoneRegex = regexp.MustCompile(`^\+[1-9]\d{7,14}$`) + +func NewPhoneNumber(phone string) (PhoneNumber, error) { + if !phoneRegex.MatchString(phone) { + return PhoneNumber{}, shared.ErrValidation + } + return PhoneNumber{value: phone}, nil +} + +func (p PhoneNumber) Value() string { + return p.value +} diff --git a/internal/domain/object-values/phone-number_test.go b/internal/domain/object-values/phone-number_test.go new file mode 100644 index 0000000..db0b26e --- /dev/null +++ b/internal/domain/object-values/phone-number_test.go @@ -0,0 +1,29 @@ +package object_values_test + +import ( + "testing" + + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" +) + +func TestNewPhoneNumber(t *testing.T) { + tests := []struct { + phone string + expectError bool + }{ + {"+14155552671", false}, // Válido: EUA + {"+5511987654321", false}, // Válido: Brasil + {"+442071838750", false}, // Válido: Reino Unido + {"5511987654321", true}, // Inválido: Sem '+' + {"+123", true}, // Inválido: Muito curto + {"+abcdefghijk", true}, // Inválido: Letras + {"+11111111111111111", true}, // Inválido: Muito longo (mais de 15 dígitos) + } + + for _, tt := range tests { + _, err := object_values.NewPhoneNumber(tt.phone) + if (err != nil) != tt.expectError { + t.Errorf("NewPhoneNumber(%s) expected error: %v, got: %v", tt.phone, tt.expectError, err) + } + } +} diff --git a/internal/domain/shared/errors.go b/internal/domain/shared/errors.go new file mode 100644 index 0000000..92201c5 --- /dev/null +++ b/internal/domain/shared/errors.go @@ -0,0 +1,12 @@ +package shared + +import "errors" + +var ( + ErrInternal = errors.New("erro interno do sistema") + + ErrNotFound = errors.New("recurso não encontrado") + ErrAuthorization = errors.New("não autorizado") + ErrValidation = errors.New("erro de validação") + ErrConflictError = errors.New("conflito de dados") +) From 1afc4bee314d33c9be3cb9d6611ebf87f0ea4dd2 Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 19:59:31 -0300 Subject: [PATCH 06/67] feat: :sparkles: ensure that create user with valid values --- internal/domain/entities/user.go | 31 +++++++++++++++++++++++++++ internal/domain/entities/user_test.go | 31 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 internal/domain/entities/user.go create mode 100644 internal/domain/entities/user_test.go diff --git a/internal/domain/entities/user.go b/internal/domain/entities/user.go new file mode 100644 index 0000000..0417d1e --- /dev/null +++ b/internal/domain/entities/user.go @@ -0,0 +1,31 @@ +package entities + +import ( + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/cassiusbessa/backend-test/internal/domain/shared" +) + +type User struct { + name string + email object_values.Email + phone object_values.PhoneNumber +} + +func NewUser(name string, email object_values.Email, phone object_values.PhoneNumber) (User, error) { + if name == "" { + return User{}, shared.ErrValidation + } + return User{name: name, email: email, phone: phone}, nil +} + +func (u User) Name() string { + return u.name +} + +func (u User) Email() object_values.Email { + return u.email +} + +func (u User) Phone() object_values.PhoneNumber { + return u.phone +} diff --git a/internal/domain/entities/user_test.go b/internal/domain/entities/user_test.go new file mode 100644 index 0000000..3b1f322 --- /dev/null +++ b/internal/domain/entities/user_test.go @@ -0,0 +1,31 @@ +package entities_test + +import ( + "testing" + + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" +) + +func TestNewUser(t *testing.T) { + + tests := []struct { + name string + email string + phone string + expectError bool + }{ + {"John Doe", "user@example.com", "+14155552671", false}, + {"", "user@example.com", "+14155552671", true}, + } + + for _, tt := range tests { + email, _ := object_values.NewEmail(tt.email) + phone, _ := object_values.NewPhoneNumber(tt.phone) + + _, err := entities.NewUser(tt.name, email, phone) + if (err != nil) != tt.expectError { + t.Errorf("NewUser(%s, %s, %s) expected error: %v, got: %v", tt.name, tt.email, tt.phone, tt.expectError, err) + } + } +} From 3cb627899145fd80b4fabe52cd5326f860a92285 Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 20:31:00 -0300 Subject: [PATCH 07/67] feat: :sparkles: ensure create password with valid values --- go.mod | 2 + go.sum | 2 + internal/domain/object-values/password.go | 32 ++++++++++++++++ .../domain/object-values/password_test.go | 37 +++++++++++++++++++ 4 files changed, 73 insertions(+) create mode 100644 go.sum create mode 100644 internal/domain/object-values/password.go create mode 100644 internal/domain/object-values/password_test.go diff --git a/go.mod b/go.mod index b2facff..8a46e49 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/cassiusbessa/backend-test go 1.24.1 + +require golang.org/x/crypto v0.36.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8228770 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= diff --git a/internal/domain/object-values/password.go b/internal/domain/object-values/password.go new file mode 100644 index 0000000..4770104 --- /dev/null +++ b/internal/domain/object-values/password.go @@ -0,0 +1,32 @@ +package object_values + +import ( + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "golang.org/x/crypto/bcrypt" +) + +type Password struct { + hash string +} + +func NewPassword(plainText string) (Password, error) { + if len(plainText) < 6 { + return Password{}, shared.ErrValidation + } + + hashed, err := bcrypt.GenerateFromPassword([]byte(plainText), bcrypt.DefaultCost) + if err != nil { + return Password{}, err + } + + return Password{hash: string(hashed)}, nil +} + +func (p Password) Hash() string { + return p.hash +} + +func (p Password) Compare(plainText string) bool { + err := bcrypt.CompareHashAndPassword([]byte(p.hash), []byte(plainText)) + return err == nil +} diff --git a/internal/domain/object-values/password_test.go b/internal/domain/object-values/password_test.go new file mode 100644 index 0000000..942d068 --- /dev/null +++ b/internal/domain/object-values/password_test.go @@ -0,0 +1,37 @@ +package object_values_test + +import ( + "testing" + + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" +) + +func TestNewPassword(t *testing.T) { + tests := []struct { + password string + expectError bool + }{ + {"password123", false}, + {"short", true}, + {"", true}, + } + + for _, tt := range tests { + _, err := object_values.NewPassword(tt.password) + if (err != nil) != tt.expectError { + t.Errorf("NewPassword(%q) expected error: %v, got: %v", tt.password, tt.expectError, err) + } + } +} + +func TestPasswordCompare(t *testing.T) { + pass, _ := object_values.NewPassword("password123") + + if !pass.Compare("password123") { + t.Errorf("Expected password to match") + } + + if pass.Compare("wrongpassword") { + t.Errorf("Expected password to NOT match") + } +} From d7e12842df79e909267cbd02ad1602cfbd76838d Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 20:32:56 -0300 Subject: [PATCH 08/67] refactor: :recycle: add password to user --- internal/domain/entities/user.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/domain/entities/user.go b/internal/domain/entities/user.go index 0417d1e..a9adf01 100644 --- a/internal/domain/entities/user.go +++ b/internal/domain/entities/user.go @@ -6,16 +6,17 @@ import ( ) type User struct { - name string - email object_values.Email - phone object_values.PhoneNumber + name string + email object_values.Email + phone object_values.PhoneNumber + password object_values.Password } -func NewUser(name string, email object_values.Email, phone object_values.PhoneNumber) (User, error) { +func NewUser(name string, email object_values.Email, hashed_pass object_values.Password, phone object_values.PhoneNumber) (User, error) { if name == "" { return User{}, shared.ErrValidation } - return User{name: name, email: email, phone: phone}, nil + return User{name: name, password: hashed_pass, email: email, phone: phone}, nil } func (u User) Name() string { @@ -26,6 +27,10 @@ func (u User) Email() object_values.Email { return u.email } +func (u User) Password() object_values.Password { + return u.password +} + func (u User) Phone() object_values.PhoneNumber { return u.phone } From 057c1acd078e811d909657f8b281184f0f7b8b21 Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 20:36:33 -0300 Subject: [PATCH 09/67] test: :white_check_mark: add password to user test --- internal/domain/entities/user_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/domain/entities/user_test.go b/internal/domain/entities/user_test.go index 3b1f322..46964a4 100644 --- a/internal/domain/entities/user_test.go +++ b/internal/domain/entities/user_test.go @@ -23,7 +23,8 @@ func TestNewUser(t *testing.T) { email, _ := object_values.NewEmail(tt.email) phone, _ := object_values.NewPhoneNumber(tt.phone) - _, err := entities.NewUser(tt.name, email, phone) + password, _ := object_values.NewPassword("defaultPassword123") + _, err := entities.NewUser(tt.name, email, password, phone) if (err != nil) != tt.expectError { t.Errorf("NewUser(%s, %s, %s) expected error: %v, got: %v", tt.name, tt.email, tt.phone, tt.expectError, err) } From f3787221a28ee7ff2d5c1939a2ab98604c54081d Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 20:59:28 -0300 Subject: [PATCH 10/67] refactor: :recycle: add uuid to entity user --- go.mod | 12 +++++++++++- go.sum | 10 ++++++++++ internal/domain/entities/user.go | 14 +++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 8a46e49..6cf1918 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,14 @@ module github.com/cassiusbessa/backend-test go 1.24.1 -require golang.org/x/crypto v0.36.0 +require ( + github.com/google/uuid v1.6.0 + golang.org/x/crypto v0.36.0 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/go.sum b/go.sum index 8228770..6fac4c4 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,12 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= diff --git a/internal/domain/entities/user.go b/internal/domain/entities/user.go index a9adf01..db69883 100644 --- a/internal/domain/entities/user.go +++ b/internal/domain/entities/user.go @@ -3,9 +3,11 @@ package entities import ( object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/google/uuid" ) type User struct { + id uuid.UUID name string email object_values.Email phone object_values.PhoneNumber @@ -16,7 +18,17 @@ func NewUser(name string, email object_values.Email, hashed_pass object_values.P if name == "" { return User{}, shared.ErrValidation } - return User{name: name, password: hashed_pass, email: email, phone: phone}, nil + return User{ + id: uuid.New(), + name: name, + email: email, + phone: phone, + password: hashed_pass, + }, nil +} + +func (u User) ID() uuid.UUID { + return u.id } func (u User) Name() string { From a84f128cf624799af93800d9a544a331343da4a8 Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 22:17:15 -0300 Subject: [PATCH 11/67] build: :hammer: dockerize app --- Dockerfile | 20 ++++++++++++++++++++ docker-compose.yml | 28 ++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2f71222 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.24.1 AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN go build -o backend-test ./cmd/main.go + +FROM gcr.io/distroless/base-debian12 + +WORKDIR /app + +COPY --from=builder /app/backend-test . + +EXPOSE 8080 + +CMD ["./backend-test"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6a000d6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +version: "3.8" + +services: + # app: + # build: . + # ports: + # - "8080:8080" + # depends_on: + # - db + # env_file: + # - .env + # restart: unless-stopped + + db: + image: postgres:latest + container_name: postgres_db + restart: always + ports: + - "5432:5432" + environment: + POSTGRES_DB: vbio + POSTGRES_USER: user + POSTGRES_PASSWORD: password + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: From c0aa7b8585da0599181abd56ecc69ed9bbdd2ed9 Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 22:17:46 -0300 Subject: [PATCH 12/67] feat: :sparkles: create data base model --- .gitignore | 1 + cmd/main.go | 32 ++++++++++ go.mod | 34 ++++++++++ go.sum | 107 ++++++++++++++++++++++++++++++++ internal/infra/db/db.go | 54 ++++++++++++++++ internal/infra/db/user-model.go | 34 ++++++++++ 6 files changed, 262 insertions(+) create mode 100644 .gitignore create mode 100644 internal/infra/db/db.go create mode 100644 internal/infra/db/user-model.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 06ab7d0..27c740e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1 +1,33 @@ package main + +import ( + "log" + "os" + + "github.com/cassiusbessa/backend-test/internal/infra/db" + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Println("No .env file found") + } + + db.Connect() + + port := os.Getenv("APP_PORT") + if port == "" { + port = "8080" + } + + router := gin.Default() + router.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + log.Printf("Server running on port %s 🚀", port) + if err := router.Run(":" + port); err != nil { + log.Fatal(err) + } +} diff --git a/go.mod b/go.mod index 6cf1918..5bf2397 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,47 @@ module github.com/cassiusbessa/backend-test go 1.24.1 require ( + github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.36.0 + gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.25.12 ) require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.5.5 // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sync v0.12.0 // indirect + golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6fac4c4..506438a 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,119 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= +gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/infra/db/db.go b/internal/infra/db/db.go new file mode 100644 index 0000000..bbe7e31 --- /dev/null +++ b/internal/infra/db/db.go @@ -0,0 +1,54 @@ +package db + +import ( + "fmt" + "log" + "os" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func Connect() { + dsn := fmt.Sprintf( + "host=%s port=%s user=%s password=%s dbname=%s sslmode=disable", + getEnv("DB_HOST", "localhost"), + getEnv("DB_PORT", "5432"), + getEnv("DB_USER", "user"), + getEnv("DB_PASSWORD", "password"), + getEnv("DB_NAME", "backend"), + ) + + var err error + DB, err = gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), // ou Info, Warn + }) + if err != nil { + log.Fatalf("❌ Failed to connect to database: %v", err) + } + + // Testa a conexão com timeout + sqlDB, err := DB.DB() + if err != nil { + log.Fatal(err) + } + sqlDB.SetConnMaxLifetime(time.Minute * 5) + + // Executa as migrations + if err := DB.AutoMigrate(&UserModel{}); err != nil { + log.Fatalf("❌ Failed to migrate database: %v", err) + } + + log.Println("✅ Connected to database and ran migrations") +} + +func getEnv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} diff --git a/internal/infra/db/user-model.go b/internal/infra/db/user-model.go new file mode 100644 index 0000000..279eebc --- /dev/null +++ b/internal/infra/db/user-model.go @@ -0,0 +1,34 @@ +package db + +import ( + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/google/uuid" +) + +type UserModel struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + Name string `gorm:"column:name;not null"` + Email string `gorm:"column:email;unique;not null"` + Phone string `gorm:"column:phone;unique;not null"` + Password string `gorm:"column:password;not null"` +} + +func (model UserModel) ToDomain() entities.User { + email, _ := object_values.NewEmail(model.Email) + phone, _ := object_values.NewPhoneNumber(model.Phone) + hashedPass, _ := object_values.NewPassword(model.Password) + + user, _ := entities.NewUser(model.Name, email, hashedPass, phone) + return user +} + +func UserToModel(user entities.User) UserModel { + return UserModel{ + ID: user.ID(), + Name: user.Name(), + Email: user.Email().Value(), + Phone: user.Phone().Value(), + Password: user.Password().Hash(), + } +} From 44857bc694124bde28b6aa19dc41e235cdf78c9a Mon Sep 17 00:00:00 2001 From: cassius Date: Thu, 3 Apr 2025 22:20:32 -0300 Subject: [PATCH 13/67] refactor: :recycle: change table name --- internal/infra/db/user-model.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/infra/db/user-model.go b/internal/infra/db/user-model.go index 279eebc..98eaeea 100644 --- a/internal/infra/db/user-model.go +++ b/internal/infra/db/user-model.go @@ -32,3 +32,7 @@ func UserToModel(user entities.User) UserModel { Password: user.Password().Hash(), } } + +func (UserModel) TableName() string { + return "users" +} From 0d1565a360cacf06ee796404b73aa3c879095188 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 02:12:19 -0300 Subject: [PATCH 14/67] feat: :sparkles: ensure that create user works with valid param and save user on not conflict email --- go.mod | 4 + go.sum | 1 + internal/application/dto/user-dto.go | 12 ++ .../repositories/user-repository.go | 8 + internal/application/use-case/create-user.go | 54 +++++++ .../application/use-case/create-user_test.go | 152 ++++++++++++++++++ 6 files changed, 231 insertions(+) create mode 100644 internal/application/dto/user-dto.go create mode 100644 internal/application/repositories/user-repository.go create mode 100644 internal/application/use-case/create-user.go create mode 100644 internal/application/use-case/create-user_test.go diff --git a/go.mod b/go.mod index 5bf2397..9c2da03 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.36.0 gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.25.12 @@ -16,6 +17,7 @@ require ( github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -36,7 +38,9 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect diff --git a/go.sum b/go.sum index 506438a..1a32fd6 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,7 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/application/dto/user-dto.go b/internal/application/dto/user-dto.go new file mode 100644 index 0000000..3312381 --- /dev/null +++ b/internal/application/dto/user-dto.go @@ -0,0 +1,12 @@ +package dto + +type CreateUserInput struct { + Name string + Email string + Password string + Phone string +} + +type CreateUserOutput struct { + UserID string +} diff --git a/internal/application/repositories/user-repository.go b/internal/application/repositories/user-repository.go new file mode 100644 index 0000000..13ae89e --- /dev/null +++ b/internal/application/repositories/user-repository.go @@ -0,0 +1,8 @@ +package repositories + +import "github.com/cassiusbessa/backend-test/internal/domain/entities" + +type UserRepository interface { + Save(user entities.User) error + FindByEmail(email string) (*entities.User, error) +} diff --git a/internal/application/use-case/create-user.go b/internal/application/use-case/create-user.go new file mode 100644 index 0000000..7612942 --- /dev/null +++ b/internal/application/use-case/create-user.go @@ -0,0 +1,54 @@ +package usecases + +import ( + "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/application/repositories" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/cassiusbessa/backend-test/internal/domain/shared" +) + +type CreateUserUseCase struct { + userRepo repositories.UserRepository +} + +func NewCreateUserUseCase(repo repositories.UserRepository) *CreateUserUseCase { + return &CreateUserUseCase{userRepo: repo} +} + +func (uc *CreateUserUseCase) Execute(input dto.CreateUserInput) (dto.CreateUserOutput, error) { + emailObj, err := object_values.NewEmail(input.Email) + if err != nil { + return dto.CreateUserOutput{}, shared.ErrValidation + } + + phoneObj, err := object_values.NewPhoneNumber(input.Phone) + if err != nil { + return dto.CreateUserOutput{}, shared.ErrValidation + } + + hashedPass, err := object_values.NewPassword(input.Password) + if err != nil { + return dto.CreateUserOutput{}, shared.ErrValidation + } + + existingUser, err := uc.userRepo.FindByEmail(emailObj.Value()) + if err != nil { + return dto.CreateUserOutput{}, shared.ErrInternal + } + if existingUser != nil { + return dto.CreateUserOutput{}, shared.ErrConflictError + } + + user, err := entities.NewUser(input.Name, emailObj, hashedPass, phoneObj) + if err != nil { + return dto.CreateUserOutput{}, err + } + + err = uc.userRepo.Save(user) + if err != nil { + return dto.CreateUserOutput{}, shared.ErrInternal + } + + return dto.CreateUserOutput{UserID: user.ID().String()}, nil +} diff --git a/internal/application/use-case/create-user_test.go b/internal/application/use-case/create-user_test.go new file mode 100644 index 0000000..5143bcd --- /dev/null +++ b/internal/application/use-case/create-user_test.go @@ -0,0 +1,152 @@ +package usecases + +import ( + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockUserRepository struct { + mock.Mock +} + +func (m *MockUserRepository) Save(user entities.User) error { + args := m.Called(user) + return args.Error(0) +} + +func (m *MockUserRepository) FindByEmail(email string) (*entities.User, error) { + args := m.Called(email) + user, _ := args.Get(0).(*entities.User) + return user, args.Error(1) +} + +func (m *MockUserRepository) SetupDefaultBehavior() { + m.On("FindByEmail", mock.Anything).Return(nil, nil) + m.On("Save", mock.Anything).Return(nil) +} + +func setupTest() (*MockUserRepository, *CreateUserUseCase) { + mockRepo := new(MockUserRepository) + mockRepo.SetupDefaultBehavior() + useCase := NewCreateUserUseCase(mockRepo) + return mockRepo, useCase +} + +func TestCreateUserUseCase_Execute_Success(t *testing.T) { + mockRepo, useCase := setupTest() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + + output, err := useCase.Execute(input) + + assert.NoError(t, err) + assert.NotEmpty(t, output.UserID) + mockRepo.AssertExpectations(t) +} + +func TestCreateUserUseCase_Execute_InvalidEmail(t *testing.T) { + _, useCase := setupTest() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "invalid-email", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + + _, err := useCase.Execute(input) + + assert.ErrorIs(t, err, shared.ErrValidation) +} + +func TestCreateUserUseCase_Execute_InvalidPhone(t *testing.T) { + _, useCase := setupTest() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "1234", + } + + _, err := useCase.Execute(input) + + assert.ErrorIs(t, err, shared.ErrValidation) +} + +func TestCreateUserUseCase_Execute_InvalidPassword(t *testing.T) { + _, useCase := setupTest() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "123", + Phone: "+5511987654321", + } + + _, err := useCase.Execute(input) + + assert.ErrorIs(t, err, shared.ErrValidation) +} + +func TestCreateUserUseCase_Execute_SaveError(t *testing.T) { + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + mockRepo, useCase := setupTest() + + mockRepo.ExpectedCalls = nil + mockRepo.On("Save", mock.Anything).Return(shared.ErrInternal) + mockRepo.On("FindByEmail", input.Email).Return(nil, nil) + + _, err := useCase.Execute(input) + + mockRepo.AssertCalled(t, "Save", mock.Anything) + assert.EqualError(t, err, shared.ErrInternal.Error()) + mockRepo.AssertExpectations(t) +} + +func TestCreateUserUseCase_Execute_UserAlreadyExists(t *testing.T) { + mockRepo, useCase := setupTest() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + + email, _ := object_values.NewEmail(input.Email) + phone, _ := object_values.NewPhoneNumber(input.Phone) + password, _ := object_values.NewPassword(input.Password) + + existingUser, _ := entities.NewUser( + "John Doe", + email, + password, + phone, + ) + + mockRepo.ExpectedCalls = nil + mockRepo.On("FindByEmail", input.Email).Return(&existingUser, nil) + + _, err := useCase.Execute(input) + + assert.ErrorIs(t, err, shared.ErrConflictError) + mockRepo.AssertNotCalled(t, "Save") + mockRepo.AssertExpectations(t) +} From ed7891f01e3cc26358af3d749ae92feffce2a945 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 02:16:51 -0300 Subject: [PATCH 15/67] feat: :sparkles: create user gorm repository --- internal/infra/db/user-repository.go | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 internal/infra/db/user-repository.go diff --git a/internal/infra/db/user-repository.go b/internal/infra/db/user-repository.go new file mode 100644 index 0000000..7bc9145 --- /dev/null +++ b/internal/infra/db/user-repository.go @@ -0,0 +1,31 @@ +package db + +import ( + "github.com/cassiusbessa/backend-test/internal/application/repositories" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + "gorm.io/gorm" +) + +type UserGormRepository struct { + db *gorm.DB +} + +func NewUserGormRepository(db *gorm.DB) repositories.UserRepository { + return &UserGormRepository{db: db} +} + +func (r *UserGormRepository) Save(user entities.User) error { + model := UserToModel(user) + return r.db.Create(&model).Error +} + +func (r *UserGormRepository) FindByEmail(email string) (*entities.User, error) { + var model UserModel + err := r.db.Where("email = ?", email).First(&model).Error + if err != nil { + return nil, err + } + + user := model.ToDomain() + return &user, nil +} From ad9e56ade6d4450c4d499e7c04904e780dec87b6 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 03:04:40 -0300 Subject: [PATCH 16/67] feat: :sparkles: add json struct mapper --- internal/application/dto/user-dto.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/application/dto/user-dto.go b/internal/application/dto/user-dto.go index 3312381..d152c4c 100644 --- a/internal/application/dto/user-dto.go +++ b/internal/application/dto/user-dto.go @@ -1,10 +1,10 @@ package dto type CreateUserInput struct { - Name string - Email string - Password string - Phone string + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + Phone string `json:"phone" binding:"required"` } type CreateUserOutput struct { From bd460a205252ac054026f2fad67675f68ae3fd8a Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 03:06:28 -0300 Subject: [PATCH 17/67] refactor: :truck: rename folder --- internal/application/{use-case => use-cases}/create-user.go | 6 +++--- .../application/{use-case => use-cases}/create-user_test.go | 0 2 files changed, 3 insertions(+), 3 deletions(-) rename internal/application/{use-case => use-cases}/create-user.go (88%) rename internal/application/{use-case => use-cases}/create-user_test.go (100%) diff --git a/internal/application/use-case/create-user.go b/internal/application/use-cases/create-user.go similarity index 88% rename from internal/application/use-case/create-user.go rename to internal/application/use-cases/create-user.go index 7612942..0180eed 100644 --- a/internal/application/use-case/create-user.go +++ b/internal/application/use-cases/create-user.go @@ -2,17 +2,17 @@ package usecases import ( "github.com/cassiusbessa/backend-test/internal/application/dto" - "github.com/cassiusbessa/backend-test/internal/application/repositories" + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" "github.com/cassiusbessa/backend-test/internal/domain/entities" object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" "github.com/cassiusbessa/backend-test/internal/domain/shared" ) type CreateUserUseCase struct { - userRepo repositories.UserRepository + userRepo output_ports.UserRepository } -func NewCreateUserUseCase(repo repositories.UserRepository) *CreateUserUseCase { +func NewCreateUserUseCase(repo output_ports.UserRepository) *CreateUserUseCase { return &CreateUserUseCase{userRepo: repo} } diff --git a/internal/application/use-case/create-user_test.go b/internal/application/use-cases/create-user_test.go similarity index 100% rename from internal/application/use-case/create-user_test.go rename to internal/application/use-cases/create-user_test.go From 89299b187048ab9a8a3e72cc5e2f810685af4339 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 03:07:54 -0300 Subject: [PATCH 18/67] refactor: :recycle: apply ports pattern --- internal/application/ports/input/create-user-usecase.go | 7 +++++++ .../{repositories => ports/output}/user-repository.go | 2 +- internal/infra/db/user-repository.go | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 internal/application/ports/input/create-user-usecase.go rename internal/application/{repositories => ports/output}/user-repository.go (90%) diff --git a/internal/application/ports/input/create-user-usecase.go b/internal/application/ports/input/create-user-usecase.go new file mode 100644 index 0000000..dff6c9f --- /dev/null +++ b/internal/application/ports/input/create-user-usecase.go @@ -0,0 +1,7 @@ +package input_ports + +import "github.com/cassiusbessa/backend-test/internal/application/dto" + +type CreateUserUseCase interface { + Execute(input dto.CreateUserInput) (dto.CreateUserOutput, error) +} diff --git a/internal/application/repositories/user-repository.go b/internal/application/ports/output/user-repository.go similarity index 90% rename from internal/application/repositories/user-repository.go rename to internal/application/ports/output/user-repository.go index 13ae89e..92d98ca 100644 --- a/internal/application/repositories/user-repository.go +++ b/internal/application/ports/output/user-repository.go @@ -1,4 +1,4 @@ -package repositories +package output_ports import "github.com/cassiusbessa/backend-test/internal/domain/entities" diff --git a/internal/infra/db/user-repository.go b/internal/infra/db/user-repository.go index 7bc9145..b627d36 100644 --- a/internal/infra/db/user-repository.go +++ b/internal/infra/db/user-repository.go @@ -1,7 +1,7 @@ package db import ( - "github.com/cassiusbessa/backend-test/internal/application/repositories" + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" "github.com/cassiusbessa/backend-test/internal/domain/entities" "gorm.io/gorm" ) @@ -10,7 +10,7 @@ type UserGormRepository struct { db *gorm.DB } -func NewUserGormRepository(db *gorm.DB) repositories.UserRepository { +func NewUserGormRepository(db *gorm.DB) output_ports.UserRepository { return &UserGormRepository{db: db} } From de3d516028feb9429314f6b4eae93f2975463a54 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 03:09:09 -0300 Subject: [PATCH 19/67] feat: :sparkles: ensure that handler works with valid input and return error in a user conflict --- .../http/handlers/create-user-handler.go | 43 +++++++++ .../http/handlers/create-user-handler_test.go | 94 +++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 internal/interfaces/http/handlers/create-user-handler.go create mode 100644 internal/interfaces/http/handlers/create-user-handler_test.go diff --git a/internal/interfaces/http/handlers/create-user-handler.go b/internal/interfaces/http/handlers/create-user-handler.go new file mode 100644 index 0000000..ffb83f8 --- /dev/null +++ b/internal/interfaces/http/handlers/create-user-handler.go @@ -0,0 +1,43 @@ +package handlers + +import ( + "net/http" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/gin-gonic/gin" +) + +type UserHandler struct { + createUserUseCase input_ports.CreateUserUseCase +} + +func NewUserHandler(createUserUseCase input_ports.CreateUserUseCase) *UserHandler { + return &UserHandler{createUserUseCase: createUserUseCase} +} + +func (h *UserHandler) CreateUser(ctx *gin.Context) { + var input dto.CreateUserInput + if err := ctx.ShouldBindJSON(&input); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + output, err := h.createUserUseCase.Execute(input) + switch err { + case shared.ErrNotFound: + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + case shared.ErrAuthorization: + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + case shared.ErrValidation: + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + case shared.ErrConflictError: + ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + case shared.ErrInternal: + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + default: + } + + ctx.JSON(http.StatusCreated, output) +} diff --git a/internal/interfaces/http/handlers/create-user-handler_test.go b/internal/interfaces/http/handlers/create-user-handler_test.go new file mode 100644 index 0000000..c4845dd --- /dev/null +++ b/internal/interfaces/http/handlers/create-user-handler_test.go @@ -0,0 +1,94 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// Mock do Use Case +type MockCreateUserUseCase struct { + mock.Mock +} + +func (m *MockCreateUserUseCase) Execute(input dto.CreateUserInput) (dto.CreateUserOutput, error) { + args := m.Called(input) + return args.Get(0).(dto.CreateUserOutput), args.Error(1) +} + +func setupTestHandler() (*MockCreateUserUseCase, *gin.Engine) { + mockUseCase := new(MockCreateUserUseCase) + handler := NewUserHandler(mockUseCase) + + router := gin.Default() + router.POST("/users", handler.CreateUser) + + return mockUseCase, router +} + +func TestCreateUser_Success(t *testing.T) { + mockUseCase, router := setupTestHandler() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + output := dto.CreateUserOutput{UserID: "123"} + + mockUseCase.On("Execute", input).Return(output, nil) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusCreated, w.Code) + mockUseCase.AssertExpectations(t) +} + +func TestCreateUser_InvalidRequestBody(t *testing.T) { + _, router := setupTestHandler() + + req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer([]byte("{invalid_json}"))) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCreateUser_Conflict(t *testing.T) { + mockUseCase, router := setupTestHandler() + + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + + mockUseCase.On("Execute", input).Return(dto.CreateUserOutput{}, shared.ErrConflictError) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) + mockUseCase.AssertExpectations(t) +} From 6c1eb6bcd338e681d643d1b379c65cb288eaee65 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 03:57:56 -0300 Subject: [PATCH 20/67] feat: :sparkles: implemente create user route --- cmd/main.go | 15 ++++++++++--- .../http/factory/handler-factory.go | 19 ++++++++++++++++ .../http/handlers/create-user-handler.go | 8 +++---- .../http/handlers/create-user-handler_test.go | 4 ++-- .../interfaces/http/routes/user-routes.go | 22 +++++++++++++++++++ 5 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 internal/interfaces/http/factory/handler-factory.go create mode 100644 internal/interfaces/http/routes/user-routes.go diff --git a/cmd/main.go b/cmd/main.go index 27c740e..fcd5ce4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,6 +5,8 @@ import ( "os" "github.com/cassiusbessa/backend-test/internal/infra/db" + "github.com/cassiusbessa/backend-test/internal/interfaces/http/factory" + "github.com/cassiusbessa/backend-test/internal/interfaces/http/routes" "github.com/gin-gonic/gin" "github.com/joho/godotenv" ) @@ -21,13 +23,20 @@ func main() { port = "8080" } - router := gin.Default() - router.GET("/health", func(c *gin.Context) { + r := gin.Default() + r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) + api := r.Group("/api") + userHandlerFactory := factory.NewUserHandlerFactory() + createUserHandler := userHandlerFactory.BuildCreateUserHandler() + + userRoutes := routes.NewUserRouterBuilder(api.Group("/users"), createUserHandler) + userRoutes.Build() + log.Printf("Server running on port %s 🚀", port) - if err := router.Run(":" + port); err != nil { + if err := r.Run(":" + port); err != nil { log.Fatal(err) } } diff --git a/internal/interfaces/http/factory/handler-factory.go b/internal/interfaces/http/factory/handler-factory.go new file mode 100644 index 0000000..8e6be12 --- /dev/null +++ b/internal/interfaces/http/factory/handler-factory.go @@ -0,0 +1,19 @@ +package factory + +import ( + usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" + "github.com/cassiusbessa/backend-test/internal/infra/db" + "github.com/cassiusbessa/backend-test/internal/interfaces/http/handlers" +) + +type UserHandlerFactory struct{} + +func NewUserHandlerFactory() *UserHandlerFactory { + return &UserHandlerFactory{} +} + +func (f *UserHandlerFactory) BuildCreateUserHandler() *handlers.CreateUserHandler { + userRepository := db.NewUserGormRepository(db.DB) + createUserUseCase := usecases.NewCreateUserUseCase(userRepository) + return handlers.NewCreateUserHandler(createUserUseCase) +} diff --git a/internal/interfaces/http/handlers/create-user-handler.go b/internal/interfaces/http/handlers/create-user-handler.go index ffb83f8..080aa97 100644 --- a/internal/interfaces/http/handlers/create-user-handler.go +++ b/internal/interfaces/http/handlers/create-user-handler.go @@ -9,15 +9,15 @@ import ( "github.com/gin-gonic/gin" ) -type UserHandler struct { +type CreateUserHandler struct { createUserUseCase input_ports.CreateUserUseCase } -func NewUserHandler(createUserUseCase input_ports.CreateUserUseCase) *UserHandler { - return &UserHandler{createUserUseCase: createUserUseCase} +func NewCreateUserHandler(createUserUseCase input_ports.CreateUserUseCase) *CreateUserHandler { + return &CreateUserHandler{createUserUseCase: createUserUseCase} } -func (h *UserHandler) CreateUser(ctx *gin.Context) { +func (h *CreateUserHandler) Execute(ctx *gin.Context) { var input dto.CreateUserInput if err := ctx.ShouldBindJSON(&input); err != nil { ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) diff --git a/internal/interfaces/http/handlers/create-user-handler_test.go b/internal/interfaces/http/handlers/create-user-handler_test.go index c4845dd..45bd2d5 100644 --- a/internal/interfaces/http/handlers/create-user-handler_test.go +++ b/internal/interfaces/http/handlers/create-user-handler_test.go @@ -26,10 +26,10 @@ func (m *MockCreateUserUseCase) Execute(input dto.CreateUserInput) (dto.CreateUs func setupTestHandler() (*MockCreateUserUseCase, *gin.Engine) { mockUseCase := new(MockCreateUserUseCase) - handler := NewUserHandler(mockUseCase) + handler := NewCreateUserHandler(mockUseCase) router := gin.Default() - router.POST("/users", handler.CreateUser) + router.POST("/users", handler.Execute) return mockUseCase, router } diff --git a/internal/interfaces/http/routes/user-routes.go b/internal/interfaces/http/routes/user-routes.go new file mode 100644 index 0000000..8e15396 --- /dev/null +++ b/internal/interfaces/http/routes/user-routes.go @@ -0,0 +1,22 @@ +package routes + +import ( + "github.com/cassiusbessa/backend-test/internal/interfaces/http/handlers" + "github.com/gin-gonic/gin" +) + +type UserRouterBuilder struct { + group *gin.RouterGroup + createUserHandler *handlers.CreateUserHandler +} + +func NewUserRouterBuilder(group *gin.RouterGroup, createUserHandler *handlers.CreateUserHandler) *UserRouterBuilder { + return &UserRouterBuilder{ + group: group, + createUserHandler: createUserHandler, + } +} + +func (b *UserRouterBuilder) Build() { + b.group.POST("/", b.createUserHandler.Execute) +} From ca961439fb9a676e09ee3a2a326f4ba589f77466 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 04:06:10 -0300 Subject: [PATCH 21/67] fix: :bug: fix error response --- internal/application/dto/user-dto.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/application/dto/user-dto.go b/internal/application/dto/user-dto.go index d152c4c..b06105d 100644 --- a/internal/application/dto/user-dto.go +++ b/internal/application/dto/user-dto.go @@ -8,5 +8,5 @@ type CreateUserInput struct { } type CreateUserOutput struct { - UserID string + UserID string `json:"user_id"` } From 585b5e63cb186e149b9984d3a410801ced484d53 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 04:06:45 -0300 Subject: [PATCH 22/67] refactor: :recycle: return nil in record not found error --- internal/infra/db/user-repository.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/infra/db/user-repository.go b/internal/infra/db/user-repository.go index b627d36..a8f7693 100644 --- a/internal/infra/db/user-repository.go +++ b/internal/infra/db/user-repository.go @@ -23,6 +23,9 @@ func (r *UserGormRepository) FindByEmail(email string) (*entities.User, error) { var model UserModel err := r.db.Where("email = ?", email).First(&model).Error if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } return nil, err } From 2347436cf708b1da804b4725a0a6e93ea707058a Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 04:07:13 -0300 Subject: [PATCH 23/67] fix: :bug: improved response in error case --- internal/interfaces/http/handlers/create-user-handler.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/internal/interfaces/http/handlers/create-user-handler.go b/internal/interfaces/http/handlers/create-user-handler.go index 080aa97..ffdf5be 100644 --- a/internal/interfaces/http/handlers/create-user-handler.go +++ b/internal/interfaces/http/handlers/create-user-handler.go @@ -28,14 +28,19 @@ func (h *CreateUserHandler) Execute(ctx *gin.Context) { switch err { case shared.ErrNotFound: ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return case shared.ErrAuthorization: ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return case shared.ErrValidation: ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return case shared.ErrConflictError: ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return case shared.ErrInternal: ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return default: } From 7e5b28dedfa159112beba4b352c7583cf0371780 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 13:56:57 -0300 Subject: [PATCH 24/67] feat: :sparkles: ensure that login works with not found user, diferrent password and correctly authentication --- internal/application/dto/user-dto.go | 9 ++ .../application/ports/input/login-usecase.go | 7 ++ .../application/ports/output/token-service.go | 6 + internal/application/use-cases/login.go | 40 ++++++ internal/application/use-cases/login_test.go | 117 ++++++++++++++++++ 5 files changed, 179 insertions(+) create mode 100644 internal/application/ports/input/login-usecase.go create mode 100644 internal/application/ports/output/token-service.go create mode 100644 internal/application/use-cases/login.go create mode 100644 internal/application/use-cases/login_test.go diff --git a/internal/application/dto/user-dto.go b/internal/application/dto/user-dto.go index b06105d..8846dfe 100644 --- a/internal/application/dto/user-dto.go +++ b/internal/application/dto/user-dto.go @@ -10,3 +10,12 @@ type CreateUserInput struct { type CreateUserOutput struct { UserID string `json:"user_id"` } + +type LoginInput struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +type LoginOutput struct { + Token string `json:"token"` +} diff --git a/internal/application/ports/input/login-usecase.go b/internal/application/ports/input/login-usecase.go new file mode 100644 index 0000000..2fdeb84 --- /dev/null +++ b/internal/application/ports/input/login-usecase.go @@ -0,0 +1,7 @@ +package input_ports + +import "github.com/cassiusbessa/backend-test/internal/application/dto" + +type LoginUseCase interface { + Execute(input dto.LoginInput) (dto.LoginOutput, error) +} diff --git a/internal/application/ports/output/token-service.go b/internal/application/ports/output/token-service.go new file mode 100644 index 0000000..eed3c16 --- /dev/null +++ b/internal/application/ports/output/token-service.go @@ -0,0 +1,6 @@ +package output_ports + +type TokenService interface { + GenerateToken(userID string) (string, error) + ValidateToken(token string) (string, error) +} diff --git a/internal/application/use-cases/login.go b/internal/application/use-cases/login.go new file mode 100644 index 0000000..503ee06 --- /dev/null +++ b/internal/application/use-cases/login.go @@ -0,0 +1,40 @@ +package usecases + +import ( + "github.com/cassiusbessa/backend-test/internal/application/dto" + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" + "github.com/cassiusbessa/backend-test/internal/domain/shared" +) + +type LoginUserUseCase struct { + userRepo output_ports.UserRepository + tokenService output_ports.TokenService +} + +func NewLoginUserUseCase( + userRepo output_ports.UserRepository, + tokenService output_ports.TokenService, +) *LoginUserUseCase { + return &LoginUserUseCase{ + userRepo: userRepo, + tokenService: tokenService, + } +} + +func (uc *LoginUserUseCase) Execute(input dto.LoginInput) (dto.LoginOutput, error) { + user, err := uc.userRepo.FindByEmail(input.Email) + if err != nil { + return dto.LoginOutput{}, shared.ErrNotFound + } + + if !user.Password().Compare(input.Password) { + return dto.LoginOutput{}, shared.ErrAuthorization + } + + token, err := uc.tokenService.GenerateToken(user.ID().String()) + if err != nil { + return dto.LoginOutput{}, shared.ErrInternal + } + + return dto.LoginOutput{Token: token}, nil +} diff --git a/internal/application/use-cases/login_test.go b/internal/application/use-cases/login_test.go new file mode 100644 index 0000000..b89b0a3 --- /dev/null +++ b/internal/application/use-cases/login_test.go @@ -0,0 +1,117 @@ +package usecases_test + +import ( + "errors" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// --- Mocks --- + +type MockUserRepository struct { + mock.Mock +} + +func (m *MockUserRepository) FindByEmail(email string) (*entities.User, error) { + args := m.Called(email) + if user := args.Get(0); user != nil { + return user.(*entities.User), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockUserRepository) Save(user entities.User) error { + args := m.Called(user) + if err := args.Error(0); err != nil { + return err + } + return nil +} + +type MockTokenService struct { + mock.Mock +} + +func (m *MockTokenService) GenerateToken(userID string) (string, error) { + args := m.Called(userID) + return args.String(0), args.Error(1) +} + +func (m *MockTokenService) ValidateToken(token string) (string, error) { + args := m.Called(token) + return args.String(0), args.Error(1) +} + +func TestLoginUserUseCase_Execute(t *testing.T) { + mockRepo := new(MockUserRepository) + mockTokenService := new(MockTokenService) + + useCase := usecases.NewLoginUserUseCase(mockRepo, mockTokenService) + + t.Run("success", func(t *testing.T) { + + password, _ := object_values.NewPassword("123456") + email, _ := object_values.NewEmail("test@example.com") + phoneNumber, _ := object_values.NewPhoneNumber("1234567890") + + user, _ := entities.NewUser("teste", email, password, phoneNumber) + + input := dto.LoginInput{Email: "test@example.com", Password: "123456"} + expectedToken := "fake.jwt.token" + + mockRepo.On("FindByEmail", input.Email).Return(&user, nil) + mockTokenService.On("GenerateToken", user.ID().String()).Return(expectedToken, nil) + + output, err := useCase.Execute(input) + + assert.NoError(t, err) + assert.Equal(t, expectedToken, output.Token) + }) + + t.Run("user not found", func(t *testing.T) { + input := dto.LoginInput{Email: "missing@example.com", Password: "any"} + + mockRepo.On("FindByEmail", input.Email).Return(nil, errors.New("not found")) + + _, err := useCase.Execute(input) + assert.ErrorIs(t, err, shared.ErrNotFound) + }) + + t.Run("invalid password", func(t *testing.T) { + email, _ := object_values.NewEmail("user@example.com") + password, _ := object_values.NewPassword("correct-password") + phone, _ := object_values.NewPhoneNumber("1234567890") + + user, _ := entities.NewUser("User", email, password, phone) + + input := dto.LoginInput{Email: "user@example.com", Password: "wrong-password"} + + mockRepo.On("FindByEmail", input.Email).Return(&user, nil) + + _, err := useCase.Execute(input) + assert.ErrorIs(t, err, shared.ErrAuthorization) + }) + + t.Run("token generation error", func(t *testing.T) { + email, _ := object_values.NewEmail("user2@example.com") + password, _ := object_values.NewPassword("pass123") + phone, _ := object_values.NewPhoneNumber("1234567890") + + user, _ := entities.NewUser("User", email, password, phone) + + input := dto.LoginInput{Email: "user2@example.com", Password: "pass123"} + + mockRepo.On("FindByEmail", input.Email).Return(&user, nil) + mockTokenService.On("GenerateToken", user.ID().String()).Return("", errors.New("token error")) + + _, err := useCase.Execute(input) + assert.ErrorIs(t, err, shared.ErrInternal) + }) +} From 7e562bce233295b8aca349ffc6fe20d8c3042d71 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 13:57:40 -0300 Subject: [PATCH 25/67] feat: :sparkles: implement jwt token --- go.mod | 1 + go.sum | 2 ++ internal/infra/token/jwt.go | 43 +++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 internal/infra/token/jwt.go diff --git a/go.mod b/go.mod index 9c2da03..c987795 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.24.1 require ( github.com/gin-gonic/gin v1.10.0 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.9.0 diff --git a/go.sum b/go.sum index 1a32fd6..a481fe8 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/internal/infra/token/jwt.go b/internal/infra/token/jwt.go new file mode 100644 index 0000000..717c3ab --- /dev/null +++ b/internal/infra/token/jwt.go @@ -0,0 +1,43 @@ +package token + +import ( + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type JWTService struct { + secretKey string + expiry time.Duration +} + +func NewJWTService(secretKey string, expiry time.Duration) *JWTService { + return &JWTService{secretKey: secretKey, expiry: expiry} +} + +func (s *JWTService) GenerateToken(userID string) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(s.expiry).Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.secretKey)) +} + +func (s *JWTService) ValidateToken(tokenStr string) (string, error) { + token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + return []byte(s.secretKey), nil + }) + if err != nil || !token.Valid { + return "", err + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "", err + } + userID, ok := claims["user_id"].(string) + if !ok { + return "", err + } + return userID, nil +} From 787f2524b60120f7c5f845b91b71674a46c2b318 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 14:26:56 -0300 Subject: [PATCH 26/67] test: :recycle: refactor tests with mocks --- .../application/use-cases/create-user_test.go | 194 ++++++++---------- internal/application/use-cases/login_test.go | 46 +---- .../use-cases/mocks/token-service.go | 21 ++ .../use-cases/mocks/user-repository.go | 30 +++ 4 files changed, 144 insertions(+), 147 deletions(-) create mode 100644 internal/application/use-cases/mocks/token-service.go create mode 100644 internal/application/use-cases/mocks/user-repository.go diff --git a/internal/application/use-cases/create-user_test.go b/internal/application/use-cases/create-user_test.go index 5143bcd..f87d332 100644 --- a/internal/application/use-cases/create-user_test.go +++ b/internal/application/use-cases/create-user_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/application/use-cases/mocks" "github.com/cassiusbessa/backend-test/internal/domain/entities" object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" "github.com/cassiusbessa/backend-test/internal/domain/shared" @@ -11,142 +12,119 @@ import ( "github.com/stretchr/testify/mock" ) -type MockUserRepository struct { - mock.Mock -} - -func (m *MockUserRepository) Save(user entities.User) error { - args := m.Called(user) - return args.Error(0) -} - -func (m *MockUserRepository) FindByEmail(email string) (*entities.User, error) { - args := m.Called(email) - user, _ := args.Get(0).(*entities.User) - return user, args.Error(1) -} - -func (m *MockUserRepository) SetupDefaultBehavior() { - m.On("FindByEmail", mock.Anything).Return(nil, nil) - m.On("Save", mock.Anything).Return(nil) -} - -func setupTest() (*MockUserRepository, *CreateUserUseCase) { - mockRepo := new(MockUserRepository) - mockRepo.SetupDefaultBehavior() +func setupTest() (*mocks.MockUserRepository, *CreateUserUseCase) { + mockRepo := mocks.NewMockUserRepository() useCase := NewCreateUserUseCase(mockRepo) return mockRepo, useCase } -func TestCreateUserUseCase_Execute_Success(t *testing.T) { - mockRepo, useCase := setupTest() +func TestCreateUserUseCase_Execute(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockRepo, useCase := setupTest() - input := dto.CreateUserInput{ - Name: "John Doe", - Email: "john@example.com", - Password: "StrongP@ssw0rd", - Phone: "+5511987654321", - } + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } - output, err := useCase.Execute(input) + mockRepo.On("FindByEmail", input.Email).Return(nil, nil) + mockRepo.On("Save", mock.Anything).Return(nil) - assert.NoError(t, err) - assert.NotEmpty(t, output.UserID) - mockRepo.AssertExpectations(t) -} + output, err := useCase.Execute(input) -func TestCreateUserUseCase_Execute_InvalidEmail(t *testing.T) { - _, useCase := setupTest() + assert.NoError(t, err) + assert.NotEmpty(t, output.UserID) + mockRepo.AssertExpectations(t) + }) - input := dto.CreateUserInput{ - Name: "John Doe", - Email: "invalid-email", - Password: "StrongP@ssw0rd", - Phone: "+5511987654321", - } + t.Run("invalid email", func(t *testing.T) { + _, useCase := setupTest() - _, err := useCase.Execute(input) + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "invalid-email", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } - assert.ErrorIs(t, err, shared.ErrValidation) -} + _, err := useCase.Execute(input) -func TestCreateUserUseCase_Execute_InvalidPhone(t *testing.T) { - _, useCase := setupTest() + assert.ErrorIs(t, err, shared.ErrValidation) + }) - input := dto.CreateUserInput{ - Name: "John Doe", - Email: "john@example.com", - Password: "StrongP@ssw0rd", - Phone: "1234", - } + t.Run("invalid phone", func(t *testing.T) { + _, useCase := setupTest() - _, err := useCase.Execute(input) + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "1234", + } - assert.ErrorIs(t, err, shared.ErrValidation) -} + _, err := useCase.Execute(input) -func TestCreateUserUseCase_Execute_InvalidPassword(t *testing.T) { - _, useCase := setupTest() + assert.ErrorIs(t, err, shared.ErrValidation) + }) - input := dto.CreateUserInput{ - Name: "John Doe", - Email: "john@example.com", - Password: "123", - Phone: "+5511987654321", - } + t.Run("invalid password", func(t *testing.T) { + _, useCase := setupTest() - _, err := useCase.Execute(input) + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "123", + Phone: "+5511987654321", + } - assert.ErrorIs(t, err, shared.ErrValidation) -} + _, err := useCase.Execute(input) -func TestCreateUserUseCase_Execute_SaveError(t *testing.T) { - input := dto.CreateUserInput{ - Name: "John Doe", - Email: "john@example.com", - Password: "StrongP@ssw0rd", - Phone: "+5511987654321", - } - mockRepo, useCase := setupTest() + assert.ErrorIs(t, err, shared.ErrValidation) + }) - mockRepo.ExpectedCalls = nil - mockRepo.On("Save", mock.Anything).Return(shared.ErrInternal) - mockRepo.On("FindByEmail", input.Email).Return(nil, nil) + t.Run("user already exists", func(t *testing.T) { + mockRepo, useCase := setupTest() - _, err := useCase.Execute(input) + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } - mockRepo.AssertCalled(t, "Save", mock.Anything) - assert.EqualError(t, err, shared.ErrInternal.Error()) - mockRepo.AssertExpectations(t) -} + email, _ := object_values.NewEmail(input.Email) + phone, _ := object_values.NewPhoneNumber(input.Phone) + password, _ := object_values.NewPassword(input.Password) + + existingUser, _ := entities.NewUser("John Doe", email, password, phone) + + mockRepo.On("FindByEmail", input.Email).Return(&existingUser, nil) -func TestCreateUserUseCase_Execute_UserAlreadyExists(t *testing.T) { - mockRepo, useCase := setupTest() + _, err := useCase.Execute(input) - input := dto.CreateUserInput{ - Name: "John Doe", - Email: "john@example.com", - Password: "StrongP@ssw0rd", - Phone: "+5511987654321", - } + assert.ErrorIs(t, err, shared.ErrConflictError) + mockRepo.AssertNotCalled(t, "Save") + mockRepo.AssertExpectations(t) + }) - email, _ := object_values.NewEmail(input.Email) - phone, _ := object_values.NewPhoneNumber(input.Phone) - password, _ := object_values.NewPassword(input.Password) + t.Run("repository save error", func(t *testing.T) { + mockRepo, useCase := setupTest() - existingUser, _ := entities.NewUser( - "John Doe", - email, - password, - phone, - ) + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } - mockRepo.ExpectedCalls = nil - mockRepo.On("FindByEmail", input.Email).Return(&existingUser, nil) + mockRepo.On("FindByEmail", input.Email).Return(nil, nil) + mockRepo.On("Save", mock.Anything).Return(shared.ErrInternal) - _, err := useCase.Execute(input) + _, err := useCase.Execute(input) - assert.ErrorIs(t, err, shared.ErrConflictError) - mockRepo.AssertNotCalled(t, "Save") - mockRepo.AssertExpectations(t) + assert.EqualError(t, err, shared.ErrInternal.Error()) + mockRepo.AssertExpectations(t) + }) } diff --git a/internal/application/use-cases/login_test.go b/internal/application/use-cases/login_test.go index b89b0a3..a13a2f8 100644 --- a/internal/application/use-cases/login_test.go +++ b/internal/application/use-cases/login_test.go @@ -6,54 +6,22 @@ import ( "github.com/cassiusbessa/backend-test/internal/application/dto" usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" + "github.com/cassiusbessa/backend-test/internal/application/use-cases/mocks" "github.com/cassiusbessa/backend-test/internal/domain/entities" object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" "github.com/cassiusbessa/backend-test/internal/domain/shared" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" ) -// --- Mocks --- - -type MockUserRepository struct { - mock.Mock -} - -func (m *MockUserRepository) FindByEmail(email string) (*entities.User, error) { - args := m.Called(email) - if user := args.Get(0); user != nil { - return user.(*entities.User), args.Error(1) - } - return nil, args.Error(1) -} - -func (m *MockUserRepository) Save(user entities.User) error { - args := m.Called(user) - if err := args.Error(0); err != nil { - return err - } - return nil -} - -type MockTokenService struct { - mock.Mock -} - -func (m *MockTokenService) GenerateToken(userID string) (string, error) { - args := m.Called(userID) - return args.String(0), args.Error(1) -} - -func (m *MockTokenService) ValidateToken(token string) (string, error) { - args := m.Called(token) - return args.String(0), args.Error(1) +func setupTest() (*mocks.MockUserRepository, *mocks.MockTokenService, *usecases.LoginUserUseCase) { + mockRepo := mocks.NewMockUserRepository() + mockTokenService := mocks.NewMockTokenService() + useCase := usecases.NewLoginUserUseCase(mockRepo, mockTokenService) + return mockRepo, mockTokenService, useCase } func TestLoginUserUseCase_Execute(t *testing.T) { - mockRepo := new(MockUserRepository) - mockTokenService := new(MockTokenService) - - useCase := usecases.NewLoginUserUseCase(mockRepo, mockTokenService) + mockRepo, mockTokenService, useCase := setupTest() t.Run("success", func(t *testing.T) { diff --git a/internal/application/use-cases/mocks/token-service.go b/internal/application/use-cases/mocks/token-service.go new file mode 100644 index 0000000..8e6bfb9 --- /dev/null +++ b/internal/application/use-cases/mocks/token-service.go @@ -0,0 +1,21 @@ +package mocks + +import "github.com/stretchr/testify/mock" + +type MockTokenService struct { + mock.Mock +} + +func (m *MockTokenService) GenerateToken(userID string) (string, error) { + args := m.Called(userID) + return args.String(0), args.Error(1) +} + +func (m *MockTokenService) ValidateToken(token string) (string, error) { + args := m.Called(token) + return args.String(0), args.Error(1) +} + +func NewMockTokenService() *MockTokenService { + return new(MockTokenService) +} diff --git a/internal/application/use-cases/mocks/user-repository.go b/internal/application/use-cases/mocks/user-repository.go new file mode 100644 index 0000000..00b46c1 --- /dev/null +++ b/internal/application/use-cases/mocks/user-repository.go @@ -0,0 +1,30 @@ +package mocks + +import ( + "github.com/cassiusbessa/backend-test/internal/domain/entities" + "github.com/stretchr/testify/mock" +) + +type MockUserRepository struct { + mock.Mock +} + +func (m *MockUserRepository) FindByEmail(email string) (*entities.User, error) { + args := m.Called(email) + if user := args.Get(0); user != nil { + return user.(*entities.User), args.Error(1) + } + return nil, args.Error(1) +} + +func (m *MockUserRepository) Save(user entities.User) error { + args := m.Called(user) + if err := args.Error(0); err != nil { + return err + } + return nil +} + +func NewMockUserRepository() *MockUserRepository { + return new(MockUserRepository) +} From cc3b0983a713117d813b488248195b93fe32c68c Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 14:49:06 -0300 Subject: [PATCH 27/67] test: :recycle: refactor create user handler test --- .../http/handlers/create-user-handler_test.go | 86 ++++++++++--------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/internal/interfaces/http/handlers/create-user-handler_test.go b/internal/interfaces/http/handlers/create-user-handler_test.go index 45bd2d5..94a63d4 100644 --- a/internal/interfaces/http/handlers/create-user-handler_test.go +++ b/internal/interfaces/http/handlers/create-user-handler_test.go @@ -34,61 +34,63 @@ func setupTestHandler() (*MockCreateUserUseCase, *gin.Engine) { return mockUseCase, router } -func TestCreateUser_Success(t *testing.T) { - mockUseCase, router := setupTestHandler() +func TestCreateUserHandler_Execute(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockUseCase, router := setupTestHandler() - input := dto.CreateUserInput{ - Name: "John Doe", - Email: "john@example.com", - Password: "StrongP@ssw0rd", - Phone: "+5511987654321", - } - output := dto.CreateUserOutput{UserID: "123"} + input := dto.CreateUserInput{ + Name: "John Doe", + Email: "john@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511987654321", + } + output := dto.CreateUserOutput{UserID: "123"} - mockUseCase.On("Execute", input).Return(output, nil) + mockUseCase.On("Execute", input).Return(output, nil) - body, _ := json.Marshal(input) - req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) - assert.Equal(t, http.StatusCreated, w.Code) - mockUseCase.AssertExpectations(t) -} + assert.Equal(t, http.StatusCreated, w.Code) + mockUseCase.AssertExpectations(t) + }) -func TestCreateUser_InvalidRequestBody(t *testing.T) { - _, router := setupTestHandler() + t.Run("invalid request body", func(t *testing.T) { + _, router := setupTestHandler() - req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer([]byte("{invalid_json}"))) - req.Header.Set("Content-Type", "application/json") + req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer([]byte("{invalid_json}"))) + req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) - assert.Equal(t, http.StatusBadRequest, w.Code) -} + assert.Equal(t, http.StatusBadRequest, w.Code) + }) -func TestCreateUser_Conflict(t *testing.T) { - mockUseCase, router := setupTestHandler() + t.Run("conflict error", func(t *testing.T) { + mockUseCase, router := setupTestHandler() - input := dto.CreateUserInput{ - Name: "John Doe", - Email: "john@example.com", - Password: "StrongP@ssw0rd", - Phone: "+5511987654321", - } + input := dto.CreateUserInput{ + Name: "Jane Doe", + Email: "jane@example.com", + Password: "StrongP@ss123", + Phone: "+5511981234567", + } - mockUseCase.On("Execute", input).Return(dto.CreateUserOutput{}, shared.ErrConflictError) + mockUseCase.On("Execute", input).Return(dto.CreateUserOutput{}, shared.ErrConflictError) - body, _ := json.Marshal(input) - req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body)) - req.Header.Set("Content-Type", "application/json") + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - router.ServeHTTP(w, req) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) - assert.Equal(t, http.StatusConflict, w.Code) - mockUseCase.AssertExpectations(t) + assert.Equal(t, http.StatusConflict, w.Code) + mockUseCase.AssertExpectations(t) + }) } From 258c680adf07ea6609482f220db3e3e811bfe077 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 15:13:01 -0300 Subject: [PATCH 28/67] refactor: :art: remove comments --- internal/interfaces/http/handlers/create-user-handler_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/interfaces/http/handlers/create-user-handler_test.go b/internal/interfaces/http/handlers/create-user-handler_test.go index 94a63d4..099acbd 100644 --- a/internal/interfaces/http/handlers/create-user-handler_test.go +++ b/internal/interfaces/http/handlers/create-user-handler_test.go @@ -14,7 +14,6 @@ import ( "github.com/stretchr/testify/mock" ) -// Mock do Use Case type MockCreateUserUseCase struct { mock.Mock } From d1cc3b7303c137dfa326262c941db6dcede5c87f Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 15:14:26 -0300 Subject: [PATCH 29/67] feat: :sparkles: ensure login return error in invalid body, not found user and wrong password --- .../interfaces/http/handlers/login-handler.go | 57 +++++++ .../http/handlers/login-handler_test.go | 153 ++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 internal/interfaces/http/handlers/login-handler.go create mode 100644 internal/interfaces/http/handlers/login-handler_test.go diff --git a/internal/interfaces/http/handlers/login-handler.go b/internal/interfaces/http/handlers/login-handler.go new file mode 100644 index 0000000..ec21e62 --- /dev/null +++ b/internal/interfaces/http/handlers/login-handler.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "fmt" + "net/http" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/gin-gonic/gin" +) + +type LoginHandler struct { + loginUserUseCase input_ports.LoginUseCase +} + +func NewLoginHandler(loginUserCase input_ports.LoginUseCase) *LoginHandler { + return &LoginHandler{ + loginUserUseCase: loginUserCase, + } +} + +func (h *LoginHandler) Execute(ctx *gin.Context) { + var input dto.LoginInput + + if err := ctx.ShouldBindJSON(&input); err != nil { + fmt.Println(err) + ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + output, err := h.loginUserUseCase.Execute(input) + if err != nil { + switch err { + case shared.ErrNotFound: + ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + case shared.ErrAuthorization: + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + case shared.ErrValidation: + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + case shared.ErrConflictError: + ctx.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + case shared.ErrInternal: + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + default: + } + } + + ctx.JSON(http.StatusOK, gin.H{ + "token": output.Token, + }) +} diff --git a/internal/interfaces/http/handlers/login-handler_test.go b/internal/interfaces/http/handlers/login-handler_test.go new file mode 100644 index 0000000..c5fd954 --- /dev/null +++ b/internal/interfaces/http/handlers/login-handler_test.go @@ -0,0 +1,153 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type MockLoginUserUseCase struct { + mock.Mock +} + +func (m *MockLoginUserUseCase) Execute(input dto.LoginInput) (dto.LoginOutput, error) { + args := m.Called(input) + return args.Get(0).(dto.LoginOutput), args.Error(1) +} + +func setupLoginHandlerTest() (*MockLoginUserUseCase, *gin.Engine) { + mockUseCase := new(MockLoginUserUseCase) + handler := NewLoginHandler(mockUseCase) + + r := gin.Default() + r.POST("/login", handler.Execute) + + return mockUseCase, r +} + +func TestUserHandler_Login(t *testing.T) { + t.Run("success", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "john@example.com", Password: "StrongP@ssw0rd"} + expected := dto.LoginOutput{Token: "fake.jwt.token"} + + mockUseCase.On("Execute", input).Return(expected, nil) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), expected.Token) + mockUseCase.AssertExpectations(t) + }) + + t.Run("invalid request body", func(t *testing.T) { + _, router := setupLoginHandlerTest() + + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer([]byte("invalid_json"))) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid request body") + }) + + t.Run("not found error", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "missing@example.com", Password: "123"} + mockUseCase.On("Execute", input).Return(dto.LoginOutput{}, shared.ErrNotFound) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusNotFound, w.Code) + assert.Contains(t, w.Body.String(), shared.ErrNotFound.Error()) + }) + + t.Run("unauthorized error", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "user@example.com", Password: "wrong"} + mockUseCase.On("Execute", input).Return(dto.LoginOutput{}, shared.ErrAuthorization) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Contains(t, w.Body.String(), shared.ErrAuthorization.Error()) + }) + + t.Run("empty params", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "", Password: ""} + mockUseCase.On("Execute", input).Return(dto.LoginOutput{}, shared.ErrValidation) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "Invalid request body") + }) + + t.Run("internal server error", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "user@example.com", Password: "123456"} + mockUseCase.On("Execute", input).Return(dto.LoginOutput{}, shared.ErrInternal) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + assert.Contains(t, w.Body.String(), shared.ErrInternal.Error()) + }) + + t.Run("unexpected error - fallback", func(t *testing.T) { + mockUseCase, router := setupLoginHandlerTest() + + input := dto.LoginInput{Email: "user@example.com", Password: "123456"} + mockUseCase.On("Execute", input).Return(dto.LoginOutput{}, errors.New("unknown error")) + + body, _ := json.Marshal(input) + req, _ := http.NewRequest("POST", "/login", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + }) +} From da12436b3d0192c20888cd754c104a24dd9e2eff Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 15:57:22 -0300 Subject: [PATCH 30/67] fix: :bug: no hash password twice --- internal/infra/db/user-model.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/infra/db/user-model.go b/internal/infra/db/user-model.go index 98eaeea..6e47899 100644 --- a/internal/infra/db/user-model.go +++ b/internal/infra/db/user-model.go @@ -17,7 +17,7 @@ type UserModel struct { func (model UserModel) ToDomain() entities.User { email, _ := object_values.NewEmail(model.Email) phone, _ := object_values.NewPhoneNumber(model.Phone) - hashedPass, _ := object_values.NewPassword(model.Password) + hashedPass := object_values.NewPasswordFromHash(model.Password) user, _ := entities.NewUser(model.Name, email, hashedPass, phone) return user From be204e4cd5ac0a38203c0b8c9882cd95564d732c Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 15:57:43 -0300 Subject: [PATCH 31/67] feat: :sparkles: create login route --- cmd/main.go | 8 ++----- internal/domain/object-values/password.go | 4 ++++ .../http/factory/handler-factory.go | 24 +++++++++++++------ .../interfaces/http/handlers/login-handler.go | 2 -- internal/interfaces/http/routes/routes.go | 16 +++++++++++++ .../interfaces/http/routes/user-routes.go | 22 ----------------- 6 files changed, 39 insertions(+), 37 deletions(-) create mode 100644 internal/interfaces/http/routes/routes.go delete mode 100644 internal/interfaces/http/routes/user-routes.go diff --git a/cmd/main.go b/cmd/main.go index fcd5ce4..08f9f50 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -5,7 +5,6 @@ import ( "os" "github.com/cassiusbessa/backend-test/internal/infra/db" - "github.com/cassiusbessa/backend-test/internal/interfaces/http/factory" "github.com/cassiusbessa/backend-test/internal/interfaces/http/routes" "github.com/gin-gonic/gin" "github.com/joho/godotenv" @@ -29,11 +28,8 @@ func main() { }) api := r.Group("/api") - userHandlerFactory := factory.NewUserHandlerFactory() - createUserHandler := userHandlerFactory.BuildCreateUserHandler() - - userRoutes := routes.NewUserRouterBuilder(api.Group("/users"), createUserHandler) - userRoutes.Build() + routes.WithCreateUser(api) + routes.WithLogin(api) log.Printf("Server running on port %s 🚀", port) if err := r.Run(":" + port); err != nil { diff --git a/internal/domain/object-values/password.go b/internal/domain/object-values/password.go index 4770104..6cd56cb 100644 --- a/internal/domain/object-values/password.go +++ b/internal/domain/object-values/password.go @@ -22,6 +22,10 @@ func NewPassword(plainText string) (Password, error) { return Password{hash: string(hashed)}, nil } +func NewPasswordFromHash(hash string) Password { + return Password{hash: hash} +} + func (p Password) Hash() string { return p.hash } diff --git a/internal/interfaces/http/factory/handler-factory.go b/internal/interfaces/http/factory/handler-factory.go index 8e6be12..661101d 100644 --- a/internal/interfaces/http/factory/handler-factory.go +++ b/internal/interfaces/http/factory/handler-factory.go @@ -1,19 +1,29 @@ package factory import ( + "os" + "time" + usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" "github.com/cassiusbessa/backend-test/internal/infra/db" + "github.com/cassiusbessa/backend-test/internal/infra/token" "github.com/cassiusbessa/backend-test/internal/interfaces/http/handlers" ) -type UserHandlerFactory struct{} - -func NewUserHandlerFactory() *UserHandlerFactory { - return &UserHandlerFactory{} -} - -func (f *UserHandlerFactory) BuildCreateUserHandler() *handlers.CreateUserHandler { +func BuildCreateUserHandler() *handlers.CreateUserHandler { userRepository := db.NewUserGormRepository(db.DB) createUserUseCase := usecases.NewCreateUserUseCase(userRepository) return handlers.NewCreateUserHandler(createUserUseCase) } + +func BuildLoginHandler() *handlers.LoginHandler { + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + jwtSecret = "segredo" + } + weekDuration := time.Hour * 24 * 7 + userRepository := db.NewUserGormRepository(db.DB) + tokenService := token.NewJWTService(jwtSecret, weekDuration) + loginUseCase := usecases.NewLoginUserUseCase(userRepository, tokenService) + return handlers.NewLoginHandler(loginUseCase) +} diff --git a/internal/interfaces/http/handlers/login-handler.go b/internal/interfaces/http/handlers/login-handler.go index ec21e62..7d11b1d 100644 --- a/internal/interfaces/http/handlers/login-handler.go +++ b/internal/interfaces/http/handlers/login-handler.go @@ -1,7 +1,6 @@ package handlers import ( - "fmt" "net/http" "github.com/cassiusbessa/backend-test/internal/application/dto" @@ -24,7 +23,6 @@ func (h *LoginHandler) Execute(ctx *gin.Context) { var input dto.LoginInput if err := ctx.ShouldBindJSON(&input); err != nil { - fmt.Println(err) ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) return } diff --git a/internal/interfaces/http/routes/routes.go b/internal/interfaces/http/routes/routes.go new file mode 100644 index 0000000..4ef0f04 --- /dev/null +++ b/internal/interfaces/http/routes/routes.go @@ -0,0 +1,16 @@ +package routes + +import ( + "github.com/cassiusbessa/backend-test/internal/interfaces/http/factory" + "github.com/gin-gonic/gin" +) + +func WithCreateUser(g *gin.RouterGroup) { + handler := factory.BuildCreateUserHandler() + g.POST("/users", handler.Execute) +} + +func WithLogin(g *gin.RouterGroup) { + handler := factory.BuildLoginHandler() + g.POST("/login", handler.Execute) +} diff --git a/internal/interfaces/http/routes/user-routes.go b/internal/interfaces/http/routes/user-routes.go deleted file mode 100644 index 8e15396..0000000 --- a/internal/interfaces/http/routes/user-routes.go +++ /dev/null @@ -1,22 +0,0 @@ -package routes - -import ( - "github.com/cassiusbessa/backend-test/internal/interfaces/http/handlers" - "github.com/gin-gonic/gin" -) - -type UserRouterBuilder struct { - group *gin.RouterGroup - createUserHandler *handlers.CreateUserHandler -} - -func NewUserRouterBuilder(group *gin.RouterGroup, createUserHandler *handlers.CreateUserHandler) *UserRouterBuilder { - return &UserRouterBuilder{ - group: group, - createUserHandler: createUserHandler, - } -} - -func (b *UserRouterBuilder) Build() { - b.group.POST("/", b.createUserHandler.Execute) -} From 9d8789a3f1216ffd9ffc8b1f9f51cef1a4f4f5b0 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 17:19:12 -0300 Subject: [PATCH 32/67] feat: :sparkles: ensure that return error when invalid token or not found user and a user if valid token --- .../ports/input/load-user-by-token.go | 7 ++ .../ports/output/user-repository.go | 1 + .../use-cases/load-user-by-token.go | 35 ++++++++ .../use-cases/load-user-by-token_test.go | 84 +++++++++++++++++++ .../use-cases/mocks/user-repository.go | 8 ++ internal/infra/db/user-repository.go | 14 ++++ 6 files changed, 149 insertions(+) create mode 100644 internal/application/ports/input/load-user-by-token.go create mode 100644 internal/application/use-cases/load-user-by-token.go create mode 100644 internal/application/use-cases/load-user-by-token_test.go diff --git a/internal/application/ports/input/load-user-by-token.go b/internal/application/ports/input/load-user-by-token.go new file mode 100644 index 0000000..b2cdd85 --- /dev/null +++ b/internal/application/ports/input/load-user-by-token.go @@ -0,0 +1,7 @@ +package input_ports + +import "github.com/cassiusbessa/backend-test/internal/domain/entities" + +type LoadUserByTokenUseCase interface { + Execute(token string) (entities.User, error) +} diff --git a/internal/application/ports/output/user-repository.go b/internal/application/ports/output/user-repository.go index 92d98ca..587ef85 100644 --- a/internal/application/ports/output/user-repository.go +++ b/internal/application/ports/output/user-repository.go @@ -5,4 +5,5 @@ import "github.com/cassiusbessa/backend-test/internal/domain/entities" type UserRepository interface { Save(user entities.User) error FindByEmail(email string) (*entities.User, error) + FindByID(id string) (*entities.User, error) } diff --git a/internal/application/use-cases/load-user-by-token.go b/internal/application/use-cases/load-user-by-token.go new file mode 100644 index 0000000..d91c71e --- /dev/null +++ b/internal/application/use-cases/load-user-by-token.go @@ -0,0 +1,35 @@ +package usecases + +import ( + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" + "github.com/cassiusbessa/backend-test/internal/domain/entities" +) + +type LoadUserByTokenUseCase struct { + userRepo output_ports.UserRepository + tokenService output_ports.TokenService +} + +func NewLoadUserByTokenUseCase( + userRepo output_ports.UserRepository, + tokenService output_ports.TokenService, +) *LoadUserByTokenUseCase { + return &LoadUserByTokenUseCase{ + userRepo: userRepo, + tokenService: tokenService, + } +} + +func (uc *LoadUserByTokenUseCase) Execute(token string) (*entities.User, error) { + userID, err := uc.tokenService.ValidateToken(token) + if err != nil { + return nil, err + } + + user, err := uc.userRepo.FindByID(userID) + if err != nil { + return nil, err + } + + return user, nil +} diff --git a/internal/application/use-cases/load-user-by-token_test.go b/internal/application/use-cases/load-user-by-token_test.go new file mode 100644 index 0000000..38600bf --- /dev/null +++ b/internal/application/use-cases/load-user-by-token_test.go @@ -0,0 +1,84 @@ +package usecases_test + +import ( + "testing" + + usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" + "github.com/cassiusbessa/backend-test/internal/application/use-cases/mocks" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" +) + +func validEmail() object_values.Email { + email, _ := object_values.NewEmail("john@example.com") + return email +} + +func validPhone() object_values.PhoneNumber { + phone, _ := object_values.NewPhoneNumber("11999999999") + return phone +} + +func validPassword() object_values.Password { + password, _ := object_values.NewPassword("123456") + return password +} + +func setupLoadUserByTokenTest() (*mocks.MockUserRepository, *mocks.MockTokenService, *usecases.LoadUserByTokenUseCase) { + userRepo := mocks.NewMockUserRepository() + tokenService := mocks.NewMockTokenService() + useCase := usecases.NewLoadUserByTokenUseCase(userRepo, tokenService) + return userRepo, tokenService, useCase +} + +func TestLoadUserByTokenUseCase_Execute(t *testing.T) { + t.Run("should return user when token is valid", func(t *testing.T) { + userRepo, tokenService, useCase := setupLoadUserByTokenTest() + + token := "valid-token" + userID := uuid.New() + expectedUser, _ := entities.NewUser("John Doe", validEmail(), validPassword(), validPhone()) + + tokenService.On("ValidateToken", token).Return(userID.String(), nil) + userRepo.On("FindByID", userID.String()).Return(&expectedUser, nil) + + user, err := useCase.Execute(token) + + assert.NoError(t, err) + assert.Equal(t, expectedUser, *user) + tokenService.AssertExpectations(t) + userRepo.AssertExpectations(t) + }) + + t.Run("should return error when token is invalid", func(t *testing.T) { + _, tokenService, useCase := setupLoadUserByTokenTest() + + token := "invalid-token" + tokenService.On("ValidateToken", token).Return("", shared.ErrAuthorization) + + user, err := useCase.Execute(token) + + assert.ErrorIs(t, err, shared.ErrAuthorization) + assert.Nil(t, user) + tokenService.AssertExpectations(t) + }) + + t.Run("should return error when user is not found", func(t *testing.T) { + userRepo, tokenService, useCase := setupLoadUserByTokenTest() + + token := "valid-token" + userID := uuid.New().String() + tokenService.On("ValidateToken", token).Return(userID, nil) + userRepo.On("FindByID", userID).Return(nil, shared.ErrNotFound) + + user, err := useCase.Execute(token) + + assert.ErrorIs(t, err, shared.ErrNotFound) + assert.Nil(t, user) + tokenService.AssertExpectations(t) + userRepo.AssertExpectations(t) + }) +} diff --git a/internal/application/use-cases/mocks/user-repository.go b/internal/application/use-cases/mocks/user-repository.go index 00b46c1..79b3196 100644 --- a/internal/application/use-cases/mocks/user-repository.go +++ b/internal/application/use-cases/mocks/user-repository.go @@ -25,6 +25,14 @@ func (m *MockUserRepository) Save(user entities.User) error { return nil } +func (m *MockUserRepository) FindByID(id string) (*entities.User, error) { + args := m.Called(id) + if user := args.Get(0); user != nil { + return user.(*entities.User), args.Error(1) + } + return nil, args.Error(1) +} + func NewMockUserRepository() *MockUserRepository { return new(MockUserRepository) } diff --git a/internal/infra/db/user-repository.go b/internal/infra/db/user-repository.go index a8f7693..64daf23 100644 --- a/internal/infra/db/user-repository.go +++ b/internal/infra/db/user-repository.go @@ -32,3 +32,17 @@ func (r *UserGormRepository) FindByEmail(email string) (*entities.User, error) { user := model.ToDomain() return &user, nil } + +func (r *UserGormRepository) FindByID(id string) (*entities.User, error) { + var model UserModel + err := r.db.Where("id = ?", id).First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + + user := model.ToDomain() + return &user, nil +} From 1b5480840a22e1c98cd78a4f8d41231ebb33cc29 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 17:24:27 -0300 Subject: [PATCH 33/67] test: :recycle: improve code style --- .../application/use-cases/load-user-by-token_test.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/application/use-cases/load-user-by-token_test.go b/internal/application/use-cases/load-user-by-token_test.go index 38600bf..8e1d0e0 100644 --- a/internal/application/use-cases/load-user-by-token_test.go +++ b/internal/application/use-cases/load-user-by-token_test.go @@ -30,8 +30,7 @@ func validPassword() object_values.Password { func setupLoadUserByTokenTest() (*mocks.MockUserRepository, *mocks.MockTokenService, *usecases.LoadUserByTokenUseCase) { userRepo := mocks.NewMockUserRepository() tokenService := mocks.NewMockTokenService() - useCase := usecases.NewLoadUserByTokenUseCase(userRepo, tokenService) - return userRepo, tokenService, useCase + return userRepo, tokenService, usecases.NewLoadUserByTokenUseCase(userRepo, tokenService) } func TestLoadUserByTokenUseCase_Execute(t *testing.T) { @@ -48,7 +47,11 @@ func TestLoadUserByTokenUseCase_Execute(t *testing.T) { user, err := useCase.Execute(token) assert.NoError(t, err) - assert.Equal(t, expectedUser, *user) + assert.NotNil(t, user) + assert.Equal(t, expectedUser.Name(), user.Name()) + assert.Equal(t, expectedUser.Email().Value(), user.Email().Value()) + assert.Equal(t, expectedUser.Phone().Value(), user.Phone().Value()) + tokenService.AssertExpectations(t) userRepo.AssertExpectations(t) }) From b5faca19b2b3328993f9082608954510ad25c707 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 17:35:40 -0300 Subject: [PATCH 34/67] refactor: :recycle: create and use loaded user dto instead user entity --- internal/application/dto/user-dto.go | 7 +++++++ .../ports/input/load-user-by-token.go | 6 ++++-- .../use-cases/load-user-by-token.go | 18 +++++++++++++----- .../use-cases/load-user-by-token_test.go | 9 +++++---- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/internal/application/dto/user-dto.go b/internal/application/dto/user-dto.go index 8846dfe..15f4fcc 100644 --- a/internal/application/dto/user-dto.go +++ b/internal/application/dto/user-dto.go @@ -19,3 +19,10 @@ type LoginInput struct { type LoginOutput struct { Token string `json:"token"` } + +type LoadedUserOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` +} diff --git a/internal/application/ports/input/load-user-by-token.go b/internal/application/ports/input/load-user-by-token.go index b2cdd85..ba61983 100644 --- a/internal/application/ports/input/load-user-by-token.go +++ b/internal/application/ports/input/load-user-by-token.go @@ -1,7 +1,9 @@ package input_ports -import "github.com/cassiusbessa/backend-test/internal/domain/entities" +import ( + "github.com/cassiusbessa/backend-test/internal/application/dto" +) type LoadUserByTokenUseCase interface { - Execute(token string) (entities.User, error) + Execute(token string) (*dto.LoadedUserOutput, error) } diff --git a/internal/application/use-cases/load-user-by-token.go b/internal/application/use-cases/load-user-by-token.go index d91c71e..9744ee7 100644 --- a/internal/application/use-cases/load-user-by-token.go +++ b/internal/application/use-cases/load-user-by-token.go @@ -1,8 +1,9 @@ package usecases import ( + "github.com/cassiusbessa/backend-test/internal/application/dto" + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" - "github.com/cassiusbessa/backend-test/internal/domain/entities" ) type LoadUserByTokenUseCase struct { @@ -13,14 +14,14 @@ type LoadUserByTokenUseCase struct { func NewLoadUserByTokenUseCase( userRepo output_ports.UserRepository, tokenService output_ports.TokenService, -) *LoadUserByTokenUseCase { - return &LoadUserByTokenUseCase{ +) input_ports.LoadUserByTokenUseCase { + return LoadUserByTokenUseCase{ userRepo: userRepo, tokenService: tokenService, } } -func (uc *LoadUserByTokenUseCase) Execute(token string) (*entities.User, error) { +func (uc LoadUserByTokenUseCase) Execute(token string) (*dto.LoadedUserOutput, error) { userID, err := uc.tokenService.ValidateToken(token) if err != nil { return nil, err @@ -31,5 +32,12 @@ func (uc *LoadUserByTokenUseCase) Execute(token string) (*entities.User, error) return nil, err } - return user, nil + if user == nil { + return nil, nil + } + return &dto.LoadedUserOutput{ + ID: user.ID().String(), + Name: user.Name(), + Email: user.Email().Value(), + }, nil } diff --git a/internal/application/use-cases/load-user-by-token_test.go b/internal/application/use-cases/load-user-by-token_test.go index 8e1d0e0..43dead9 100644 --- a/internal/application/use-cases/load-user-by-token_test.go +++ b/internal/application/use-cases/load-user-by-token_test.go @@ -3,6 +3,7 @@ package usecases_test import ( "testing" + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" "github.com/cassiusbessa/backend-test/internal/application/use-cases/mocks" "github.com/cassiusbessa/backend-test/internal/domain/entities" @@ -27,7 +28,7 @@ func validPassword() object_values.Password { return password } -func setupLoadUserByTokenTest() (*mocks.MockUserRepository, *mocks.MockTokenService, *usecases.LoadUserByTokenUseCase) { +func setupLoadUserByTokenTest() (*mocks.MockUserRepository, *mocks.MockTokenService, input_ports.LoadUserByTokenUseCase) { userRepo := mocks.NewMockUserRepository() tokenService := mocks.NewMockTokenService() return userRepo, tokenService, usecases.NewLoadUserByTokenUseCase(userRepo, tokenService) @@ -48,9 +49,9 @@ func TestLoadUserByTokenUseCase_Execute(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, user) - assert.Equal(t, expectedUser.Name(), user.Name()) - assert.Equal(t, expectedUser.Email().Value(), user.Email().Value()) - assert.Equal(t, expectedUser.Phone().Value(), user.Phone().Value()) + assert.Equal(t, expectedUser.Name(), user.Name) + assert.Equal(t, expectedUser.Email().Value(), user.Email) + assert.Equal(t, expectedUser.Phone().Value(), user.Phone) tokenService.AssertExpectations(t) userRepo.AssertExpectations(t) From 6b299ea54684dc2132a70c2c02cb533aa734698d Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 17:58:27 -0300 Subject: [PATCH 35/67] feat: :sparkles: ensure that returns error if token is not valid or a user info --- .../http/handlers/load-user-by-id-handler.go | 32 +++++++ .../handlers/load-user-by-id-handler_test.go | 89 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 internal/interfaces/http/handlers/load-user-by-id-handler.go create mode 100644 internal/interfaces/http/handlers/load-user-by-id-handler_test.go diff --git a/internal/interfaces/http/handlers/load-user-by-id-handler.go b/internal/interfaces/http/handlers/load-user-by-id-handler.go new file mode 100644 index 0000000..0cd17bf --- /dev/null +++ b/internal/interfaces/http/handlers/load-user-by-id-handler.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "net/http" + + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + "github.com/gin-gonic/gin" +) + +type LoadUserByTokenHandler struct { + LoadUserByTokenUseCase input_ports.LoadUserByTokenUseCase +} + +func NewLoadUserByTokenHandler(loadUserByTokenUseCase input_ports.LoadUserByTokenUseCase) *LoadUserByTokenHandler { + return &LoadUserByTokenHandler{LoadUserByTokenUseCase: loadUserByTokenUseCase} +} + +func (h *LoadUserByTokenHandler) Execute(ctx *gin.Context) { + token := ctx.GetHeader("Authorization") + if token == "" { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + + user, err := h.LoadUserByTokenUseCase.Execute(token) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, user) +} diff --git a/internal/interfaces/http/handlers/load-user-by-id-handler_test.go b/internal/interfaces/http/handlers/load-user-by-id-handler_test.go new file mode 100644 index 0000000..592f745 --- /dev/null +++ b/internal/interfaces/http/handlers/load-user-by-id-handler_test.go @@ -0,0 +1,89 @@ +package handlers_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/interfaces/http/handlers" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func validUserDTO() *dto.LoadedUserOutput { + return &dto.LoadedUserOutput{ + ID: "123", + Name: "John Doe", + Email: "valid_email@email.com", + Phone: "+5511987654321", + } +} + +type LoadUserByTokenUseCaseMock struct { + mock.Mock +} + +func (m *LoadUserByTokenUseCaseMock) Execute(token string) (*dto.LoadedUserOutput, error) { + args := m.Called(token) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*dto.LoadedUserOutput), args.Error(1) +} + +func setupLoadUserByTokenHandlerTest() (*LoadUserByTokenUseCaseMock, *gin.Engine) { + mockUseCase := new(LoadUserByTokenUseCaseMock) + handler := handlers.NewLoadUserByTokenHandler(mockUseCase) + + router := gin.Default() + router.GET("/me", handler.Execute) + + return mockUseCase, router +} + +func TestLoadUserByTokenHandler_Execute(t *testing.T) { + + t.Run("should return user when token is valid", func(t *testing.T) { + mockUseCase, router := setupLoadUserByTokenHandlerTest() + + user := validUserDTO() + mockUseCase.On("Execute", "valid-token").Return(user, nil) + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req.Header.Set("Authorization", "valid-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + mockUseCase.AssertExpectations(t) + }) + + t.Run("should return 401 if token is missing", func(t *testing.T) { + _, router := setupLoadUserByTokenHandlerTest() + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) + + t.Run("should return 401 if token is invalid", func(t *testing.T) { + mockUseCase, router := setupLoadUserByTokenHandlerTest() + + mockUseCase.On("Execute", "invalid-token").Return(nil, errors.New("invalid token")) + + req := httptest.NewRequest(http.MethodGet, "/me", nil) + req.Header.Set("Authorization", "invalid-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + mockUseCase.AssertExpectations(t) + }) +} From cf6efa244da6668f5f8b52e95f5b17e8ba982573 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 18:21:12 -0300 Subject: [PATCH 36/67] refactor: :recycle: apply DRY in factories --- .../http/factory/handler-factory.go | 53 ++++++++++++++----- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/internal/interfaces/http/factory/handler-factory.go b/internal/interfaces/http/factory/handler-factory.go index 661101d..0b0dbb2 100644 --- a/internal/interfaces/http/factory/handler-factory.go +++ b/internal/interfaces/http/factory/handler-factory.go @@ -2,28 +2,57 @@ package factory import ( "os" + "sync" "time" + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" "github.com/cassiusbessa/backend-test/internal/infra/db" "github.com/cassiusbessa/backend-test/internal/infra/token" "github.com/cassiusbessa/backend-test/internal/interfaces/http/handlers" ) +type dependencies struct { + UserRepository output_ports.UserRepository + TokenService output_ports.TokenService +} + +var ( + depsInstance *dependencies + once sync.Once +) + +func getDependencies() *dependencies { + once.Do(func() { + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + jwtSecret = "segredo" + } + weekDuration := 7 * 24 * time.Hour + + depsInstance = &dependencies{ + UserRepository: db.NewUserGormRepository(db.DB), + TokenService: token.NewJWTService(jwtSecret, weekDuration), + } + }) + + return depsInstance +} + func BuildCreateUserHandler() *handlers.CreateUserHandler { - userRepository := db.NewUserGormRepository(db.DB) - createUserUseCase := usecases.NewCreateUserUseCase(userRepository) - return handlers.NewCreateUserHandler(createUserUseCase) + deps := getDependencies() + useCase := usecases.NewCreateUserUseCase(deps.UserRepository) + return handlers.NewCreateUserHandler(useCase) } func BuildLoginHandler() *handlers.LoginHandler { - jwtSecret := os.Getenv("JWT_SECRET") - if jwtSecret == "" { - jwtSecret = "segredo" - } - weekDuration := time.Hour * 24 * 7 - userRepository := db.NewUserGormRepository(db.DB) - tokenService := token.NewJWTService(jwtSecret, weekDuration) - loginUseCase := usecases.NewLoginUserUseCase(userRepository, tokenService) - return handlers.NewLoginHandler(loginUseCase) + deps := getDependencies() + useCase := usecases.NewLoginUserUseCase(deps.UserRepository, deps.TokenService) + return handlers.NewLoginHandler(useCase) +} + +func BuildLoadUserByTokenHandler() *handlers.LoadUserByTokenHandler { + deps := getDependencies() + useCase := usecases.NewLoadUserByTokenUseCase(deps.UserRepository, deps.TokenService) + return handlers.NewLoadUserByTokenHandler(useCase) } From 4cb7cf4746473305dd88076051dbdf12b0486833 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 18:22:16 -0300 Subject: [PATCH 37/67] feat: :sparkles: add load user by token route --- cmd/main.go | 1 + internal/interfaces/http/routes/routes.go | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/cmd/main.go b/cmd/main.go index 08f9f50..a238902 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -30,6 +30,7 @@ func main() { api := r.Group("/api") routes.WithCreateUser(api) routes.WithLogin(api) + routes.WithLoadUserByToken(api) log.Printf("Server running on port %s 🚀", port) if err := r.Run(":" + port); err != nil { diff --git a/internal/interfaces/http/routes/routes.go b/internal/interfaces/http/routes/routes.go index 4ef0f04..0f339dc 100644 --- a/internal/interfaces/http/routes/routes.go +++ b/internal/interfaces/http/routes/routes.go @@ -14,3 +14,8 @@ func WithLogin(g *gin.RouterGroup) { handler := factory.BuildLoginHandler() g.POST("/login", handler.Execute) } + +func WithLoadUserByToken(g *gin.RouterGroup) { + handler := factory.BuildLoadUserByTokenHandler() + g.GET("/me", handler.Execute) +} From c11e93ec55bd281b8d59f3c57455fc511406ee9b Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 18:39:59 -0300 Subject: [PATCH 38/67] fix: :bug: fix generate new user id and load user by id --- internal/application/use-cases/load-user-by-token.go | 1 + internal/domain/entities/user.go | 10 ++++++++++ internal/infra/db/user-model.go | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/application/use-cases/load-user-by-token.go b/internal/application/use-cases/load-user-by-token.go index 9744ee7..62ababd 100644 --- a/internal/application/use-cases/load-user-by-token.go +++ b/internal/application/use-cases/load-user-by-token.go @@ -39,5 +39,6 @@ func (uc LoadUserByTokenUseCase) Execute(token string) (*dto.LoadedUserOutput, e ID: user.ID().String(), Name: user.Name(), Email: user.Email().Value(), + Phone: user.Phone().Value(), }, nil } diff --git a/internal/domain/entities/user.go b/internal/domain/entities/user.go index db69883..aba901a 100644 --- a/internal/domain/entities/user.go +++ b/internal/domain/entities/user.go @@ -27,6 +27,16 @@ func NewUser(name string, email object_values.Email, hashed_pass object_values.P }, nil } +func LoadUser(id uuid.UUID, name string, email object_values.Email, hashed_pass object_values.Password, phone object_values.PhoneNumber) User { + return User{ + id: id, + name: name, + email: email, + phone: phone, + password: hashed_pass, + } +} + func (u User) ID() uuid.UUID { return u.id } diff --git a/internal/infra/db/user-model.go b/internal/infra/db/user-model.go index 6e47899..11a805b 100644 --- a/internal/infra/db/user-model.go +++ b/internal/infra/db/user-model.go @@ -19,7 +19,7 @@ func (model UserModel) ToDomain() entities.User { phone, _ := object_values.NewPhoneNumber(model.Phone) hashedPass := object_values.NewPasswordFromHash(model.Password) - user, _ := entities.NewUser(model.Name, email, hashedPass, phone) + user := entities.LoadUser(model.ID, model.Name, email, hashedPass, phone) return user } From 45eb6de642103bf985fe92c13c364c2c3370dcb6 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 19:23:52 -0300 Subject: [PATCH 39/67] feat: :sparkles: add code invitation and points --- internal/application/use-cases/create-user.go | 2 +- .../application/use-cases/create-user_test.go | 2 +- .../use-cases/load-user-by-token_test.go | 2 +- internal/application/use-cases/login_test.go | 6 +- internal/domain/entities/user.go | 75 ++++++++++++++----- internal/domain/entities/user_test.go | 2 +- internal/infra/db/user-model.go | 38 +++++++--- 7 files changed, 91 insertions(+), 36 deletions(-) diff --git a/internal/application/use-cases/create-user.go b/internal/application/use-cases/create-user.go index 0180eed..2933138 100644 --- a/internal/application/use-cases/create-user.go +++ b/internal/application/use-cases/create-user.go @@ -40,7 +40,7 @@ func (uc *CreateUserUseCase) Execute(input dto.CreateUserInput) (dto.CreateUserO return dto.CreateUserOutput{}, shared.ErrConflictError } - user, err := entities.NewUser(input.Name, emailObj, hashedPass, phoneObj) + user, err := entities.NewUser(input.Name, emailObj, hashedPass, phoneObj, nil) if err != nil { return dto.CreateUserOutput{}, err } diff --git a/internal/application/use-cases/create-user_test.go b/internal/application/use-cases/create-user_test.go index f87d332..f5b9a3f 100644 --- a/internal/application/use-cases/create-user_test.go +++ b/internal/application/use-cases/create-user_test.go @@ -98,7 +98,7 @@ func TestCreateUserUseCase_Execute(t *testing.T) { phone, _ := object_values.NewPhoneNumber(input.Phone) password, _ := object_values.NewPassword(input.Password) - existingUser, _ := entities.NewUser("John Doe", email, password, phone) + existingUser, _ := entities.NewUser("John Doe", email, password, phone, nil) mockRepo.On("FindByEmail", input.Email).Return(&existingUser, nil) diff --git a/internal/application/use-cases/load-user-by-token_test.go b/internal/application/use-cases/load-user-by-token_test.go index 43dead9..cd28707 100644 --- a/internal/application/use-cases/load-user-by-token_test.go +++ b/internal/application/use-cases/load-user-by-token_test.go @@ -40,7 +40,7 @@ func TestLoadUserByTokenUseCase_Execute(t *testing.T) { token := "valid-token" userID := uuid.New() - expectedUser, _ := entities.NewUser("John Doe", validEmail(), validPassword(), validPhone()) + expectedUser, _ := entities.NewUser("John Doe", validEmail(), validPassword(), validPhone(), nil) tokenService.On("ValidateToken", token).Return(userID.String(), nil) userRepo.On("FindByID", userID.String()).Return(&expectedUser, nil) diff --git a/internal/application/use-cases/login_test.go b/internal/application/use-cases/login_test.go index a13a2f8..1e36241 100644 --- a/internal/application/use-cases/login_test.go +++ b/internal/application/use-cases/login_test.go @@ -29,7 +29,7 @@ func TestLoginUserUseCase_Execute(t *testing.T) { email, _ := object_values.NewEmail("test@example.com") phoneNumber, _ := object_values.NewPhoneNumber("1234567890") - user, _ := entities.NewUser("teste", email, password, phoneNumber) + user, _ := entities.NewUser("teste", email, password, phoneNumber, nil) input := dto.LoginInput{Email: "test@example.com", Password: "123456"} expectedToken := "fake.jwt.token" @@ -57,7 +57,7 @@ func TestLoginUserUseCase_Execute(t *testing.T) { password, _ := object_values.NewPassword("correct-password") phone, _ := object_values.NewPhoneNumber("1234567890") - user, _ := entities.NewUser("User", email, password, phone) + user, _ := entities.NewUser("User", email, password, phone, nil) input := dto.LoginInput{Email: "user@example.com", Password: "wrong-password"} @@ -72,7 +72,7 @@ func TestLoginUserUseCase_Execute(t *testing.T) { password, _ := object_values.NewPassword("pass123") phone, _ := object_values.NewPhoneNumber("1234567890") - user, _ := entities.NewUser("User", email, password, phone) + user, _ := entities.NewUser("User", email, password, phone, nil) input := dto.LoginInput{Email: "user2@example.com", Password: "pass123"} diff --git a/internal/domain/entities/user.go b/internal/domain/entities/user.go index aba901a..58b6b1a 100644 --- a/internal/domain/entities/user.go +++ b/internal/domain/entities/user.go @@ -7,33 +7,58 @@ import ( ) type User struct { - id uuid.UUID - name string - email object_values.Email - phone object_values.PhoneNumber - password object_values.Password + id uuid.UUID + name string + email object_values.Email + phone object_values.PhoneNumber + password object_values.Password + inviteCode string + invitedBy *uuid.UUID + points int } -func NewUser(name string, email object_values.Email, hashed_pass object_values.Password, phone object_values.PhoneNumber) (User, error) { +func NewUser( + name string, + email object_values.Email, + hashedPass object_values.Password, + phone object_values.PhoneNumber, + invitedBy *uuid.UUID, +) (User, error) { if name == "" { return User{}, shared.ErrValidation } + return User{ - id: uuid.New(), - name: name, - email: email, - phone: phone, - password: hashed_pass, + id: uuid.New(), + name: name, + email: email, + phone: phone, + password: hashedPass, + inviteCode: uuid.NewString(), + invitedBy: invitedBy, + points: 1, }, nil } -func LoadUser(id uuid.UUID, name string, email object_values.Email, hashed_pass object_values.Password, phone object_values.PhoneNumber) User { +func LoadUser( + id uuid.UUID, + name string, + email object_values.Email, + hashedPass object_values.Password, + phone object_values.PhoneNumber, + inviteCode string, + invitedBy *uuid.UUID, + points int, +) User { return User{ - id: id, - name: name, - email: email, - phone: phone, - password: hashed_pass, + id: id, + name: name, + email: email, + phone: phone, + password: hashedPass, + inviteCode: inviteCode, + invitedBy: invitedBy, + points: points, } } @@ -56,3 +81,19 @@ func (u User) Password() object_values.Password { func (u User) Phone() object_values.PhoneNumber { return u.phone } + +func (u User) InviteCode() string { + return u.inviteCode +} + +func (u User) InvitedBy() *uuid.UUID { + return u.invitedBy +} + +func (u *User) AddPoint() { + u.points++ +} + +func (u User) Points() int { + return u.points +} diff --git a/internal/domain/entities/user_test.go b/internal/domain/entities/user_test.go index 46964a4..88a6291 100644 --- a/internal/domain/entities/user_test.go +++ b/internal/domain/entities/user_test.go @@ -24,7 +24,7 @@ func TestNewUser(t *testing.T) { phone, _ := object_values.NewPhoneNumber(tt.phone) password, _ := object_values.NewPassword("defaultPassword123") - _, err := entities.NewUser(tt.name, email, password, phone) + _, err := entities.NewUser(tt.name, email, password, phone, nil) if (err != nil) != tt.expectError { t.Errorf("NewUser(%s, %s, %s) expected error: %v, got: %v", tt.name, tt.email, tt.phone, tt.expectError, err) } diff --git a/internal/infra/db/user-model.go b/internal/infra/db/user-model.go index 11a805b..f93b5c9 100644 --- a/internal/infra/db/user-model.go +++ b/internal/infra/db/user-model.go @@ -7,11 +7,14 @@ import ( ) type UserModel struct { - ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` - Name string `gorm:"column:name;not null"` - Email string `gorm:"column:email;unique;not null"` - Phone string `gorm:"column:phone;unique;not null"` - Password string `gorm:"column:password;not null"` + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey"` + Name string `gorm:"column:name;not null"` + Email string `gorm:"column:email;unique;not null"` + Phone string `gorm:"column:phone;unique;not null"` + Password string `gorm:"column:password;not null"` + InviteCode string `gorm:"column:invite_code;unique;not null"` + InvitedBy *uuid.UUID `gorm:"column:invited_by"` + Points int `gorm:"column:points;not null;default:1"` } func (model UserModel) ToDomain() entities.User { @@ -19,17 +22,28 @@ func (model UserModel) ToDomain() entities.User { phone, _ := object_values.NewPhoneNumber(model.Phone) hashedPass := object_values.NewPasswordFromHash(model.Password) - user := entities.LoadUser(model.ID, model.Name, email, hashedPass, phone) - return user + return entities.LoadUser( + model.ID, + model.Name, + email, + hashedPass, + phone, + model.InviteCode, + model.InvitedBy, + model.Points, + ) } func UserToModel(user entities.User) UserModel { return UserModel{ - ID: user.ID(), - Name: user.Name(), - Email: user.Email().Value(), - Phone: user.Phone().Value(), - Password: user.Password().Hash(), + ID: user.ID(), + Name: user.Name(), + Email: user.Email().Value(), + Phone: user.Phone().Value(), + Password: user.Password().Hash(), + InviteCode: user.InviteCode(), + InvitedBy: user.InvitedBy(), + Points: user.Points(), } } From 0478fe1674cc8fd9fa71c6c01d2e95462fde5066 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 19:45:12 -0300 Subject: [PATCH 40/67] test: :white_check_mark: ensures that users are created with a starting point --- internal/application/dto/user-dto.go | 9 +++++---- internal/domain/entities/user_test.go | 14 ++++++++++---- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/internal/application/dto/user-dto.go b/internal/application/dto/user-dto.go index 15f4fcc..6f4859d 100644 --- a/internal/application/dto/user-dto.go +++ b/internal/application/dto/user-dto.go @@ -1,10 +1,11 @@ package dto type CreateUserInput struct { - Name string `json:"name" binding:"required"` - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required"` - Phone string `json:"phone" binding:"required"` + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + Phone string `json:"phone" binding:"required"` + InvitedBy string `json:"invited_by"` } type CreateUserOutput struct { diff --git a/internal/domain/entities/user_test.go b/internal/domain/entities/user_test.go index 88a6291..86169f4 100644 --- a/internal/domain/entities/user_test.go +++ b/internal/domain/entities/user_test.go @@ -5,6 +5,7 @@ import ( "github.com/cassiusbessa/backend-test/internal/domain/entities" object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/google/uuid" ) func TestNewUser(t *testing.T) { @@ -13,20 +14,25 @@ func TestNewUser(t *testing.T) { name string email string phone string + invitedBy *uuid.UUID expectError bool }{ - {"John Doe", "user@example.com", "+14155552671", false}, - {"", "user@example.com", "+14155552671", true}, + {"John Doe", "user@example.com", "+14155552671", nil, false}, + {"", "user@example.com", "+14155552671", nil, true}, } for _, tt := range tests { email, _ := object_values.NewEmail(tt.email) phone, _ := object_values.NewPhoneNumber(tt.phone) - password, _ := object_values.NewPassword("defaultPassword123") - _, err := entities.NewUser(tt.name, email, password, phone, nil) + + u, err := entities.NewUser(tt.name, email, password, phone, tt.invitedBy) if (err != nil) != tt.expectError { t.Errorf("NewUser(%s, %s, %s) expected error: %v, got: %v", tt.name, tt.email, tt.phone, tt.expectError, err) } + + if err == nil && u.Points() != 1 { + t.Errorf("expected initial points to be 1, got %d", u.Points()) + } } } From 1de6d782da2ec0f772960beafdfa2897fb9cc31e Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 20:57:48 -0300 Subject: [PATCH 41/67] feat: :sparkles: add point to inviter user --- internal/application/dto/user-dto.go | 10 ++-- .../ports/output/user-repository.go | 1 + internal/application/use-cases/create-user.go | 21 ++++++++- .../application/use-cases/create-user_test.go | 46 +++++++++++++++++++ .../use-cases/mocks/user-repository.go | 8 ++++ internal/infra/db/user-repository.go | 16 ++++++- 6 files changed, 95 insertions(+), 7 deletions(-) diff --git a/internal/application/dto/user-dto.go b/internal/application/dto/user-dto.go index 6f4859d..df4b2bb 100644 --- a/internal/application/dto/user-dto.go +++ b/internal/application/dto/user-dto.go @@ -1,11 +1,11 @@ package dto type CreateUserInput struct { - Name string `json:"name" binding:"required"` - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required"` - Phone string `json:"phone" binding:"required"` - InvitedBy string `json:"invited_by"` + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` + Phone string `json:"phone" binding:"required"` + InviteCode *string `json:"invite_code" binding:"omitempty"` } type CreateUserOutput struct { diff --git a/internal/application/ports/output/user-repository.go b/internal/application/ports/output/user-repository.go index 587ef85..f10880b 100644 --- a/internal/application/ports/output/user-repository.go +++ b/internal/application/ports/output/user-repository.go @@ -6,4 +6,5 @@ type UserRepository interface { Save(user entities.User) error FindByEmail(email string) (*entities.User, error) FindByID(id string) (*entities.User, error) + FindByInviteCode(inviteCode string) (*entities.User, error) } diff --git a/internal/application/use-cases/create-user.go b/internal/application/use-cases/create-user.go index 2933138..b89ac8d 100644 --- a/internal/application/use-cases/create-user.go +++ b/internal/application/use-cases/create-user.go @@ -6,6 +6,7 @@ import ( "github.com/cassiusbessa/backend-test/internal/domain/entities" object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/google/uuid" ) type CreateUserUseCase struct { @@ -40,7 +41,25 @@ func (uc *CreateUserUseCase) Execute(input dto.CreateUserInput) (dto.CreateUserO return dto.CreateUserOutput{}, shared.ErrConflictError } - user, err := entities.NewUser(input.Name, emailObj, hashedPass, phoneObj, nil) + var invitedByID *uuid.UUID + + if input.InviteCode != nil { + inviter, err := uc.userRepo.FindByInviteCode(*input.InviteCode) + if err != nil { + return dto.CreateUserOutput{}, shared.ErrInternal + } + if inviter != nil { + inviter.AddPoint() + err = uc.userRepo.Save(*inviter) + if err != nil { + return dto.CreateUserOutput{}, shared.ErrInternal + } + id := inviter.ID() + invitedByID = &id + } + } + + user, err := entities.NewUser(input.Name, emailObj, hashedPass, phoneObj, invitedByID) if err != nil { return dto.CreateUserOutput{}, err } diff --git a/internal/application/use-cases/create-user_test.go b/internal/application/use-cases/create-user_test.go index f5b9a3f..b46bcc6 100644 --- a/internal/application/use-cases/create-user_test.go +++ b/internal/application/use-cases/create-user_test.go @@ -8,6 +8,7 @@ import ( "github.com/cassiusbessa/backend-test/internal/domain/entities" object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -127,4 +128,49 @@ func TestCreateUserUseCase_Execute(t *testing.T) { assert.EqualError(t, err, shared.ErrInternal.Error()) mockRepo.AssertExpectations(t) }) + + t.Run("invited user", func(t *testing.T) { + mockRepo, useCase := setupTest() + + validEmail, _ := object_values.NewEmail("inviter@example.com") + validPhone, _ := object_values.NewPhoneNumber("+5511987654321") + validPassword, _ := object_values.NewPassword("StrongP@ssw0rd") + inviterCode := uuid.NewString() + + inviter := entities.LoadUser( + uuid.New(), + "Inviter", + validEmail, + validPassword, + validPhone, + inviterCode, + nil, + 1, + ) + + input := dto.CreateUserInput{ + Name: "New User", + Email: "newuser@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511999999999", + InviteCode: &inviterCode, + } + + mockRepo.On("FindByEmail", input.Email).Return(nil, nil) + mockRepo.On("FindByInviteCode", *input.InviteCode).Return(&inviter, nil) + mockRepo.On("Save", mock.MatchedBy(func(u entities.User) bool { + return u.ID() == inviter.ID() && u.Points() == 2 + })).Return(nil).Once() + + mockRepo.On("Save", mock.MatchedBy(func(u entities.User) bool { + return u.Email().Value() == input.Email + })).Return(nil).Once() + + output, err := useCase.Execute(input) + + assert.NoError(t, err) + assert.NotEmpty(t, output.UserID) + mockRepo.AssertExpectations(t) + }) + } diff --git a/internal/application/use-cases/mocks/user-repository.go b/internal/application/use-cases/mocks/user-repository.go index 79b3196..62c2a48 100644 --- a/internal/application/use-cases/mocks/user-repository.go +++ b/internal/application/use-cases/mocks/user-repository.go @@ -33,6 +33,14 @@ func (m *MockUserRepository) FindByID(id string) (*entities.User, error) { return nil, args.Error(1) } +func (m *MockUserRepository) FindByInviteCode(inviteCode string) (*entities.User, error) { + args := m.Called(inviteCode) + if user := args.Get(0); user != nil { + return user.(*entities.User), args.Error(1) + } + return nil, args.Error(1) +} + func NewMockUserRepository() *MockUserRepository { return new(MockUserRepository) } diff --git a/internal/infra/db/user-repository.go b/internal/infra/db/user-repository.go index 64daf23..c01ba27 100644 --- a/internal/infra/db/user-repository.go +++ b/internal/infra/db/user-repository.go @@ -16,7 +16,7 @@ func NewUserGormRepository(db *gorm.DB) output_ports.UserRepository { func (r *UserGormRepository) Save(user entities.User) error { model := UserToModel(user) - return r.db.Create(&model).Error + return r.db.Save(&model).Error } func (r *UserGormRepository) FindByEmail(email string) (*entities.User, error) { @@ -46,3 +46,17 @@ func (r *UserGormRepository) FindByID(id string) (*entities.User, error) { user := model.ToDomain() return &user, nil } + +func (r *UserGormRepository) FindByInviteCode(inviteCode string) (*entities.User, error) { + var model UserModel + err := r.db.Where("invite_code = ?", inviteCode).First(&model).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + + user := model.ToDomain() + return &user, nil +} From e635519a686b4cc8df729eb15d2bc619ebe2b09f Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 21:26:24 -0300 Subject: [PATCH 42/67] refactor: :art: improve code style --- internal/application/use-cases/create-user.go | 80 +++++++++++++------ 1 file changed, 54 insertions(+), 26 deletions(-) diff --git a/internal/application/use-cases/create-user.go b/internal/application/use-cases/create-user.go index b89ac8d..1cc99d9 100644 --- a/internal/application/use-cases/create-user.go +++ b/internal/application/use-cases/create-user.go @@ -18,56 +18,84 @@ func NewCreateUserUseCase(repo output_ports.UserRepository) *CreateUserUseCase { } func (uc *CreateUserUseCase) Execute(input dto.CreateUserInput) (dto.CreateUserOutput, error) { - emailObj, err := object_values.NewEmail(input.Email) + newUser, err := uc.createUserDTOToUser(input) if err != nil { - return dto.CreateUserOutput{}, shared.ErrValidation + return dto.CreateUserOutput{}, err } - phoneObj, err := object_values.NewPhoneNumber(input.Phone) + existingUser, err := uc.userRepo.FindByEmail(input.Email) if err != nil { - return dto.CreateUserOutput{}, shared.ErrValidation + return dto.CreateUserOutput{}, shared.ErrInternal + } + if existingUser != nil { + return dto.CreateUserOutput{}, shared.ErrConflictError } - hashedPass, err := object_values.NewPassword(input.Password) + err = uc.processInviteCode(input.InviteCode) if err != nil { - return dto.CreateUserOutput{}, shared.ErrValidation + return dto.CreateUserOutput{}, err } - existingUser, err := uc.userRepo.FindByEmail(emailObj.Value()) + err = uc.userRepo.Save(*newUser) if err != nil { return dto.CreateUserOutput{}, shared.ErrInternal } - if existingUser != nil { - return dto.CreateUserOutput{}, shared.ErrConflictError + + return dto.CreateUserOutput{UserID: newUser.ID().String()}, nil +} + +func (uc *CreateUserUseCase) createUserDTOToUser(input dto.CreateUserInput) (*entities.User, error) { + email, err := object_values.NewEmail(input.Email) + if err != nil { + return nil, err } - var invitedByID *uuid.UUID + password, err := object_values.NewPassword(input.Password) + if err != nil { + return nil, err + } + phone, err := object_values.NewPhoneNumber(input.Phone) + if err != nil { + return nil, err + } + + var inviteCode *uuid.UUID if input.InviteCode != nil { - inviter, err := uc.userRepo.FindByInviteCode(*input.InviteCode) + inviteCodeUUID, err := uuid.Parse(*input.InviteCode) if err != nil { - return dto.CreateUserOutput{}, shared.ErrInternal - } - if inviter != nil { - inviter.AddPoint() - err = uc.userRepo.Save(*inviter) - if err != nil { - return dto.CreateUserOutput{}, shared.ErrInternal - } - id := inviter.ID() - invitedByID = &id + return nil, err } + inviteCode = &inviteCodeUUID } - user, err := entities.NewUser(input.Name, emailObj, hashedPass, phoneObj, invitedByID) + user, err := entities.NewUser(input.Name, email, password, phone, inviteCode) if err != nil { - return dto.CreateUserOutput{}, err + return nil, err + } + + return &user, nil +} + +func (uc *CreateUserUseCase) processInviteCode(inviteCode *string) error { + if inviteCode == nil { + return nil } - err = uc.userRepo.Save(user) + inviter, err := uc.userRepo.FindByInviteCode(*inviteCode) if err != nil { - return dto.CreateUserOutput{}, shared.ErrInternal + return shared.ErrInternal + } + + if inviter == nil { + return shared.ErrNotFound + } + + inviter.AddPoint() + err = uc.userRepo.Save(*inviter) + if err != nil { + return shared.ErrInternal } - return dto.CreateUserOutput{UserID: user.ID().String()}, nil + return nil } From d850f075c2e49c4910219a910ce1dcfd04a3d675 Mon Sep 17 00:00:00 2001 From: cassius Date: Fri, 4 Apr 2025 23:31:38 -0300 Subject: [PATCH 43/67] feat: :sparkles: implement email sent to inviter user --- go.mod | 6 ++ go.sum | 6 ++ .../application/ports/output/email-service.go | 5 ++ internal/application/use-cases/create-user.go | 25 +++++++- .../application/use-cases/create-user_test.go | 63 ++++++++++++++++--- .../use-cases/mocks/user-service.go | 16 +++++ internal/infra/email/brevo-email.go | 45 +++++++++++++ .../http/factory/handler-factory.go | 8 ++- 8 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 internal/application/ports/output/email-service.go create mode 100644 internal/application/use-cases/mocks/user-service.go create mode 100644 internal/infra/email/brevo-email.go diff --git a/go.mod b/go.mod index c987795..5758288 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,11 @@ require ( gorm.io/gorm v1.25.12 ) +require ( + github.com/antihax/optional v1.0.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect +) + require ( github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect @@ -20,6 +25,7 @@ require ( github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/getbrevo/brevo-go v1.1.3 github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect diff --git a/go.sum b/go.sum index a481fe8..1191148 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= @@ -12,6 +14,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/getbrevo/brevo-go v1.1.3 h1:8TYrhhxbfAJLGArlPzCDKzbNfzvjIykBRhTDzLJqmyw= +github.com/getbrevo/brevo-go v1.1.3/go.mod h1:ExhytIoPxt/cOBl6ZEMeEZNLUKrWEYA5U3hM/8WP2bg= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= @@ -96,6 +100,8 @@ golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/application/ports/output/email-service.go b/internal/application/ports/output/email-service.go new file mode 100644 index 0000000..d008b61 --- /dev/null +++ b/internal/application/ports/output/email-service.go @@ -0,0 +1,5 @@ +package output_ports + +type EmailService interface { + SendEmail(to string, subject string, body string) error +} diff --git a/internal/application/use-cases/create-user.go b/internal/application/use-cases/create-user.go index 1cc99d9..5aeefba 100644 --- a/internal/application/use-cases/create-user.go +++ b/internal/application/use-cases/create-user.go @@ -1,6 +1,8 @@ package usecases import ( + "strconv" + "github.com/cassiusbessa/backend-test/internal/application/dto" output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" "github.com/cassiusbessa/backend-test/internal/domain/entities" @@ -10,11 +12,12 @@ import ( ) type CreateUserUseCase struct { - userRepo output_ports.UserRepository + userRepo output_ports.UserRepository + emailService output_ports.EmailService } -func NewCreateUserUseCase(repo output_ports.UserRepository) *CreateUserUseCase { - return &CreateUserUseCase{userRepo: repo} +func NewCreateUserUseCase(repo output_ports.UserRepository, emailService output_ports.EmailService) *CreateUserUseCase { + return &CreateUserUseCase{userRepo: repo, emailService: emailService} } func (uc *CreateUserUseCase) Execute(input dto.CreateUserInput) (dto.CreateUserOutput, error) { @@ -97,5 +100,21 @@ func (uc *CreateUserUseCase) processInviteCode(inviteCode *string) error { return shared.ErrInternal } + err = uc.emailService.SendEmail( + inviter.Email().Value(), + "New user invited by you", + uc.emailConfirmationToInviterBody(*inviter), + ) + if err != nil { + return shared.ErrInternal + } + return nil } + +func (uc *CreateUserUseCase) emailConfirmationToInviterBody(inviter entities.User) string { + return "Hello " + inviter.Name() + ",\n\n" + + "Now you have " + strconv.Itoa(inviter.Points()) + " points.\n\n" + + "Best regards,\n" + + "bvio" +} diff --git a/internal/application/use-cases/create-user_test.go b/internal/application/use-cases/create-user_test.go index b46bcc6..9172c25 100644 --- a/internal/application/use-cases/create-user_test.go +++ b/internal/application/use-cases/create-user_test.go @@ -13,15 +13,16 @@ import ( "github.com/stretchr/testify/mock" ) -func setupTest() (*mocks.MockUserRepository, *CreateUserUseCase) { +func setupTest() (*mocks.MockUserRepository, *mocks.MockEmailService, *CreateUserUseCase) { mockRepo := mocks.NewMockUserRepository() - useCase := NewCreateUserUseCase(mockRepo) - return mockRepo, useCase + mockEmail := mocks.NewMockEmailService() + useCase := NewCreateUserUseCase(mockRepo, mockEmail) + return mockRepo, mockEmail, useCase } func TestCreateUserUseCase_Execute(t *testing.T) { t.Run("success", func(t *testing.T) { - mockRepo, useCase := setupTest() + mockRepo, _, useCase := setupTest() input := dto.CreateUserInput{ Name: "John Doe", @@ -41,7 +42,7 @@ func TestCreateUserUseCase_Execute(t *testing.T) { }) t.Run("invalid email", func(t *testing.T) { - _, useCase := setupTest() + _, _, useCase := setupTest() input := dto.CreateUserInput{ Name: "John Doe", @@ -56,7 +57,7 @@ func TestCreateUserUseCase_Execute(t *testing.T) { }) t.Run("invalid phone", func(t *testing.T) { - _, useCase := setupTest() + _, _, useCase := setupTest() input := dto.CreateUserInput{ Name: "John Doe", @@ -71,7 +72,7 @@ func TestCreateUserUseCase_Execute(t *testing.T) { }) t.Run("invalid password", func(t *testing.T) { - _, useCase := setupTest() + _, _, useCase := setupTest() input := dto.CreateUserInput{ Name: "John Doe", @@ -86,7 +87,7 @@ func TestCreateUserUseCase_Execute(t *testing.T) { }) t.Run("user already exists", func(t *testing.T) { - mockRepo, useCase := setupTest() + mockRepo, _, useCase := setupTest() input := dto.CreateUserInput{ Name: "John Doe", @@ -111,7 +112,7 @@ func TestCreateUserUseCase_Execute(t *testing.T) { }) t.Run("repository save error", func(t *testing.T) { - mockRepo, useCase := setupTest() + mockRepo, _, useCase := setupTest() input := dto.CreateUserInput{ Name: "John Doe", @@ -130,7 +131,7 @@ func TestCreateUserUseCase_Execute(t *testing.T) { }) t.Run("invited user", func(t *testing.T) { - mockRepo, useCase := setupTest() + mockRepo, _, useCase := setupTest() validEmail, _ := object_values.NewEmail("inviter@example.com") validPhone, _ := object_values.NewPhoneNumber("+5511987654321") @@ -173,4 +174,46 @@ func TestCreateUserUseCase_Execute(t *testing.T) { mockRepo.AssertExpectations(t) }) + t.Run("invited user", func(t *testing.T) { + mockRepo, mockEmail, useCase := setupTest() + + validEmail, _ := object_values.NewEmail("inviter@example.com") + validPhone, _ := object_values.NewPhoneNumber("+5511987654321") + validPassword, _ := object_values.NewPassword("StrongP@ssw0rd") + inviterCode := uuid.NewString() + + inviter := entities.LoadUser( + uuid.New(), + "Inviter", + validEmail, + validPassword, + validPhone, + inviterCode, + nil, + 1, + ) + + input := dto.CreateUserInput{ + Name: "New User", + Email: "newuser@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511999999999", + InviteCode: &inviterCode, + } + + expectedEmailBody := useCase.emailConfirmationToInviterBody(inviter) + + mockRepo.On("FindByEmail", input.Email).Return(nil, nil) + mockRepo.On("FindByInviteCode", *input.InviteCode).Return(&inviter, nil) + mockRepo.On("Save", mock.Anything).Return(nil) + mockEmail.On("SendEmail", inviter.Email().Value(), "New user invited by you", expectedEmailBody).Return(nil).Once() + + output, err := useCase.Execute(input) + + assert.NoError(t, err) + assert.NotEmpty(t, output.UserID) + mockRepo.AssertExpectations(t) + mockEmail.AssertExpectations(t) + }) + } diff --git a/internal/application/use-cases/mocks/user-service.go b/internal/application/use-cases/mocks/user-service.go new file mode 100644 index 0000000..1a62a19 --- /dev/null +++ b/internal/application/use-cases/mocks/user-service.go @@ -0,0 +1,16 @@ +package mocks + +import "github.com/stretchr/testify/mock" + +type MockEmailService struct { + mock.Mock +} + +func NewMockEmailService() *MockEmailService { + return &MockEmailService{} +} + +func (m *MockEmailService) SendEmail(to string, subject string, body string) error { + args := m.Called(to, subject, body) + return args.Error(0) +} diff --git a/internal/infra/email/brevo-email.go b/internal/infra/email/brevo-email.go new file mode 100644 index 0000000..9da1026 --- /dev/null +++ b/internal/infra/email/brevo-email.go @@ -0,0 +1,45 @@ +package email + +import ( + "context" + "os" + + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" + "github.com/cassiusbessa/backend-test/internal/domain/shared" + "github.com/getbrevo/brevo-go/lib" +) + +type BrevoEmailService struct { + client *lib.APIClient + sender lib.SendSmtpEmailSender +} + +func NewBrevoEmailService(senderEmail, senderName string) output_ports.EmailService { + cfg := lib.NewConfiguration() + cfg.AddDefaultHeader("api-key", os.Getenv("BREVO_API_KEY")) + + client := lib.NewAPIClient(cfg) + + return &BrevoEmailService{ + client: client, + sender: lib.SendSmtpEmailSender{Email: senderEmail, Name: senderName}, + } +} + +func (s *BrevoEmailService) SendEmail(to string, subject string, body string) error { + ctx := context.Background() + + email := lib.SendSmtpEmail{ + Sender: &s.sender, + To: []lib.SendSmtpEmailTo{{Email: to}}, + Subject: subject, + TextContent: body, + } + + _, _, err := s.client.TransactionalEmailsApi.SendTransacEmail(ctx, email) + if err != nil { + return shared.ErrInternal + } + + return nil +} diff --git a/internal/interfaces/http/factory/handler-factory.go b/internal/interfaces/http/factory/handler-factory.go index 0b0dbb2..58c6a6a 100644 --- a/internal/interfaces/http/factory/handler-factory.go +++ b/internal/interfaces/http/factory/handler-factory.go @@ -8,6 +8,7 @@ import ( output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" "github.com/cassiusbessa/backend-test/internal/infra/db" + "github.com/cassiusbessa/backend-test/internal/infra/email" "github.com/cassiusbessa/backend-test/internal/infra/token" "github.com/cassiusbessa/backend-test/internal/interfaces/http/handlers" ) @@ -15,6 +16,7 @@ import ( type dependencies struct { UserRepository output_ports.UserRepository TokenService output_ports.TokenService + EmailService output_ports.EmailService } var ( @@ -33,6 +35,10 @@ func getDependencies() *dependencies { depsInstance = &dependencies{ UserRepository: db.NewUserGormRepository(db.DB), TokenService: token.NewJWTService(jwtSecret, weekDuration), + EmailService: email.NewBrevoEmailService( + "cassiusbessa@gmail.com", + "bvio", + ), } }) @@ -41,7 +47,7 @@ func getDependencies() *dependencies { func BuildCreateUserHandler() *handlers.CreateUserHandler { deps := getDependencies() - useCase := usecases.NewCreateUserUseCase(deps.UserRepository) + useCase := usecases.NewCreateUserUseCase(deps.UserRepository, deps.EmailService) return handlers.NewCreateUserHandler(useCase) } From 7d36653423d7d355f2887637c8642ae5ca020a99 Mon Sep 17 00:00:00 2001 From: cassius Date: Sat, 5 Apr 2025 01:59:38 -0300 Subject: [PATCH 44/67] feat: :sparkles: ensure that returns users ordered by points --- internal/application/dto/pagination-dto.go | 6 ++ internal/application/dto/user-dto.go | 10 +++ .../ports/input/get-users-ranking-usecase.go | 7 ++ .../ports/output/user-repository.go | 1 + .../use-cases/load-users-ordered-by-points.go | 37 ++++++++++ .../load-users-ordered-by-points_test.go | 69 +++++++++++++++++++ .../use-cases/mocks/user-repository.go | 8 +++ internal/infra/db/user-repository.go | 15 ++++ 8 files changed, 153 insertions(+) create mode 100644 internal/application/dto/pagination-dto.go create mode 100644 internal/application/ports/input/get-users-ranking-usecase.go create mode 100644 internal/application/use-cases/load-users-ordered-by-points.go create mode 100644 internal/application/use-cases/load-users-ordered-by-points_test.go diff --git a/internal/application/dto/pagination-dto.go b/internal/application/dto/pagination-dto.go new file mode 100644 index 0000000..d8fac7b --- /dev/null +++ b/internal/application/dto/pagination-dto.go @@ -0,0 +1,6 @@ +package dto + +type PaginationInput struct { + Page int + Limit int +} diff --git a/internal/application/dto/user-dto.go b/internal/application/dto/user-dto.go index df4b2bb..7f007bb 100644 --- a/internal/application/dto/user-dto.go +++ b/internal/application/dto/user-dto.go @@ -27,3 +27,13 @@ type LoadedUserOutput struct { Email string `json:"email"` Phone string `json:"phone"` } + +type GetUsersRankingInput struct { + PaginationInput +} + +type UserRankingItem struct { + UserID string `json:"user_id"` + Name string `json:"name"` + Points int `json:"points"` +} diff --git a/internal/application/ports/input/get-users-ranking-usecase.go b/internal/application/ports/input/get-users-ranking-usecase.go new file mode 100644 index 0000000..9311e0a --- /dev/null +++ b/internal/application/ports/input/get-users-ranking-usecase.go @@ -0,0 +1,7 @@ +package input_ports + +import "github.com/cassiusbessa/backend-test/internal/application/dto" + +type GetUsersRankingUseCase interface { + Execute(input dto.GetUsersRankingInput) ([]dto.UserRankingItem, error) +} diff --git a/internal/application/ports/output/user-repository.go b/internal/application/ports/output/user-repository.go index f10880b..44acbe9 100644 --- a/internal/application/ports/output/user-repository.go +++ b/internal/application/ports/output/user-repository.go @@ -7,4 +7,5 @@ type UserRepository interface { FindByEmail(email string) (*entities.User, error) FindByID(id string) (*entities.User, error) FindByInviteCode(inviteCode string) (*entities.User, error) + FindUsersOrderedByPoints(page int, limit int) ([]entities.User, error) } diff --git a/internal/application/use-cases/load-users-ordered-by-points.go b/internal/application/use-cases/load-users-ordered-by-points.go new file mode 100644 index 0000000..5f76f5f --- /dev/null +++ b/internal/application/use-cases/load-users-ordered-by-points.go @@ -0,0 +1,37 @@ +package usecases + +import ( + "github.com/cassiusbessa/backend-test/internal/application/dto" + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + output_ports "github.com/cassiusbessa/backend-test/internal/application/ports/output" +) + +type LoadUsersOrderedByPointsUseCase struct { + userRepo output_ports.UserRepository +} + +func NewLoadUsersOrderedByPointsUseCase(userRepo output_ports.UserRepository) input_ports.GetUsersRankingUseCase { + return LoadUsersOrderedByPointsUseCase{ + userRepo: userRepo, + } +} + +func (uc LoadUsersOrderedByPointsUseCase) Execute(input dto.GetUsersRankingInput) ([]dto.UserRankingItem, error) { + users, err := uc.userRepo.FindUsersOrderedByPoints(input.Page, input.Limit) + if err != nil { + return nil, err + } + + usersOutputs := make([]dto.UserRankingItem, len(users)) + + for i, user := range users { + usersOutputs[i] = dto.UserRankingItem{ + UserID: user.ID().String(), + Name: user.Name(), + Points: user.Points(), + } + } + + return usersOutputs, nil + +} diff --git a/internal/application/use-cases/load-users-ordered-by-points_test.go b/internal/application/use-cases/load-users-ordered-by-points_test.go new file mode 100644 index 0000000..937c047 --- /dev/null +++ b/internal/application/use-cases/load-users-ordered-by-points_test.go @@ -0,0 +1,69 @@ +package usecases_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + usecases "github.com/cassiusbessa/backend-test/internal/application/use-cases" + "github.com/cassiusbessa/backend-test/internal/application/use-cases/mocks" + "github.com/cassiusbessa/backend-test/internal/domain/entities" + object_values "github.com/cassiusbessa/backend-test/internal/domain/object-values" + "github.com/google/uuid" +) + +func TestLoadUsersOrderedByPointsUseCase_Execute(t *testing.T) { + t.Run("successfully returns ranked users", func(t *testing.T) { + mockRepo := mocks.NewMockUserRepository() + useCase := usecases.NewLoadUsersOrderedByPointsUseCase(mockRepo) + + email, _ := object_values.NewEmail("user1@example.com") + phone, _ := object_values.NewPhoneNumber("+5511999999999") + password, _ := object_values.NewPassword("StrongP@ssw0rd") + user1 := entities.LoadUser(uuid.New(), "Alice", email, password, phone, "", nil, 10) + + email2, _ := object_values.NewEmail("user2@example.com") + phone2, _ := object_values.NewPhoneNumber("+5511988888888") + password2, _ := object_values.NewPassword("AnotherP@ss") + user2 := entities.LoadUser(uuid.New(), "Bob", email2, password2, phone2, "", nil, 5) + + mockRepo.On("FindUsersOrderedByPoints", 1, 10).Return([]entities.User{user1, user2}, nil) + + input := dto.GetUsersRankingInput{ + PaginationInput: dto.PaginationInput{ + Page: 1, + Limit: 10, + }, + } + output, err := useCase.Execute(input) + + assert.NoError(t, err) + assert.Len(t, output, 2) + assert.Equal(t, "Alice", output[0].Name) + assert.Equal(t, 10, output[0].Points) + assert.Equal(t, "Bob", output[1].Name) + assert.Equal(t, 5, output[1].Points) + mockRepo.AssertExpectations(t) + }) + + t.Run("repository returns error", func(t *testing.T) { + mockRepo := mocks.NewMockUserRepository() + useCase := usecases.NewLoadUsersOrderedByPointsUseCase(mockRepo) + + mockRepo.On("FindUsersOrderedByPoints", 1, 10).Return(nil, errors.New("db error")) + + input := dto.GetUsersRankingInput{ + PaginationInput: dto.PaginationInput{ + Page: 1, + Limit: 10, + }, + } + output, err := useCase.Execute(input) + + assert.Error(t, err) + assert.Nil(t, output) + mockRepo.AssertExpectations(t) + }) +} diff --git a/internal/application/use-cases/mocks/user-repository.go b/internal/application/use-cases/mocks/user-repository.go index 62c2a48..e395f40 100644 --- a/internal/application/use-cases/mocks/user-repository.go +++ b/internal/application/use-cases/mocks/user-repository.go @@ -41,6 +41,14 @@ func (m *MockUserRepository) FindByInviteCode(inviteCode string) (*entities.User return nil, args.Error(1) } +func (m *MockUserRepository) FindUsersOrderedByPoints(page int, limit int) ([]entities.User, error) { + args := m.Called(page, limit) + if users := args.Get(0); users != nil { + return users.([]entities.User), args.Error(1) + } + return nil, args.Error(1) +} + func NewMockUserRepository() *MockUserRepository { return new(MockUserRepository) } diff --git a/internal/infra/db/user-repository.go b/internal/infra/db/user-repository.go index c01ba27..0aedb9d 100644 --- a/internal/infra/db/user-repository.go +++ b/internal/infra/db/user-repository.go @@ -60,3 +60,18 @@ func (r *UserGormRepository) FindByInviteCode(inviteCode string) (*entities.User user := model.ToDomain() return &user, nil } + +func (r *UserGormRepository) FindUsersOrderedByPoints(page int, limit int) ([]entities.User, error) { + var models []UserModel + err := r.db.Order("points desc").Offset(page * limit).Limit(limit).Find(&models).Error + if err != nil { + return nil, err + } + + users := make([]entities.User, len(models)) + for i, model := range models { + users[i] = model.ToDomain() + } + + return users, nil +} From b9aaef151b7198875c030a0e345f2ba1f31d01c2 Mon Sep 17 00:00:00 2001 From: cassius Date: Sat, 5 Apr 2025 03:04:42 -0300 Subject: [PATCH 45/67] feat: :sparkles: implement get user ranking route --- cmd/main.go | 1 + .../http/factory/handler-factory.go | 6 + .../http/handlers/create-user-handler.go | 8 +- .../http/handlers/load-user-by-id-handler.go | 8 +- .../load-users-ordered-by-points-handler.go | 57 +++++++++ ...ad-users-ordered-by-points-handler_test.go | 108 ++++++++++++++++++ .../interfaces/http/handlers/login-handler.go | 8 +- internal/interfaces/http/routes/routes.go | 5 + 8 files changed, 189 insertions(+), 12 deletions(-) create mode 100644 internal/interfaces/http/handlers/load-users-ordered-by-points-handler.go create mode 100644 internal/interfaces/http/handlers/load-users-ordered-by-points-handler_test.go diff --git a/cmd/main.go b/cmd/main.go index a238902..977753a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -31,6 +31,7 @@ func main() { routes.WithCreateUser(api) routes.WithLogin(api) routes.WithLoadUserByToken(api) + routes.WithUsersRanking(api) log.Printf("Server running on port %s 🚀", port) if err := r.Run(":" + port); err != nil { diff --git a/internal/interfaces/http/factory/handler-factory.go b/internal/interfaces/http/factory/handler-factory.go index 58c6a6a..5d445c4 100644 --- a/internal/interfaces/http/factory/handler-factory.go +++ b/internal/interfaces/http/factory/handler-factory.go @@ -62,3 +62,9 @@ func BuildLoadUserByTokenHandler() *handlers.LoadUserByTokenHandler { useCase := usecases.NewLoadUserByTokenUseCase(deps.UserRepository, deps.TokenService) return handlers.NewLoadUserByTokenHandler(useCase) } + +func BuildLoadUsersOrderedByPointsHandler() *handlers.LoadUsersOrderedByPointsHandler { + deps := getDependencies() + useCase := usecases.NewLoadUsersOrderedByPointsUseCase(deps.UserRepository) + return handlers.NewLoadUsersOrderedByPointsHandler(useCase) +} diff --git a/internal/interfaces/http/handlers/create-user-handler.go b/internal/interfaces/http/handlers/create-user-handler.go index ffdf5be..b821334 100644 --- a/internal/interfaces/http/handlers/create-user-handler.go +++ b/internal/interfaces/http/handlers/create-user-handler.go @@ -10,11 +10,11 @@ import ( ) type CreateUserHandler struct { - createUserUseCase input_ports.CreateUserUseCase + uc input_ports.CreateUserUseCase } -func NewCreateUserHandler(createUserUseCase input_ports.CreateUserUseCase) *CreateUserHandler { - return &CreateUserHandler{createUserUseCase: createUserUseCase} +func NewCreateUserHandler(uc input_ports.CreateUserUseCase) *CreateUserHandler { + return &CreateUserHandler{uc: uc} } func (h *CreateUserHandler) Execute(ctx *gin.Context) { @@ -24,7 +24,7 @@ func (h *CreateUserHandler) Execute(ctx *gin.Context) { return } - output, err := h.createUserUseCase.Execute(input) + output, err := h.uc.Execute(input) switch err { case shared.ErrNotFound: ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) diff --git a/internal/interfaces/http/handlers/load-user-by-id-handler.go b/internal/interfaces/http/handlers/load-user-by-id-handler.go index 0cd17bf..fc2272d 100644 --- a/internal/interfaces/http/handlers/load-user-by-id-handler.go +++ b/internal/interfaces/http/handlers/load-user-by-id-handler.go @@ -8,11 +8,11 @@ import ( ) type LoadUserByTokenHandler struct { - LoadUserByTokenUseCase input_ports.LoadUserByTokenUseCase + uc input_ports.LoadUserByTokenUseCase } -func NewLoadUserByTokenHandler(loadUserByTokenUseCase input_ports.LoadUserByTokenUseCase) *LoadUserByTokenHandler { - return &LoadUserByTokenHandler{LoadUserByTokenUseCase: loadUserByTokenUseCase} +func NewLoadUserByTokenHandler(uc input_ports.LoadUserByTokenUseCase) *LoadUserByTokenHandler { + return &LoadUserByTokenHandler{uc: uc} } func (h *LoadUserByTokenHandler) Execute(ctx *gin.Context) { @@ -22,7 +22,7 @@ func (h *LoadUserByTokenHandler) Execute(ctx *gin.Context) { return } - user, err := h.LoadUserByTokenUseCase.Execute(token) + user, err := h.uc.Execute(token) if err != nil { ctx.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) return diff --git a/internal/interfaces/http/handlers/load-users-ordered-by-points-handler.go b/internal/interfaces/http/handlers/load-users-ordered-by-points-handler.go new file mode 100644 index 0000000..ee522d5 --- /dev/null +++ b/internal/interfaces/http/handlers/load-users-ordered-by-points-handler.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + input_ports "github.com/cassiusbessa/backend-test/internal/application/ports/input" + "github.com/gin-gonic/gin" +) + +type LoadUsersOrderedByPointsHandler struct { + uc input_ports.GetUsersRankingUseCase +} + +func NewLoadUsersOrderedByPointsHandler(uc input_ports.GetUsersRankingUseCase) *LoadUsersOrderedByPointsHandler { + return &LoadUsersOrderedByPointsHandler{uc: uc} +} +func (h *LoadUsersOrderedByPointsHandler) Execute(ctx *gin.Context) { + + page := ctx.Query("page") + limit := ctx.Query("limit") + + if page == "" { + page = "0" + } + if limit == "" { + limit = "10" + } + + pageInt, err := strconv.Atoi(page) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid page"}) + return + } + + limitInt, err := strconv.Atoi(limit) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"}) + return + } + + input := dto.GetUsersRankingInput{ + PaginationInput: dto.PaginationInput{ + Page: pageInt, + Limit: limitInt, + }, + } + + users, err := h.uc.Execute(input) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load users"}) + return + } + + ctx.JSON(http.StatusOK, users) +} diff --git a/internal/interfaces/http/handlers/load-users-ordered-by-points-handler_test.go b/internal/interfaces/http/handlers/load-users-ordered-by-points-handler_test.go new file mode 100644 index 0000000..df986d8 --- /dev/null +++ b/internal/interfaces/http/handlers/load-users-ordered-by-points-handler_test.go @@ -0,0 +1,108 @@ +package handlers_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cassiusbessa/backend-test/internal/application/dto" + "github.com/cassiusbessa/backend-test/internal/interfaces/http/handlers" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type GetUsersRankingUseCaseMock struct { + mock.Mock +} + +func (m *GetUsersRankingUseCaseMock) Execute(input dto.GetUsersRankingInput) ([]dto.UserRankingItem, error) { + args := m.Called(input) + return args.Get(0).([]dto.UserRankingItem), args.Error(1) +} + +func validUserRankingList() []dto.UserRankingItem { + return []dto.UserRankingItem{ + {UserID: "1", Name: "Alice", Points: 100}, + {UserID: "2", Name: "Bob", Points: 80}, + } +} + +func setupLoadUsersOrderedByPointsHandlerTest() (*GetUsersRankingUseCaseMock, *gin.Engine) { + mockUseCase := new(GetUsersRankingUseCaseMock) + handler := handlers.NewLoadUsersOrderedByPointsHandler(mockUseCase) + + router := gin.Default() + router.GET("/ranking", handler.Execute) + + return mockUseCase, router +} + +func TestLoadUsersOrderedByPointsHandler_Execute(t *testing.T) { + + t.Run("should return 200 with user ranking list", func(t *testing.T) { + mockUseCase, router := setupLoadUsersOrderedByPointsHandlerTest() + + expectedInput := dto.GetUsersRankingInput{ + PaginationInput: dto.PaginationInput{ + Page: 1, + Limit: 10, + }, + } + expectedOutput := validUserRankingList() + + mockUseCase.On("Execute", expectedInput).Return(expectedOutput, nil) + + req := httptest.NewRequest(http.MethodGet, "/ranking?page=1&limit=10", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + mockUseCase.AssertExpectations(t) + }) + + t.Run("should return 400 if page is invalid", func(t *testing.T) { + _, router := setupLoadUsersOrderedByPointsHandlerTest() + + req := httptest.NewRequest(http.MethodGet, "/ranking?page=abc&limit=10", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("should return 400 if limit is invalid", func(t *testing.T) { + _, router := setupLoadUsersOrderedByPointsHandlerTest() + + req := httptest.NewRequest(http.MethodGet, "/ranking?page=1&limit=xyz", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("should return 500 if usecase fails", func(t *testing.T) { + mockUseCase, router := setupLoadUsersOrderedByPointsHandlerTest() + + expectedInput := dto.GetUsersRankingInput{ + PaginationInput: dto.PaginationInput{ + Page: 1, + Limit: 10, + }, + } + + mockUseCase.On("Execute", expectedInput).Return(nil, errors.New("internal error")) + + req := httptest.NewRequest(http.MethodGet, "/ranking?page=1&limit=10", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusInternalServerError, w.Code) + mockUseCase.AssertExpectations(t) + }) +} diff --git a/internal/interfaces/http/handlers/login-handler.go b/internal/interfaces/http/handlers/login-handler.go index 7d11b1d..5de3300 100644 --- a/internal/interfaces/http/handlers/login-handler.go +++ b/internal/interfaces/http/handlers/login-handler.go @@ -10,12 +10,12 @@ import ( ) type LoginHandler struct { - loginUserUseCase input_ports.LoginUseCase + uc input_ports.LoginUseCase } -func NewLoginHandler(loginUserCase input_ports.LoginUseCase) *LoginHandler { +func NewLoginHandler(uc input_ports.LoginUseCase) *LoginHandler { return &LoginHandler{ - loginUserUseCase: loginUserCase, + uc: uc, } } @@ -27,7 +27,7 @@ func (h *LoginHandler) Execute(ctx *gin.Context) { return } - output, err := h.loginUserUseCase.Execute(input) + output, err := h.uc.Execute(input) if err != nil { switch err { case shared.ErrNotFound: diff --git a/internal/interfaces/http/routes/routes.go b/internal/interfaces/http/routes/routes.go index 0f339dc..c3e6d38 100644 --- a/internal/interfaces/http/routes/routes.go +++ b/internal/interfaces/http/routes/routes.go @@ -19,3 +19,8 @@ func WithLoadUserByToken(g *gin.RouterGroup) { handler := factory.BuildLoadUserByTokenHandler() g.GET("/me", handler.Execute) } + +func WithUsersRanking(g *gin.RouterGroup) { + handler := factory.BuildLoadUsersOrderedByPointsHandler() + g.GET("/users/ranking", handler.Execute) +} From 774aa55db31ed72948ff23dde013c70228b58b67 Mon Sep 17 00:00:00 2001 From: cassius Date: Sat, 5 Apr 2025 03:18:01 -0300 Subject: [PATCH 46/67] fix: :bug: check if user is nil --- internal/application/use-cases/login.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/application/use-cases/login.go b/internal/application/use-cases/login.go index 503ee06..a24e981 100644 --- a/internal/application/use-cases/login.go +++ b/internal/application/use-cases/login.go @@ -24,7 +24,11 @@ func NewLoginUserUseCase( func (uc *LoginUserUseCase) Execute(input dto.LoginInput) (dto.LoginOutput, error) { user, err := uc.userRepo.FindByEmail(input.Email) if err != nil { - return dto.LoginOutput{}, shared.ErrNotFound + return dto.LoginOutput{}, shared.ErrInternal + } + + if user == nil { + return dto.LoginOutput{}, shared.ErrAuthorization } if !user.Password().Compare(input.Password) { From 4b63930e4745a8c2d1b9ca062a9b0c7eb891ed3c Mon Sep 17 00:00:00 2001 From: cassius Date: Sat, 5 Apr 2025 23:50:31 -0300 Subject: [PATCH 47/67] feat: :sparkles: add cors --- cmd/main.go | 12 ++++++++++ go.mod | 28 ++++++++++++------------ go.sum | 63 +++++++++++++++++++++++++---------------------------- 3 files changed, 56 insertions(+), 47 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 977753a..70dba9a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,9 +3,11 @@ package main import ( "log" "os" + "time" "github.com/cassiusbessa/backend-test/internal/infra/db" "github.com/cassiusbessa/backend-test/internal/interfaces/http/routes" + "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/joho/godotenv" ) @@ -23,6 +25,16 @@ func main() { } r := gin.Default() + + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Authorization", "Content-Type"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) diff --git a/go.mod b/go.mod index 5758288..a75e242 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,12 @@ module github.com/cassiusbessa/backend-test go 1.24.1 require ( + github.com/gin-contrib/cors v1.7.5 github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.36.0 gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.25.12 @@ -19,18 +20,17 @@ require ( ) require ( - github.com/bytedance/sonic v1.11.6 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect - github.com/cloudwego/base64x v0.1.4 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect + github.com/bytedance/sonic v1.13.2 // indirect + github.com/bytedance/sonic/loader v0.2.4 // indirect + github.com/cloudwego/base64x v0.1.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/getbrevo/brevo-go v1.1.3 - github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-contrib/sse v1.0.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.20.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgx/v5 v5.5.5 // indirect @@ -38,23 +38,23 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect - golang.org/x/arch v0.8.0 // indirect - golang.org/x/net v0.25.0 // indirect + golang.org/x/arch v0.15.0 // indirect + golang.org/x/net v0.38.0 // indirect golang.org/x/sync v0.12.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect - google.golang.org/protobuf v1.34.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1191148..b2ba992 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,25 @@ github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= +github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= +github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= +github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/getbrevo/brevo-go v1.1.3 h1:8TYrhhxbfAJLGArlPzCDKzbNfzvjIykBRhTDzLJqmyw= github.com/getbrevo/brevo-go v1.1.3/go.mod h1:ExhytIoPxt/cOBl6ZEMeEZNLUKrWEYA5U3hM/8WP2bg= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk= +github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0= +github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= +github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -26,14 +28,14 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= +github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -54,8 +56,8 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -70,8 +72,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -87,33 +89,29 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= -golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= +golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -125,4 +123,3 @@ gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSk gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= From a0db8c3af9c9d03e99a4fd427545534a3986d87b Mon Sep 17 00:00:00 2001 From: cassius Date: Sun, 6 Apr 2025 00:02:29 -0300 Subject: [PATCH 48/67] refactor: :recycle: phone number more testable --- internal/domain/object-values/phone-number.go | 6 +----- internal/domain/object-values/phone-number_test.go | 11 ++++------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/internal/domain/object-values/phone-number.go b/internal/domain/object-values/phone-number.go index f3dfc3e..9fad383 100644 --- a/internal/domain/object-values/phone-number.go +++ b/internal/domain/object-values/phone-number.go @@ -1,8 +1,6 @@ package object_values import ( - "regexp" - "github.com/cassiusbessa/backend-test/internal/domain/shared" ) @@ -10,10 +8,8 @@ type PhoneNumber struct { value string } -var phoneRegex = regexp.MustCompile(`^\+[1-9]\d{7,14}$`) - func NewPhoneNumber(phone string) (PhoneNumber, error) { - if !phoneRegex.MatchString(phone) { + if len(phone) < 8 || len(phone) > 20 { return PhoneNumber{}, shared.ErrValidation } return PhoneNumber{value: phone}, nil diff --git a/internal/domain/object-values/phone-number_test.go b/internal/domain/object-values/phone-number_test.go index db0b26e..e67a8cd 100644 --- a/internal/domain/object-values/phone-number_test.go +++ b/internal/domain/object-values/phone-number_test.go @@ -11,13 +11,10 @@ func TestNewPhoneNumber(t *testing.T) { phone string expectError bool }{ - {"+14155552671", false}, // Válido: EUA - {"+5511987654321", false}, // Válido: Brasil - {"+442071838750", false}, // Válido: Reino Unido - {"5511987654321", true}, // Inválido: Sem '+' - {"+123", true}, // Inválido: Muito curto - {"+abcdefghijk", true}, // Inválido: Letras - {"+11111111111111111", true}, // Inválido: Muito longo (mais de 15 dígitos) + {"+14155552671", false}, // Válido: EUA + {"+5511987654321", false}, // Válido: Brasil + {"+442071838750", false}, // Válido: Reino Unido + {"+123", true}, // Inválido: Muito curto } for _, tt := range tests { From df321c80c9cdd2ae2922a58276932608b59e2dc4 Mon Sep 17 00:00:00 2001 From: cassius Date: Sun, 6 Apr 2025 00:03:03 -0300 Subject: [PATCH 49/67] fix: :bug: handle empty invite code and invite code not valid --- internal/application/use-cases/create-user.go | 18 +++-- .../application/use-cases/create-user_test.go | 69 +++++++++++-------- 2 files changed, 50 insertions(+), 37 deletions(-) diff --git a/internal/application/use-cases/create-user.go b/internal/application/use-cases/create-user.go index 5aeefba..0687269 100644 --- a/internal/application/use-cases/create-user.go +++ b/internal/application/use-cases/create-user.go @@ -1,6 +1,7 @@ package usecases import ( + "log" "strconv" "github.com/cassiusbessa/backend-test/internal/application/dto" @@ -50,23 +51,27 @@ func (uc *CreateUserUseCase) Execute(input dto.CreateUserInput) (dto.CreateUserO func (uc *CreateUserUseCase) createUserDTOToUser(input dto.CreateUserInput) (*entities.User, error) { email, err := object_values.NewEmail(input.Email) if err != nil { + log.Println("Error creating email:", err) return nil, err } password, err := object_values.NewPassword(input.Password) if err != nil { + log.Println("Error creating password:", err) return nil, err } phone, err := object_values.NewPhoneNumber(input.Phone) if err != nil { + log.Println("Error creating phone number:", err) return nil, err } var inviteCode *uuid.UUID - if input.InviteCode != nil { + if input.InviteCode != nil && *input.InviteCode != "" { inviteCodeUUID, err := uuid.Parse(*input.InviteCode) if err != nil { + log.Println("Error parsing invite code:", err) return nil, err } inviteCode = &inviteCodeUUID @@ -74,6 +79,7 @@ func (uc *CreateUserUseCase) createUserDTOToUser(input dto.CreateUserInput) (*en user, err := entities.NewUser(input.Name, email, password, phone, inviteCode) if err != nil { + log.Println("Error creating user:", err) return nil, err } @@ -81,7 +87,7 @@ func (uc *CreateUserUseCase) createUserDTOToUser(input dto.CreateUserInput) (*en } func (uc *CreateUserUseCase) processInviteCode(inviteCode *string) error { - if inviteCode == nil { + if inviteCode == nil || *inviteCode == "" { return nil } @@ -95,17 +101,15 @@ func (uc *CreateUserUseCase) processInviteCode(inviteCode *string) error { } inviter.AddPoint() - err = uc.userRepo.Save(*inviter) - if err != nil { + if err := uc.userRepo.Save(*inviter); err != nil { return shared.ErrInternal } - err = uc.emailService.SendEmail( + if err := uc.emailService.SendEmail( inviter.Email().Value(), "New user invited by you", uc.emailConfirmationToInviterBody(*inviter), - ) - if err != nil { + ); err != nil { return shared.ErrInternal } diff --git a/internal/application/use-cases/create-user_test.go b/internal/application/use-cases/create-user_test.go index 9172c25..f029920 100644 --- a/internal/application/use-cases/create-user_test.go +++ b/internal/application/use-cases/create-user_test.go @@ -1,6 +1,7 @@ package usecases import ( + "strings" "testing" "github.com/cassiusbessa/backend-test/internal/application/dto" @@ -131,7 +132,7 @@ func TestCreateUserUseCase_Execute(t *testing.T) { }) t.Run("invited user", func(t *testing.T) { - mockRepo, _, useCase := setupTest() + mockRepo, mockEmail, useCase := setupTest() validEmail, _ := object_values.NewEmail("inviter@example.com") validPhone, _ := object_values.NewPhoneNumber("+5511987654321") @@ -159,54 +160,62 @@ func TestCreateUserUseCase_Execute(t *testing.T) { mockRepo.On("FindByEmail", input.Email).Return(nil, nil) mockRepo.On("FindByInviteCode", *input.InviteCode).Return(&inviter, nil) - mockRepo.On("Save", mock.MatchedBy(func(u entities.User) bool { - return u.ID() == inviter.ID() && u.Points() == 2 - })).Return(nil).Once() - - mockRepo.On("Save", mock.MatchedBy(func(u entities.User) bool { - return u.Email().Value() == input.Email - })).Return(nil).Once() + mockRepo.On("Save", mock.Anything).Return(nil) + mockEmail.On( + "SendEmail", + inviter.Email().Value(), + "New user invited by you", + mock.MatchedBy(func(body string) bool { + return strings.Contains(body, "Hello Inviter") && + strings.Contains(body, "points") && + strings.Contains(body, "bvio") + }), + ).Return(nil).Once() output, err := useCase.Execute(input) assert.NoError(t, err) assert.NotEmpty(t, output.UserID) mockRepo.AssertExpectations(t) + mockEmail.AssertExpectations(t) }) - t.Run("invited user", func(t *testing.T) { + t.Run("should return error if invite code is invalid", func(t *testing.T) { mockRepo, mockEmail, useCase := setupTest() - validEmail, _ := object_values.NewEmail("inviter@example.com") - validPhone, _ := object_values.NewPhoneNumber("+5511987654321") - validPassword, _ := object_values.NewPassword("StrongP@ssw0rd") - inviterCode := uuid.NewString() - - inviter := entities.LoadUser( - uuid.New(), - "Inviter", - validEmail, - validPassword, - validPhone, - inviterCode, - nil, - 1, - ) + badCode := "invalid-uuid" input := dto.CreateUserInput{ - Name: "New User", - Email: "newuser@example.com", + Name: "Errored User", + Email: "errored@example.com", Password: "StrongP@ssw0rd", Phone: "+5511999999999", - InviteCode: &inviterCode, + InviteCode: &badCode, } - expectedEmailBody := useCase.emailConfirmationToInviterBody(inviter) + output, err := useCase.Execute(input) + + assert.Error(t, err) + assert.Empty(t, output.UserID) + mockRepo.AssertExpectations(t) + mockEmail.AssertExpectations(t) + }) + + t.Run("should handle empty string invite code gracefully", func(t *testing.T) { + mockRepo, mockEmail, useCase := setupTest() + + emptyCode := "" + + input := dto.CreateUserInput{ + Name: "User Without Code", + Email: "userwithout@example.com", + Password: "StrongP@ssw0rd", + Phone: "+5511999999999", + InviteCode: &emptyCode, + } mockRepo.On("FindByEmail", input.Email).Return(nil, nil) - mockRepo.On("FindByInviteCode", *input.InviteCode).Return(&inviter, nil) mockRepo.On("Save", mock.Anything).Return(nil) - mockEmail.On("SendEmail", inviter.Email().Value(), "New user invited by you", expectedEmailBody).Return(nil).Once() output, err := useCase.Execute(input) From 59fae14f3567cea35096bbafb488667744458d4d Mon Sep 17 00:00:00 2001 From: cassius Date: Sun, 6 Apr 2025 00:39:27 -0300 Subject: [PATCH 50/67] front end base --- .bolt/config.json | 3 + .bolt/ignore | 2 + .bolt/prompt | 11 + .eslintrc.json | 3 + .gitignore | 35 + app/dashboard/page.tsx | 133 + app/globals.css | 26 + app/layout.tsx | 22 + app/page.tsx | 45 + components.json | 20 + components/LoginForm.tsx | 62 + components/RegisterForm.tsx | 108 + components/ui/accordion.tsx | 58 + components/ui/alert-dialog.tsx | 141 + components/ui/alert.tsx | 59 + components/ui/aspect-ratio.tsx | 7 + components/ui/avatar.tsx | 50 + components/ui/badge.tsx | 36 + components/ui/breadcrumb.tsx | 115 + components/ui/button.tsx | 56 + components/ui/calendar.tsx | 66 + components/ui/card.tsx | 86 + components/ui/carousel.tsx | 262 ++ components/ui/chart.tsx | 365 ++ components/ui/checkbox.tsx | 30 + components/ui/collapsible.tsx | 11 + components/ui/command.tsx | 155 + components/ui/context-menu.tsx | 200 + components/ui/dialog.tsx | 122 + components/ui/drawer.tsx | 118 + components/ui/dropdown-menu.tsx | 200 + components/ui/form.tsx | 179 + components/ui/hover-card.tsx | 29 + components/ui/input-otp.tsx | 71 + components/ui/input.tsx | 25 + components/ui/label.tsx | 26 + components/ui/menubar.tsx | 236 + components/ui/navigation-menu.tsx | 128 + components/ui/pagination.tsx | 117 + components/ui/popover.tsx | 31 + components/ui/progress.tsx | 28 + components/ui/radio-group.tsx | 44 + components/ui/resizable.tsx | 45 + components/ui/scroll-area.tsx | 48 + components/ui/select.tsx | 160 + components/ui/separator.tsx | 31 + components/ui/sheet.tsx | 140 + components/ui/skeleton.tsx | 15 + components/ui/slider.tsx | 28 + components/ui/sonner.tsx | 31 + components/ui/switch.tsx | 29 + components/ui/table.tsx | 117 + components/ui/tabs.tsx | 55 + components/ui/textarea.tsx | 24 + components/ui/toast.tsx | 129 + components/ui/toaster.tsx | 35 + components/ui/toggle-group.tsx | 61 + components/ui/toggle.tsx | 45 + components/ui/tooltip.tsx | 30 + hooks/use-toast.ts | 191 + lib/services/api.ts | 35 + lib/utils.ts | 6 + next.config.js | 10 + package-lock.json | 7259 +++++++++++++++++++++++++++++ package.json | 72 + postcss.config.js | 6 + tailwind.config.ts | 90 + tsconfig.json | 27 + 68 files changed, 12240 insertions(+) create mode 100644 .bolt/config.json create mode 100644 .bolt/ignore create mode 100644 .bolt/prompt create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 app/dashboard/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/page.tsx create mode 100644 components.json create mode 100644 components/LoginForm.tsx create mode 100644 components/RegisterForm.tsx create mode 100644 components/ui/accordion.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/alert.tsx create mode 100644 components/ui/aspect-ratio.tsx create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/breadcrumb.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 components/ui/chart.tsx create mode 100644 components/ui/checkbox.tsx create mode 100644 components/ui/collapsible.tsx create mode 100644 components/ui/command.tsx create mode 100644 components/ui/context-menu.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/drawer.tsx create mode 100644 components/ui/dropdown-menu.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/hover-card.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/menubar.tsx create mode 100644 components/ui/navigation-menu.tsx create mode 100644 components/ui/pagination.tsx create mode 100644 components/ui/popover.tsx create mode 100644 components/ui/progress.tsx create mode 100644 components/ui/radio-group.tsx create mode 100644 components/ui/resizable.tsx create mode 100644 components/ui/scroll-area.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/separator.tsx create mode 100644 components/ui/sheet.tsx create mode 100644 components/ui/skeleton.tsx create mode 100644 components/ui/slider.tsx create mode 100644 components/ui/sonner.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/toggle-group.tsx create mode 100644 components/ui/toggle.tsx create mode 100644 components/ui/tooltip.tsx create mode 100644 hooks/use-toast.ts create mode 100644 lib/services/api.ts create mode 100644 lib/utils.ts create mode 100644 next.config.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 tailwind.config.ts create mode 100644 tsconfig.json diff --git a/.bolt/config.json b/.bolt/config.json new file mode 100644 index 0000000..f236591 --- /dev/null +++ b/.bolt/config.json @@ -0,0 +1,3 @@ +{ + "template": "nextjs-shadcn" +} diff --git a/.bolt/ignore b/.bolt/ignore new file mode 100644 index 0000000..bbe3a15 --- /dev/null +++ b/.bolt/ignore @@ -0,0 +1,2 @@ +components/ui/* +hooks/use-toast.ts diff --git a/.bolt/prompt b/.bolt/prompt new file mode 100644 index 0000000..3d0f7d3 --- /dev/null +++ b/.bolt/prompt @@ -0,0 +1,11 @@ +For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production. + +When using client-side hooks (useState and useEffect) in a component that's being treated as a Server Component by Next.js, always add the "use client" directive at the top of the file. + +Do not write code that will trigger this error: "Warning: Extra attributes from the server: %s%s""class,style" + +By default, this template supports JSX syntax with Tailwind CSS classes, the shadcn/ui library, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them. + +Use icons from lucide-react for logos. + +Use stock photos from unsplash where appropriate, only valid URLs you know exist. diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f322f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..dc4337e --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,133 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Leaf, Copy, Crown } from 'lucide-react'; +import { getMe, getRanking } from '@/lib/services/api'; + +interface User { + id: string; + name: string; + email: string; + phone: string; +} + +interface RankingUser { + user_id: string; + name: string; + points: number; +} + +export default function Dashboard() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [ranking, setRanking] = useState([]); + const [copied, setCopied] = useState(false); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (!token) { + router.push('/'); + return; + } + + const fetchData = async () => { + try { + const [userData, rankingData] = await Promise.all([ + getMe(token), + getRanking(), + ]); + setUser(userData); + setRanking(rankingData); + } catch (err) { + localStorage.removeItem('token'); + router.push('/'); + } + }; + + fetchData(); + }, [router]); + + const handleCopyInviteCode = () => { + if (user) { + navigator.clipboard.writeText(user.id); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + if (!user) return null; + + return ( +
+
+
+
+ +

vbio

+
+ +
+
+ +
+
+
+

Seu perfil

+
+

Nome: {user.name}

+

Email: {user.email}

+

Telefone: {user.phone}

+
+

Seu código de convite:

+ + {copied && ( +

Código copiado!

+ )} +
+
+
+ +
+

+ + Ranking +

+
+ {ranking.map((rank, index) => ( +
+
+ {index + 1}º + {rank.name} +
+ {rank.points} pontos +
+ ))} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..8179b52 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,26 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: rgb(252, 252, 252); + --foreground: rgb(48, 43, 41); + --accent: rgb(176, 122, 10); + --accent-hover: rgb(140, 98, 8); + --muted: rgb(245, 245, 245); + --border: rgb(229, 229, 229); +} + +body { + background-color: var(--background); + color: var(--foreground); +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..092954c --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,22 @@ +import './globals.css'; +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata: Metadata = { + title: 'Create Next App', + description: 'Generated by create next app', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..4a2672b --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Leaf } from 'lucide-react'; +import LoginForm from '@/components/LoginForm'; +import RegisterForm from '@/components/RegisterForm'; + +export default function Home() { + const [isLogin, setIsLogin] = useState(true); + const router = useRouter(); + + const handleSuccess = (token: string) => { + localStorage.setItem('token', token); + router.push('/dashboard'); + }; + + return ( +
+
+ +
+
+ +

vbio

+
+ + {isLogin ? ( + + ) : ( + setIsLogin(true)} /> + )} + +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/components.json b/components.json new file mode 100644 index 0000000..c597462 --- /dev/null +++ b/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx new file mode 100644 index 0000000..e7d6250 --- /dev/null +++ b/components/LoginForm.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { useState } from 'react'; +import { login } from '@/lib/services/api'; + +export default function LoginForm({ onSuccess }: { onSuccess: (token: string) => void }) { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const { token } = await login(email, password); + onSuccess(token); + } catch (err) { + setError('Email ou senha inválidos'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ + setEmail(e.target.value)} + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--accent)]" + required + /> +
+ +
+ + setPassword(e.target.value)} + className="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-[var(--accent)]" + required + /> +
+ + {error &&

{error}

} + + +
+ ); +} \ No newline at end of file diff --git a/components/RegisterForm.tsx b/components/RegisterForm.tsx new file mode 100644 index 0000000..461de01 --- /dev/null +++ b/components/RegisterForm.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useState } from 'react'; +import { createUser } from '@/lib/services/api'; + +export default function RegisterForm({ onSuccess }: { onSuccess: () => void }) { + const [formData, setFormData] = useState({ + name: '', + email: '', + password: '', + phone: '', + invite_code: '', + }); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + await createUser(formData); + onSuccess(); + } catch (err) { + setError('Erro ao criar conta. Verifique os dados e tente novamente.'); + } finally { + setLoading(false); + } + }; + + const handleChange = (e: React.ChangeEvent) => { + setFormData({ ...formData, [e.target.name]: e.target.value }); + }; + + return ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {error &&

{error}

} + + +
+ ); +} \ No newline at end of file diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..84bf2eb --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +'use client'; + +import * as React from 'react'; +import * as AccordionPrimitive from '@radix-ui/react-accordion'; +import { ChevronDown } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..5cba559 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +'use client'; + +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; + +const AlertDialog = AlertDialogPrimitive.Root; + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +const AlertDialogPortal = AlertDialogPrimitive.Portal; + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogHeader.displayName = 'AlertDialogHeader'; + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +AlertDialogFooter.displayName = 'AlertDialogFooter'; + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..d2b59cc --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const alertVariants = cva( + 'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = 'Alert'; + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = 'AlertTitle'; + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = 'AlertDescription'; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..aaabffb --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +'use client'; + +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'; + +const AspectRatio = AspectRatioPrimitive.Root; + +export { AspectRatio }; diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..1346957 --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +'use client'; + +import * as React from 'react'; +import * as AvatarPrimitive from '@radix-ui/react-avatar'; + +import { cn } from '@/lib/utils'; + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Avatar.displayName = AvatarPrimitive.Root.displayName; + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarImage.displayName = AvatarPrimitive.Image.displayName; + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..2eb790a --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/components/ui/breadcrumb.tsx b/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..8b62197 --- /dev/null +++ b/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; +import { ChevronRight, MoreHorizontal } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<'nav'> & { + separator?: React.ReactNode; + } +>(({ ...props }, ref) =>