diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 672ca72..1991375 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,14 +45,6 @@ jobs: - name: Run go vet run: go vet ./... - - name: Check formatting - run: | - if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then - echo "The following files are not properly formatted:" - gofmt -s -l . - exit 1 - fi - - name: Run linting with our script run: ./run_tests.sh lint diff --git a/app/main.go b/app/main.go index c7eebea..f626a9f 100644 --- a/app/main.go +++ b/app/main.go @@ -12,6 +12,8 @@ import ( "github.com/aws/aws-lambda-go/lambda" ginadapter "github.com/awslabs/aws-lambda-go-api-proxy/gin" "github.com/gin-gonic/gin" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/handler" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/repository" usr_router "github.com/williamkoller/cloud-architecture-golang/internal/usr/router" ) @@ -23,12 +25,15 @@ var ( func init() { gin.SetMode(gin.ReleaseMode) router = gin.Default() + api := router.Group("/api") router.GET("/health", func(c *gin.Context) { c.String(http.StatusOK, "OK") }) - usr_router.RegisterUserRoutes(router) + userRepo := repository.NewInMemoryUserRepository() + useHandler := handler.NewUserHandler(userRepo) + usr_router.RegisterUserRoutes(api, useHandler) ginLambdaV2 = ginadapter.NewV2(router) } diff --git a/internal/usr/domain/usr.go b/internal/usr/domain/usr.go new file mode 100644 index 0000000..5feea6d --- /dev/null +++ b/internal/usr/domain/usr.go @@ -0,0 +1,70 @@ +package domain + +import ( + "fmt" + "strings" + + "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain/vo" +) + +type UserType string + +const ( + UserTypeAdmin UserType = "Admin" + UserTypeUser UserType = "User" +) + +type User struct { + Name string + Email vo.Email + Password vo.Password + Active bool + UserType UserType +} + +func (u User) Validate() error { + var missing []string + + if strings.TrimSpace(u.Name) == "" { + missing = append(missing, "Name") + } + if u.Email == "" { + missing = append(missing, "Email") + } + if u.Password == "" { + missing = append(missing, "Password") + } + if u.UserType == "" { + missing = append(missing, "UserType") + } + + if len(missing) > 0 { + return fmt.Errorf("the following fields are required: %s", strings.Join(missing, ", ")) + } + return nil +} + +func NewUser(name, emailRaw, passRaw string, active bool, userType UserType) (User, error) { + email, err := vo.NewEmail(emailRaw) + if err != nil { + return User{}, err + } + + pass, err := vo.NewPassword(passRaw) + if err != nil { + return User{}, err + } + u := User{ + Name: name, + Email: email, + Password: pass, + Active: active, + UserType: userType, + } + + if err := u.Validate(); err != nil { + return User{}, err + } + + return u, nil +} diff --git a/internal/usr/domain/usr_test.go b/internal/usr/domain/usr_test.go new file mode 100644 index 0000000..be24ac1 --- /dev/null +++ b/internal/usr/domain/usr_test.go @@ -0,0 +1,97 @@ +package domain + +import ( + "strings" + "testing" + + "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain/vo" +) + +func TestUserValidate_MissingFields_OrderAndMessage(t *testing.T) { + u := User{ + Name: " ", // TrimSpace → vazio + Email: vo.Email(""), // vazio + Password: vo.Password(""), // vazio + Active: false, // não é obrigatório + UserType: UserType(""), // vazio + } + + err := u.Validate() + if err == nil { + t.Fatalf("expected error for missing fields, got nil") + } + + got := err.Error() + want := "the following fields are required: Name, Email, Password, UserType" + if got != want { + t.Fatalf("error message mismatch:\n got: %q\nwant: %q", got, want) + } +} + +func TestUserValidate_Success(t *testing.T) { + u := User{ + Name: "Ana", + Email: vo.Email("ana@example.com"), + Password: vo.Password("$2a$10$fakehashjustforvalidate"), // só precisa ser não-vazio + Active: true, + UserType: UserTypeUser, + } + + if err := u.Validate(); err != nil { + t.Fatalf("Validate() unexpected error: %v", err) + } +} + +func TestNewUser_Success(t *testing.T) { + u, err := NewUser("Ana", "ana@example.com", "secret123", true, UserTypeAdmin) + if err != nil { + t.Fatalf("NewUser unexpected error: %v", err) + } + + if u.Name != "Ana" { + t.Fatalf("Name: got %q, want %q", u.Name, "Ana") + } + if string(u.Email) != "ana@example.com" { + t.Fatalf("Email: got %q, want %q", string(u.Email), "ana@example.com") + } + + // >>> Correções aqui: não comparar com texto puro <<< + // 1) Não deve ser igual ao raw + if string(u.Password) == "secret123" { + t.Fatalf("Password must be hashed, but equals raw password") + } + // 2) Deve ter cara de bcrypt + if !strings.HasPrefix(string(u.Password), "$2") { + t.Fatalf("Password hash does not look like bcrypt: %q", string(u.Password)) + } + // 3) Compare deve validar a senha crua + if ok := u.Password.Compare("secret123"); !ok { + t.Fatalf("Password.Compare should return true for the correct raw password") + } + + if u.Active != true { + t.Fatalf("Active: got %v, want %v", u.Active, true) + } + if u.UserType != UserTypeAdmin { + t.Fatalf("UserType: got %v, want %v", u.UserType, UserTypeAdmin) + } +} + +func TestNewUser_InvalidEmail_ReturnsError(t *testing.T) { + // Email malformado para forçar erro em vo.NewEmail + if _, err := NewUser("Ana", "invalid-email", "secret123", true, UserTypeUser); err == nil { + t.Fatalf("expected error for invalid email, got nil") + } +} + +func TestNewUser_EmptyPassword_ReturnsError(t *testing.T) { + if _, err := NewUser("Ana", "ana@example.com", "", true, UserTypeUser); err == nil { + t.Fatalf("expected error for empty password, got nil") + } +} + +func TestNewUser_NameOnlySpaces_ReturnsError(t *testing.T) { + if _, err := NewUser(strings.Repeat(" ", 3), "ana@example.com", "secret123", true, UserTypeUser); err == nil { + t.Fatalf("expected error for name with only spaces, got nil") + } +} diff --git a/internal/usr/domain/vo/email.go b/internal/usr/domain/vo/email.go new file mode 100644 index 0000000..c186add --- /dev/null +++ b/internal/usr/domain/vo/email.go @@ -0,0 +1,39 @@ +package vo + +import ( + "errors" + "fmt" + "net/mail" + "strings" +) + +type Email string + +func NewEmail(value string) (Email, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", errors.New("invalid email: empty") + } + + addr, err := mail.ParseAddress(trimmed) + if err != nil { + return "", fmt.Errorf("invalid email: %w", err) + } + + addrSpec := trimmed + if i := strings.Index(trimmed, "<"); i != -1 { + if j := strings.Index(trimmed[i:], ">"); j != -1 { + addrSpec = strings.TrimSpace(trimmed[i+1 : i+j]) + } + } + + if strings.ContainsAny(addrSpec, " \t\r\n") { + return "", errors.New("invalid email: whitespace inside address") + } + + return Email(addr.Address), nil +} + +func (e Email) String() string { + return string(e) +} diff --git a/internal/usr/domain/vo/email_test.go b/internal/usr/domain/vo/email_test.go new file mode 100644 index 0000000..c54b3a4 --- /dev/null +++ b/internal/usr/domain/vo/email_test.go @@ -0,0 +1,63 @@ +package vo + +import ( + "testing" +) + +func TestNewEmail_ValidAddresses(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "simple address", + input: "ana@example.com", + want: "ana@example.com", + }, + { + name: "with display name", + input: "Ana Silva ", + want: "ana@example.com", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + got, err := NewEmail(tc.input) + if err != nil { + t.Fatalf("NewEmail(%q) unexpected error: %v", tc.input, err) + } + if string(got) != tc.want { + t.Fatalf("email value: got %q, want %q", string(got), tc.want) + } + }) + } +} + +func TestNewEmail_InvalidAddresses(t *testing.T) { + invalids := []string{ + "", + "plainaddress", + "ana@", + "@example.com", + "ana@example,com", + "ana@ example.com", + "ana example@example", + } + + for _, in := range invalids { + _, err := NewEmail(in) + if err == nil { + t.Fatalf("NewEmail(%q) expected error, got nil", in) + } + } +} + +func TestEmail_String(t *testing.T) { + e := Email("user@example.com") + if got := e.String(); got != "user@example.com" { + t.Fatalf("String(): got %q, want %q", got, "user@example.com") + } +} diff --git a/internal/usr/domain/vo/password.go b/internal/usr/domain/vo/password.go new file mode 100644 index 0000000..b4daecd --- /dev/null +++ b/internal/usr/domain/vo/password.go @@ -0,0 +1,31 @@ +package vo + +import ( + "errors" + + "golang.org/x/crypto/bcrypt" +) + +type Password string + +func NewPassword(raw string) (Password, error) { + if len(raw) < 6 { + return "", errors.New("password must be at least 6 characters long") + } + + hash, err := bcrypt.GenerateFromPassword([]byte(raw), bcrypt.DefaultCost) + if err != nil { + return "", err + } + + return Password(hash), nil +} + +func (p Password) Compare(raw string) bool { + err := bcrypt.CompareHashAndPassword([]byte(p), []byte(raw)) + return err == nil +} + +func (p Password) String() string { + return string(p) +} diff --git a/internal/usr/domain/vo/password_test.go b/internal/usr/domain/vo/password_test.go new file mode 100644 index 0000000..a7e9913 --- /dev/null +++ b/internal/usr/domain/vo/password_test.go @@ -0,0 +1,86 @@ +package vo + +import ( + "strings" + "testing" +) + +func TestNewPassword_HashAndCompare(t *testing.T) { + raw := "secret123" + + p, err := NewPassword(raw) + if err != nil { + t.Fatalf("NewPassword error: %v", err) + } + + // Não deve ser o texto puro + if string(p) == raw { + t.Fatalf("hash must not equal raw password") + } + // Deve parecer um hash bcrypt ($2*) + if !strings.HasPrefix(string(p), "$2") { + t.Fatalf("hash does not look like bcrypt: %q", string(p)) + } + + // Compare correto/errado + if !p.Compare(raw) { + t.Fatalf("Compare with correct password should be true") + } + if p.Compare("wrongpass") { + t.Fatalf("Compare with wrong password should be false") + } +} + +func TestNewPassword_MinLength(t *testing.T) { + _, err := NewPassword("12345") // < 6 + if err == nil { + t.Fatalf("expected error for short password, got nil") + } + if !strings.Contains(err.Error(), "at least 6") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestCompare_InvalidOrZeroValue(t *testing.T) { + // Hash inválido não deve panicar e deve retornar false + var invalid Password = "not-a-bcrypt-hash" + if invalid.Compare("anything") { + t.Fatalf("Compare on invalid hash should be false") + } + + // Zero value + var zero Password + if zero.Compare("secret") { + t.Fatalf("Compare on zero value should be false") + } +} + +func TestNewPassword_ProducesDifferentHashes(t *testing.T) { + raw := "samepassword" + + p1, err := NewPassword(raw) + if err != nil { + t.Fatalf("NewPassword #1: %v", err) + } + p2, err := NewPassword(raw) + if err != nil { + t.Fatalf("NewPassword #2: %v", err) + } + + // Bcrypt usa salt → hashes devem diferir praticamente sempre + if string(p1) == string(p2) { + t.Fatalf("hashes for the same password should differ (got equal)") + } + + // Ambos devem validar o raw + if !p1.Compare(raw) || !p2.Compare(raw) { + t.Fatalf("both hashes should validate the original password") + } +} + +func TestPassword_String(t *testing.T) { + p := Password("$2a$10$somesalthash...............") + if got := p.String(); got != string(p) { + t.Fatalf("String() = %q, want %q", got, string(p)) + } +} diff --git a/internal/usr/dtos/dtos_test.go b/internal/usr/dtos/dtos_test.go new file mode 100644 index 0000000..0cf895b --- /dev/null +++ b/internal/usr/dtos/dtos_test.go @@ -0,0 +1,146 @@ +package dtos + +import ( + "testing" + + "github.com/go-playground/validator/v10" +) + +func newValidator() *validator.Validate { + v := validator.New() + v.SetTagName("binding") + return v +} + +func TestCreateUserRequest_Valid(t *testing.T) { + v := newValidator() + + req := CreateUserRequest{ + Name: "Ana", + Email: "ana@example.com", + Password: "secret123", + Active: nil, + UserType: "Admin", + } + + if err := v.Struct(req); err != nil { + t.Fatalf("expected valid CreateUserRequest, got error: %v", err) + } + + req.UserType = "User" + if err := v.Struct(req); err != nil { + t.Fatalf("expected valid CreateUserRequest (User type), got error: %v", err) + } +} + +func TestCreateUserRequest_InvalidCases(t *testing.T) { + v := newValidator() + + tests := []struct { + name string + req CreateUserRequest + }{ + { + name: "missing all required", + req: CreateUserRequest{}, + }, + { + name: "invalid email", + req: CreateUserRequest{ + Name: "Ana", + Email: "invalid-email", + Password: "secret123", + UserType: "User", + }, + }, + { + name: "short password", + req: CreateUserRequest{ + Name: "Ana", + Email: "ana@example.com", + Password: "12345", // < 6 + UserType: "User", + }, + }, + { + name: "invalid user type", + req: CreateUserRequest{ + Name: "Ana", + Email: "ana@example.com", + Password: "secret123", + UserType: "Root", + }, + }, + { + name: "empty name", + req: CreateUserRequest{ + Name: "", + Email: "ana@example.com", + Password: "secret123", + UserType: "User", + }, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + if err := v.Struct(tc.req); err == nil { + t.Fatalf("expected validation error, got nil") + } + }) + } +} + +func TestUpdateUserRequest_AllOptionalNil_IsValid(t *testing.T) { + v := newValidator() + + req := UpdateUserRequest{ + Name: nil, + Password: nil, + Active: nil, + UserType: nil, + } + + if err := v.Struct(req); err != nil { + t.Fatalf("expected valid UpdateUserRequest (all nil), got error: %v", err) + } +} + +func TestUpdateUserRequest_FieldRules(t *testing.T) { + v := newValidator() + + name := "" + req := UpdateUserRequest{Name: &name} + if err := v.Struct(req); err == nil { + t.Fatalf("expected error when Name is present but empty") + } + + pass := "12345" + req = UpdateUserRequest{Password: &pass} + if err := v.Struct(req); err == nil { + t.Fatalf("expected error when Password is present but short") + } + + ut := "Root" + req = UpdateUserRequest{UserType: &ut} + if err := v.Struct(req); err == nil { + t.Fatalf("expected error when UserType is invalid") + } + + validName := "Ana" + validPass := "secret123" + validUT := "Admin" + active := new(bool) + *active = true + + req = UpdateUserRequest{ + Name: &validName, + Password: &validPass, + Active: active, + UserType: &validUT, + } + if err := v.Struct(req); err != nil { + t.Fatalf("expected valid UpdateUserRequest with all fields present, got error: %v", err) + } +} diff --git a/internal/usr/dtos/usr_dto.go b/internal/usr/dtos/usr_dto.go new file mode 100644 index 0000000..73477c1 --- /dev/null +++ b/internal/usr/dtos/usr_dto.go @@ -0,0 +1,16 @@ +package dtos + +type CreateUserRequest struct { + Name string `json:"name" binding:"required,min=1"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` + Active *bool `json:"active"` + UserType string `json:"userType" binding:"required,oneof=Admin User"` +} + +type UpdateUserRequest struct { + Name *string `json:"name" binding:"omitempty,min=1"` + Password *string `json:"password" binding:"omitempty,min=6"` + Active *bool `json:"active"` + UserType *string `json:"userType" binding:"omitempty,oneof=Admin User"` +} diff --git a/internal/usr/handler/usr_handler.go b/internal/usr/handler/usr_handler.go new file mode 100644 index 0000000..207bfc9 --- /dev/null +++ b/internal/usr/handler/usr_handler.go @@ -0,0 +1,196 @@ +package handler + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain/vo" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/dtos" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/mappers" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/repository" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/validation" +) + +type UserHandler struct { + repo repository.UserRepository +} + +func NewUserHandler(repo repository.UserRepository) *UserHandler { + return &UserHandler{repo: repo} +} + +// Timeoutzinho helper para operações rápidas +func (h *UserHandler) ctx(c *gin.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(c.Request.Context(), 2*time.Second) +} + +func (h *UserHandler) CreateUser(c *gin.Context) { + var req dtos.CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + validation.RespondValidationError(c, err) + return + } + + active := true + if req.Active != nil { + active = *req.Active + } + + u, err := domain.NewUser( + strings.TrimSpace(req.Name), + strings.TrimSpace(req.Email), + req.Password, + active, + domain.UserType(strings.TrimSpace(req.UserType)), + ) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + + ctx, cancel := h.ctx(c) + defer cancel() + + if err := h.repo.Create(ctx, u); err != nil { + if err == repository.ErrAlreadyExists { + c.JSON(http.StatusConflict, gin.H{"error": "user already exists"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, mappers.ToUserResponse(u)) +} + +func (h *UserHandler) ListUsers(c *gin.Context) { + ctx, cancel := h.ctx(c) + defer cancel() + + users, err := h.repo.List(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + resp := make([]mappers.UserResponse, 0, len(users)) + for _, u := range users { + resp = append(resp, mappers.ToUserResponse(u)) + } + c.JSON(http.StatusOK, resp) +} + +func (h *UserHandler) GetUser(c *gin.Context) { + emailParam := strings.TrimSpace(c.Param("email")) + email, err := vo.NewEmail(emailParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email"}) + return + } + + ctx, cancel := h.ctx(c) + defer cancel() + + u, ok, err := h.repo.GetByEmail(ctx, email) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + c.JSON(http.StatusOK, mappers.ToUserResponse(u)) +} + +func (h *UserHandler) UpdateUser(c *gin.Context) { + emailParam := strings.TrimSpace(c.Param("email")) + email, err := vo.NewEmail(emailParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email"}) + return + } + + var req dtos.UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + validation.RespondValidationError(c, err) + return + } + + ctx, cancel := h.ctx(c) + defer cancel() + + current, ok, err := h.repo.GetByEmail(ctx, email) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if !ok { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + // aplica mudanças parciais + name := current.Name + if req.Name != nil { + name = strings.TrimSpace(*req.Name) + } + active := current.Active + if req.Active != nil { + active = *req.Active + } + userType := current.UserType + if req.UserType != nil { + userType = domain.UserType(strings.TrimSpace(*req.UserType)) + } + password := string(current.Password) // vo.Password é alias de string + if req.Password != nil { + password = *req.Password + } + + updated, err := domain.NewUser(name, string(email), password, active, userType) + if err != nil { + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) + return + } + + if err := h.repo.Update(ctx, updated); err != nil { + if err == repository.ErrNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, mappers.ToUserResponse(updated)) +} + +func (h *UserHandler) DeleteUser(c *gin.Context) { + emailParam := strings.TrimSpace(c.Param("email")) + email, err := vo.NewEmail(emailParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid email"}) + return + } + + ctx, cancel := h.ctx(c) + defer cancel() + + if err := h.repo.Delete(ctx, email); err != nil { + if err == repository.ErrNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/internal/usr/handler/usr_handler_test.go b/internal/usr/handler/usr_handler_test.go new file mode 100644 index 0000000..6e71cac --- /dev/null +++ b/internal/usr/handler/usr_handler_test.go @@ -0,0 +1,463 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain/vo" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/mappers" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/repository" +) + +// ---- stub repo ---- + +type stubRepo struct { + createFn func(ctx context.Context, u domain.User) error + getFn func(ctx context.Context, email vo.Email) (domain.User, bool, error) + listFn func(ctx context.Context) ([]domain.User, error) + updateFn func(ctx context.Context, u domain.User) error + deleteFn func(ctx context.Context, email vo.Email) error +} + +func (s *stubRepo) Create(ctx context.Context, u domain.User) error { + if s.createFn != nil { + return s.createFn(ctx, u) + } + return nil +} +func (s *stubRepo) GetByEmail(ctx context.Context, email vo.Email) (domain.User, bool, error) { + if s.getFn != nil { + return s.getFn(ctx, email) + } + return domain.User{}, false, nil +} +func (s *stubRepo) List(ctx context.Context) ([]domain.User, error) { + if s.listFn != nil { + return s.listFn(ctx) + } + return nil, nil +} +func (s *stubRepo) Update(ctx context.Context, u domain.User) error { + if s.updateFn != nil { + return s.updateFn(ctx, u) + } + return nil +} +func (s *stubRepo) Delete(ctx context.Context, email vo.Email) error { + if s.deleteFn != nil { + return s.deleteFn(ctx, email) + } + return nil +} + + +func routerWithUserRoutes(h *UserHandler) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/users", h.CreateUser) + r.GET("/users", h.ListUsers) + r.GET("/users/:email", h.GetUser) + r.PATCH("/users/:email", h.UpdateUser) + r.DELETE("/users/:email", h.DeleteUser) + return r +} + +func doJSON(t *testing.T, r http.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var buf *bytes.Buffer + if body != nil { + bts, err := json.Marshal(body) + if err != nil { + t.Fatalf("marshal body: %v", err) + } + buf = bytes.NewBuffer(bts) + } else { + buf = bytes.NewBuffer(nil) + } + req := httptest.NewRequest(method, path, buf) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func mustUser(t *testing.T, name, email string, active bool, ut domain.UserType) domain.User { + t.Helper() + u, err := domain.NewUser(name, email, "secret123", active, ut) + if err != nil { + t.Fatalf("NewUser: %v", err) + } + return u +} + + +func TestCreateUser_Success(t *testing.T) { + var captured domain.User + repo := &stubRepo{ + createFn: func(ctx context.Context, u domain.User) error { + captured = u + return nil + }, + } + h := NewUserHandler(repo) + r := routerWithUserRoutes(h) + + body := map[string]any{ + "name": "Ana", + "email": "ana@example.com", + "password": "secret123", + "userType": "User", + } + w := doJSON(t, r, http.MethodPost, "/users", body) + + if w.Code != http.StatusCreated { + t.Fatalf("status: got %d, want %d", w.Code, http.StatusCreated) + } + var resp mappers.UserResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Name != "Ana" || resp.Email != "ana@example.com" || resp.UserType != domain.UserTypeUser || resp.Active != true { + t.Fatalf("response mismatch: %+v", resp) + } + // senha deve ser hash (não igual ao raw) + if string(captured.Password) == "secret123" || !strings.HasPrefix(string(captured.Password), "$2") { + t.Fatalf("password should be bcrypt hash; got %q", string(captured.Password)) + } +} + +func TestCreateUser_Conflict(t *testing.T) { + repo := &stubRepo{ + createFn: func(ctx context.Context, u domain.User) error { + return repository.ErrAlreadyExists + }, + } + h := NewUserHandler(repo) + r := routerWithUserRoutes(h) + + body := map[string]any{ + "name": "Ana", + "email": "ana@example.com", + "password": "secret123", + "userType": "User", + } + w := doJSON(t, r, http.MethodPost, "/users", body) + + if w.Code != http.StatusConflict { + t.Fatalf("status: got %d, want %d", w.Code, http.StatusConflict) + } +} + +func TestCreateUser_BindingError_Returns400(t *testing.T) { + repo := &stubRepo{} + h := NewUserHandler(repo) + r := routerWithUserRoutes(h) + + body := map[string]any{ + "name": "Ana", + } + w := doJSON(t, r, http.MethodPost, "/users", body) + if w.Code != http.StatusBadRequest { + t.Fatalf("status: got %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestCreateUser_DomainError_Returns422(t *testing.T) { + repo := &stubRepo{} + h := NewUserHandler(repo) + r := routerWithUserRoutes(h) + + body := map[string]any{ + "name": " ", + "email": "ana@example.com", + "password": "secret123", + "userType": "User", + } + w := doJSON(t, r, http.MethodPost, "/users", body) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("status: got %d, want %d", w.Code, http.StatusUnprocessableEntity) + } +} + +func TestCreateUser_InternalError_Returns500(t *testing.T) { + repo := &stubRepo{ + createFn: func(ctx context.Context, u domain.User) error { + return errors.New("boom") + }, + } + h := NewUserHandler(repo) + r := routerWithUserRoutes(h) + + body := map[string]any{ + "name": "Ana", + "email": "ana@example.com", + "password": "secret123", + "userType": "User", + } + w := doJSON(t, r, http.MethodPost, "/users", body) + if w.Code != http.StatusInternalServerError { + t.Fatalf("status: got %d, want %d", w.Code, http.StatusInternalServerError) + } +} + +func TestListUsers_Success(t *testing.T) { + u1 := mustUser(t, "Ana", "ana@example.com", true, domain.UserTypeUser) + u2 := mustUser(t, "Bob", "bob@example.com", false, domain.UserTypeAdmin) + + repo := &stubRepo{ + listFn: func(ctx context.Context) ([]domain.User, error) { + return []domain.User{u1, u2}, nil + }, + } + h := NewUserHandler(repo) + r := routerWithUserRoutes(h) + + w := doJSON(t, r, http.MethodGet, "/users", nil) + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d", w.Code, http.StatusOK) + } + var resp []mappers.UserResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(resp) != 2 { + t.Fatalf("len: got %d, want 2", len(resp)) + } +} + +func TestListUsers_InternalError(t *testing.T) { + repo := &stubRepo{ + listFn: func(ctx context.Context) ([]domain.User, error) { + return nil, errors.New("db down") + }, + } + h := NewUserHandler(repo) + r := routerWithUserRoutes(h) + + w := doJSON(t, r, http.MethodGet, "/users", nil) + if w.Code != http.StatusInternalServerError { + t.Fatalf("status: got %d, want %d", w.Code, http.StatusInternalServerError) + } +} + +func TestGetUser_Success(t *testing.T) { + u := mustUser(t, "Ana", "ana@example.com", true, domain.UserTypeUser) + repo := &stubRepo{ + getFn: func(ctx context.Context, email vo.Email) (domain.User, bool, error) { + return u, true, nil + }, + } + h := NewUserHandler(repo) + r := routerWithUserRoutes(h) + + w := doJSON(t, r, http.MethodGet, "/users/ana@example.com", nil) + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d", w.Code, http.StatusOK) + } + var resp mappers.UserResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp.Email != "ana@example.com" { + t.Fatalf("email mismatch: %q", resp.Email) + } +} + +func TestGetUser_InvalidEmail_Returns400(t *testing.T) { + repo := &stubRepo{} + h := NewUserHandler(repo) + r := routerWithUserRoutes(h) + + w := doJSON(t, r, http.MethodGet, "/users/invalid-email", nil) + if w.Code != http.StatusBadRequest { + t.Fatalf("status: got %d, want %d", w.Code, http.StatusBadRequest) + } +} + +func TestGetUser_NotFoundAndInternalError(t *testing.T) { + repoNF := &stubRepo{ + getFn: func(ctx context.Context, email vo.Email) (domain.User, bool, error) { + return domain.User{}, false, nil + }, + } + h1 := NewUserHandler(repoNF) + r1 := routerWithUserRoutes(h1) + if w := doJSON(t, r1, http.MethodGet, "/users/ana@example.com", nil); w.Code != http.StatusNotFound { + t.Fatalf("not found: got %d, want %d", w.Code, http.StatusNotFound) + } + + repoErr := &stubRepo{ + getFn: func(ctx context.Context, email vo.Email) (domain.User, bool, error) { + return domain.User{}, false, errors.New("boom") + }, + } + h2 := NewUserHandler(repoErr) + r2 := routerWithUserRoutes(h2) + if w := doJSON(t, r2, http.MethodGet, "/users/ana@example.com", nil); w.Code != http.StatusInternalServerError { + t.Fatalf("internal: got %d, want %d", w.Code, http.StatusInternalServerError) + } +} + +func TestUpdateUser_Success(t *testing.T) { + current := mustUser(t, "Ana", "ana@example.com", true, domain.UserTypeUser) + + repo := &stubRepo{ + getFn: func(ctx context.Context, email vo.Email) (domain.User, bool, error) { + return current, true, nil + }, + updateFn: func(ctx context.Context, u domain.User) error { + if u.Name != "Ana Paula" || u.Active != false || u.UserType != domain.UserTypeAdmin { + return errors.New("unexpected update data") + } + return nil + }, + } + h := NewUserHandler(repo) + r := routerWithUserRoutes(h) + + body := map[string]any{ + "name": "Ana Paula", + "active": false, + "userType": "Admin", + } + w := doJSON(t, r, http.MethodPatch, "/users/ana@example.com", body) + if w.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d", w.Code, http.StatusOK) + } +} + +func TestUpdateUser_BindError_And_DomainError(t *testing.T) { + current := mustUser(t, "Ana", "ana@example.com", true, domain.UserTypeUser) + + repo1 := &stubRepo{ + getFn: func(ctx context.Context, email vo.Email) (domain.User, bool, error) { + return current, true, nil + }, + } + h1 := NewUserHandler(repo1) + r1 := routerWithUserRoutes(h1) + w1 := doJSON(t, r1, http.MethodPatch, "/users/ana@example.com", map[string]any{ + "password": "123", + }) + if w1.Code != http.StatusBadRequest { + t.Fatalf("bind error: got %d, want %d", w1.Code, http.StatusBadRequest) + } + + repo2 := &stubRepo{ + getFn: func(ctx context.Context, email vo.Email) (domain.User, bool, error) { + return current, true, nil + }, + } + h2 := NewUserHandler(repo2) + r2 := routerWithUserRoutes(h2) + w2 := doJSON(t, r2, http.MethodPatch, "/users/ana@example.com", map[string]any{ + "name": " ", + }) + if w2.Code != http.StatusUnprocessableEntity { + t.Fatalf("domain error: got %d, want %d", w2.Code, http.StatusUnprocessableEntity) + } +} + +func TestUpdateUser_NotFound_And_InternalError(t *testing.T) { + repo1 := &stubRepo{ + getFn: func(ctx context.Context, email vo.Email) (domain.User, bool, error) { + return domain.User{}, false, nil + }, + } + h1 := NewUserHandler(repo1) + r1 := routerWithUserRoutes(h1) + if w := doJSON(t, r1, http.MethodPatch, "/users/ana@example.com", map[string]any{"name": "Ana"}); w.Code != http.StatusNotFound { + t.Fatalf("get not found: got %d, want %d", w.Code, http.StatusNotFound) + } + + repo2 := &stubRepo{ + getFn: func(ctx context.Context, email vo.Email) (domain.User, bool, error) { + return domain.User{}, false, errors.New("boom") + }, + } + h2 := NewUserHandler(repo2) + r2 := routerWithUserRoutes(h2) + if w := doJSON(t, r2, http.MethodPatch, "/users/ana@example.com", map[string]any{"name": "Ana"}); w.Code != http.StatusInternalServerError { + t.Fatalf("get internal: got %d, want %d", w.Code, http.StatusInternalServerError) + } + + current := mustUser(t, "Ana", "ana@example.com", true, domain.UserTypeUser) + repo3 := &stubRepo{ + getFn: func(ctx context.Context, email vo.Email) (domain.User, bool, error) { + return current, true, nil + }, + updateFn: func(ctx context.Context, u domain.User) error { + return repository.ErrNotFound + }, + } + h3 := NewUserHandler(repo3) + r3 := routerWithUserRoutes(h3) + if w := doJSON(t, r3, http.MethodPatch, "/users/ana@example.com", map[string]any{"name": "Ana"}); w.Code != http.StatusNotFound { + t.Fatalf("update not found: got %d, want %d", w.Code, http.StatusNotFound) + } + + repo4 := &stubRepo{ + getFn: func(ctx context.Context, email vo.Email) (domain.User, bool, error) { + return current, true, nil + }, + updateFn: func(ctx context.Context, u domain.User) error { + return errors.New("boom") + }, + } + h4 := NewUserHandler(repo4) + r4 := routerWithUserRoutes(h4) + if w := doJSON(t, r4, http.MethodPatch, "/users/ana@example.com", map[string]any{"name": "Ana"}); w.Code != http.StatusInternalServerError { + t.Fatalf("update internal: got %d, want %d", w.Code, http.StatusInternalServerError) + } +} + +func TestDeleteUser_Scenarios(t *testing.T) { + repo0 := &stubRepo{} + h0 := NewUserHandler(repo0) + r0 := routerWithUserRoutes(h0) + if w := doJSON(t, r0, http.MethodDelete, "/users/invalid-email", nil); w.Code != http.StatusBadRequest { + t.Fatalf("invalid email: got %d, want %d", w.Code, http.StatusBadRequest) + } + + repo1 := &stubRepo{ + deleteFn: func(ctx context.Context, email vo.Email) error { + return repository.ErrNotFound + }, + } + h1 := NewUserHandler(repo1) + r1 := routerWithUserRoutes(h1) + if w := doJSON(t, r1, http.MethodDelete, "/users/ana@example.com", nil); w.Code != http.StatusNotFound { + t.Fatalf("not found: got %d, want %d", w.Code, http.StatusNotFound) + } + + repo2 := &stubRepo{ + deleteFn: func(ctx context.Context, email vo.Email) error { + return errors.New("boom") + }, + } + h2 := NewUserHandler(repo2) + r2 := routerWithUserRoutes(h2) + if w := doJSON(t, r2, http.MethodDelete, "/users/ana@example.com", nil); w.Code != http.StatusInternalServerError { + t.Fatalf("internal: got %d, want %d", w.Code, http.StatusInternalServerError) + } + + repo3 := &stubRepo{ + deleteFn: func(ctx context.Context, email vo.Email) error { + return nil + }, + } + h3 := NewUserHandler(repo3) + r3 := routerWithUserRoutes(h3) + if w := doJSON(t, r3, http.MethodDelete, "/users/ana@example.com", nil); w.Code != http.StatusNoContent { + t.Fatalf("success: got %d, want %d", w.Code, http.StatusNoContent) + } +} diff --git a/internal/usr/mappers/usr_mapper.go b/internal/usr/mappers/usr_mapper.go new file mode 100644 index 0000000..8998332 --- /dev/null +++ b/internal/usr/mappers/usr_mapper.go @@ -0,0 +1,19 @@ +package mappers + +import "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain" + +type UserResponse struct { + Name string `json:"name"` + Email string `json:"email"` + Active bool `json:"active"` + UserType domain.UserType `json:"userType"` +} + +func ToUserResponse(u domain.User) UserResponse { + return UserResponse{ + Name: u.Name, + Email: string(u.Email), + Active: u.Active, + UserType: u.UserType, + } +} diff --git a/internal/usr/mappers/usr_mapper_test.go b/internal/usr/mappers/usr_mapper_test.go new file mode 100644 index 0000000..4713381 --- /dev/null +++ b/internal/usr/mappers/usr_mapper_test.go @@ -0,0 +1,73 @@ +package mappers + +import ( + "testing" + + "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain" +) + +func TestToUserResponse_MapsAllFields(t *testing.T) { + u, err := domain.NewUser( + "Ana", + "ana@example.com", + "secret123", + true, + domain.UserTypeAdmin, + ) + if err != nil { + t.Fatalf("domain.NewUser: %v", err) + } + + resp := ToUserResponse(u) + + if resp.Name != "Ana" { + t.Fatalf("Name: got %q, want %q", resp.Name, "Ana") + } + if resp.Email != "ana@example.com" { + t.Fatalf("Email: got %q, want %q", resp.Email, "ana@example.com") + } + if resp.Active != true { + t.Fatalf("Active: got %v, want %v", resp.Active, true) + } + if resp.UserType != domain.UserTypeAdmin { + t.Fatalf("UserType: got %v, want %v", resp.UserType, domain.UserTypeAdmin) + } +} + +func TestToUserResponse_MapsDifferentValues(t *testing.T) { + u, err := domain.NewUser( + "Bruno", + "bruno@example.com", + "anotherSecret!", + false, + domain.UserTypeUser, + ) + if err != nil { + t.Fatalf("domain.NewUser: %v", err) + } + + resp := ToUserResponse(u) + + if resp.Name != "Bruno" { + t.Fatalf("Name: got %q, want %q", resp.Name, "Bruno") + } + if resp.Email != "bruno@example.com" { + t.Fatalf("Email: got %q, want %q", resp.Email, "bruno@example.com") + } + if resp.Active != false { + t.Fatalf("Active: got %v, want %v", resp.Active, false) + } + if resp.UserType != domain.UserTypeUser { + t.Fatalf("UserType: got %v, want %v", resp.UserType, domain.UserTypeUser) + } +} + +func TestToUserResponse_ZeroValueUser_DoesNotPanic(t *testing.T) { + // domain.User zero value — ToUserResponse não deve panicar + var zero domain.User + resp := ToUserResponse(zero) + + if resp.Name != "" || resp.Email != "" || resp.Active != false || resp.UserType != "" { + t.Fatalf("unexpected response from zero value user: %+v", resp) + } +} diff --git a/internal/usr/repository/usr_repository.go b/internal/usr/repository/usr_repository.go new file mode 100644 index 0000000..e7817a0 --- /dev/null +++ b/internal/usr/repository/usr_repository.go @@ -0,0 +1,121 @@ +package repository + +import ( + "context" + "errors" + "sync" + + "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain/vo" +) + +var ( + ErrAlreadyExists = errors.New("user already exists") + ErrNotFound = errors.New("user not found") +) + +type UserRepository interface { + Create(ctx context.Context, u domain.User) error + GetByEmail(ctx context.Context, email vo.Email) (domain.User, bool, error) + List(ctx context.Context) ([]domain.User, error) + Update(ctx context.Context, u domain.User) error + Delete(ctx context.Context, email vo.Email) error +} + + +type inMemoryUserRepo struct { + mu sync.RWMutex + data map[string]domain.User +} + +func NewInMemoryUserRepository() UserRepository { + return &inMemoryUserRepo{ + data: make(map[string]domain.User), + } +} + +func (r *inMemoryUserRepo) Create(ctx context.Context, u domain.User) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + key := string(u.Email) + + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.data[key]; ok { + return ErrAlreadyExists + } + // cópia defensiva + r.data[key] = u + return nil +} + +func (r *inMemoryUserRepo) GetByEmail(ctx context.Context, email vo.Email) (domain.User, bool, error) { + select { + case <-ctx.Done(): + return domain.User{}, false, ctx.Err() + default: + } + key := string(email) + + r.mu.RLock() + defer r.mu.RUnlock() + + u, ok := r.data[key] + return u, ok, nil +} + +func (r *inMemoryUserRepo) List(ctx context.Context) ([]domain.User, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + r.mu.RLock() + defer r.mu.RUnlock() + + out := make([]domain.User, 0, len(r.data)) + for _, u := range r.data { + out = append(out, u) + } + return out, nil +} + +func (r *inMemoryUserRepo) Update(ctx context.Context, u domain.User) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + key := string(u.Email) + + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.data[key]; !ok { + return ErrNotFound + } + r.data[key] = u + return nil +} + +func (r *inMemoryUserRepo) Delete(ctx context.Context, email vo.Email) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + key := string(email) + + r.mu.Lock() + defer r.mu.Unlock() + + if _, ok := r.data[key]; !ok { + return ErrNotFound + } + delete(r.data, key) + return nil +} diff --git a/internal/usr/repository/usr_repository_test.go b/internal/usr/repository/usr_repository_test.go new file mode 100644 index 0000000..fdda5ba --- /dev/null +++ b/internal/usr/repository/usr_repository_test.go @@ -0,0 +1,224 @@ +package repository + +import ( + "context" + "sync" + "sync/atomic" + "testing" + + "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/domain/vo" +) + + +func mustEmail(t *testing.T, s string) vo.Email { + t.Helper() + e, err := vo.NewEmail(s) + if err != nil { + t.Fatalf("invalid email %q: %v", s, err) + } + return e +} + +func mustUser(t *testing.T, name, email string, active bool, ut domain.UserType) domain.User { + t.Helper() + u, err := domain.NewUser(name, email, "secret123", active, ut) + if err != nil { + t.Fatalf("NewUser: %v", err) + } + return u +} + + +func TestCreateAndGetByEmail(t *testing.T) { + repo := NewInMemoryUserRepository() + + u := mustUser(t, "Ana", "ana@example.com", true, domain.UserTypeUser) + + if err := repo.Create(context.Background(), u); err != nil { + t.Fatalf("Create: %v", err) + } + + got, ok, err := repo.GetByEmail(context.Background(), mustEmail(t, "ana@example.com")) + if err != nil { + t.Fatalf("GetByEmail: %v", err) + } + if !ok { + t.Fatalf("GetByEmail: expected ok=true") + } + + if got.Name != u.Name || got.Active != u.Active || got.UserType != u.UserType || string(got.Email) != string(u.Email) { + t.Fatalf("GetByEmail: got %+v, want %+v", got, u) + } +} + +func TestCreate_DuplicateConcurrent(t *testing.T) { + repo := NewInMemoryUserRepository() + u := mustUser(t, "Ana", "ana@example.com", true, domain.UserTypeUser) + + const N = 16 + var wg sync.WaitGroup + var successes int64 + var conflicts int64 + + wg.Add(N) + for i := 0; i < N; i++ { + go func() { + defer wg.Done() + err := repo.Create(context.Background(), u) + switch err { + case nil: + atomic.AddInt64(&successes, 1) + case ErrAlreadyExists: + atomic.AddInt64(&conflicts, 1) + default: + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }() + } + wg.Wait() + + if successes != 1 { + t.Fatalf("expected exactly 1 success, got %d", successes) + } + if conflicts != N-1 { + t.Fatalf("expected %d conflicts, got %d", N-1, conflicts) + } + + _, ok, err := repo.GetByEmail(context.Background(), mustEmail(t, "ana@example.com")) + if err != nil || !ok { + t.Fatalf("GetByEmail after concurrent create: ok=%v err=%v", ok, err) + } +} + +func TestGetByEmail_NotFound(t *testing.T) { + repo := NewInMemoryUserRepository() + + _, ok, err := repo.GetByEmail(context.Background(), mustEmail(t, "missing@example.com")) + if err != nil { + t.Fatalf("GetByEmail: unexpected err: %v", err) + } + if ok { + t.Fatalf("GetByEmail: expected ok=false for missing user") + } +} + +func TestList_ReturnsCopies(t *testing.T) { + repo := NewInMemoryUserRepository() + + u1 := mustUser(t, "Ana", "ana@example.com", true, domain.UserTypeUser) + u2 := mustUser(t, "Bob", "bob@example.com", false, domain.UserTypeAdmin) + + if err := repo.Create(context.Background(), u1); err != nil { + t.Fatalf("Create u1: %v", err) + } + if err := repo.Create(context.Background(), u2); err != nil { + t.Fatalf("Create u2: %v", err) + } + + list, err := repo.List(context.Background()) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(list) != 2 { + t.Fatalf("List length: got %d, want 2", len(list)) + } + + list[0].Name = "Hacked" + got, ok, err := repo.GetByEmail(context.Background(), mustEmail(t, "ana@example.com")) + if err != nil || !ok { + t.Fatalf("GetByEmail ana: ok=%v err=%v", ok, err) + } + if got.Name != "Ana" { + t.Fatalf("repository was mutated via List result; got.Name=%q", got.Name) + } +} + +func TestUpdate_Success_And_NotFound(t *testing.T) { + repo := NewInMemoryUserRepository() + + u := mustUser(t, "Ana", "ana@example.com", true, domain.UserTypeUser) + if err := repo.Create(context.Background(), u); err != nil { + t.Fatalf("Create: %v", err) + } + + updated := mustUser(t, "Ana Paula", "ana@example.com", false, domain.UserTypeAdmin) + if err := repo.Update(context.Background(), updated); err != nil { + t.Fatalf("Update existing: %v", err) + } + + got, ok, err := repo.GetByEmail(context.Background(), mustEmail(t, "ana@example.com")) + if err != nil || !ok { + t.Fatalf("GetByEmail after update: ok=%v err=%v", ok, err) + } + if got.Name != "Ana Paula" || got.Active != false || got.UserType != domain.UserTypeAdmin { + t.Fatalf("updated user mismatch: got=%+v", got) + } + + missing := mustUser(t, "Carlos", "carlos@example.com", true, domain.UserTypeUser) + if err := repo.Update(context.Background(), missing); err != ErrNotFound { + t.Fatalf("Update missing: got %v, want %v", err, ErrNotFound) + } +} + +func TestDelete_Success_And_NotFound(t *testing.T) { + repo := NewInMemoryUserRepository() + + u := mustUser(t, "Ana", "ana@example.com", true, domain.UserTypeUser) + if err := repo.Create(context.Background(), u); err != nil { + t.Fatalf("Create: %v", err) + } + + if err := repo.Delete(context.Background(), mustEmail(t, "ana@example.com")); err != nil { + t.Fatalf("Delete existing: %v", err) + } + + _, ok, err := repo.GetByEmail(context.Background(), mustEmail(t, "ana@example.com")) + if err != nil { + t.Fatalf("GetByEmail after delete: %v", err) + } + if ok { + t.Fatalf("GetByEmail after delete: expected ok=false") + } + + if err := repo.Delete(context.Background(), mustEmail(t, "ana@example.com")); err != ErrNotFound { + t.Fatalf("Delete missing: got %v, want %v", err, ErrNotFound) + } +} + +func TestContextCanceled_OnOperations(t *testing.T) { + repo := NewInMemoryUserRepository() + u := mustUser(t, "Ana", "ana@example.com", true, domain.UserTypeUser) + + ctxC, cancel := context.WithCancel(context.Background()) + cancel() + if err := repo.Create(ctxC, u); err == nil { + t.Fatalf("Create with canceled context: expected error") + } + + ctxG, cancelG := context.WithCancel(context.Background()) + cancelG() + if _, _, err := repo.GetByEmail(ctxG, mustEmail(t, "ana@example.com")); err == nil { + t.Fatalf("GetByEmail with canceled context: expected error") + } + + ctxL, cancelL := context.WithCancel(context.Background()) + cancelL() + if _, err := repo.List(ctxL); err == nil { + t.Fatalf("List with canceled context: expected error") + } + + ctxU, cancelU := context.WithCancel(context.Background()) + cancelU() + if err := repo.Update(ctxU, u); err == nil { + t.Fatalf("Update with canceled context: expected error") + } + + ctxD, cancelD := context.WithCancel(context.Background()) + cancelD() + if err := repo.Delete(ctxD, mustEmail(t, "ana@example.com")); err == nil { + t.Fatalf("Delete with canceled context: expected error") + } +} diff --git a/internal/usr/router/router.go b/internal/usr/router/router.go deleted file mode 100644 index 92c15c8..0000000 --- a/internal/usr/router/router.go +++ /dev/null @@ -1,46 +0,0 @@ -package usr_router - -import ( - "net/http" - - "github.com/gin-gonic/gin" -) - -type User struct { - Name string - Email string -} - -type UserResponse struct { - Name string `json:"name"` - Email string `json:"email"` -} - -func mapToUserResponse(u User) UserResponse { - return UserResponse{ - Name: u.Name, - Email: u.Email, - } -} - -func mapUsersToResponse(users []User) []UserResponse { - out := make([]UserResponse, 0, len(users)) - for _, u := range users { - out = append(out, mapToUserResponse(u)) - } - return out -} - -func handlerUsers(c *gin.Context) { - users := []User{ - {Name: "William K", Email: "william@mail.com"}, - {Name: "Novo user test", Email: "novo-user@mail.com"}, - } - - response := mapUsersToResponse(users) - c.JSON(http.StatusOK, response) -} - -func RegisterUserRoutes(r *gin.Engine) { - r.GET("/users", handlerUsers) -} diff --git a/internal/usr/router/router_test.go b/internal/usr/router/router_test.go deleted file mode 100644 index 6ad98cc..0000000 --- a/internal/usr/router/router_test.go +++ /dev/null @@ -1,322 +0,0 @@ -package usr_router - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestMain(m *testing.M) { - gin.SetMode(gin.TestMode) - m.Run() -} - -func TestMapToUserResponse(t *testing.T) { - tests := []struct { - name string - input User - expected UserResponse - }{ - { - name: "mapeamento_usuario_completo", - input: User{ - Name: "João Silva", - Email: "joao@exemplo.com", - }, - expected: UserResponse{ - Name: "João Silva", - Email: "joao@exemplo.com", - }, - }, - { - name: "mapeamento_usuario_campos_vazios", - input: User{ - Name: "", - Email: "", - }, - expected: UserResponse{ - Name: "", - Email: "", - }, - }, - { - name: "mapeamento_usuario_nome_vazio", - input: User{ - Name: "", - Email: "teste@exemplo.com", - }, - expected: UserResponse{ - Name: "", - Email: "teste@exemplo.com", - }, - }, - { - name: "mapeamento_usuario_email_vazio", - input: User{ - Name: "Maria Santos", - Email: "", - }, - expected: UserResponse{ - Name: "Maria Santos", - Email: "", - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := mapToUserResponse(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestMapUsersToResponse(t *testing.T) { - tests := []struct { - name string - input []User - expected []UserResponse - }{ - { - name: "lista_usuarios_multiplos", - input: []User{ - {Name: "João Silva", Email: "joao@exemplo.com"}, - {Name: "Maria Santos", Email: "maria@exemplo.com"}, - {Name: "Pedro Oliveira", Email: "pedro@exemplo.com"}, - }, - expected: []UserResponse{ - {Name: "João Silva", Email: "joao@exemplo.com"}, - {Name: "Maria Santos", Email: "maria@exemplo.com"}, - {Name: "Pedro Oliveira", Email: "pedro@exemplo.com"}, - }, - }, - { - name: "lista_usuarios_vazia", - input: []User{}, - expected: []UserResponse{}, - }, - { - name: "lista_usuario_unico", - input: []User{ - {Name: "Usuário Único", Email: "unico@exemplo.com"}, - }, - expected: []UserResponse{ - {Name: "Usuário Único", Email: "unico@exemplo.com"}, - }, - }, - { - name: "lista_usuarios_nil", - input: nil, - expected: []UserResponse{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := mapUsersToResponse(tt.input) - assert.Equal(t, tt.expected, result) - }) - } -} - -func TestHandlerUsers(t *testing.T) { - router := gin.New() - RegisterUserRoutes(router) - - tests := []struct { - name string - method string - path string - expectedStatus int - expectedUsers []UserResponse - }{ - { - name: "get_users_sucesso", - method: http.MethodGet, - path: "/users", - expectedStatus: http.StatusOK, - expectedUsers: []UserResponse{ - {Name: "William K", Email: "william@mail.com"}, - {Name: "Novo user test", Email: "novo-user@mail.com"}, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req, err := http.NewRequest(tt.method, tt.path, nil) - require.NoError(t, err) - - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - assert.Equal(t, tt.expectedStatus, w.Code) - - assert.Equal(t, "application/json; charset=utf-8", w.Header().Get("Content-Type")) - - var response []UserResponse - err = json.Unmarshal(w.Body.Bytes(), &response) - require.NoError(t, err) - assert.Equal(t, tt.expectedUsers, response) - }) - } -} - -func TestHandlerUsersMetodoInvalido(t *testing.T) { - router := gin.New() - RegisterUserRoutes(router) - - metodosInvalidos := []string{ - http.MethodPost, - http.MethodPut, - http.MethodDelete, - http.MethodPatch, - } - - for _, metodo := range metodosInvalidos { - t.Run("metodo_"+metodo+"_nao_permitido", func(t *testing.T) { - req, err := http.NewRequest(metodo, "/users", nil) - require.NoError(t, err) - - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.Equal(t, http.StatusNotFound, w.Code) - }) - } -} - -func TestRegisterUserRoutes(t *testing.T) { - tests := []struct { - name string - path string - }{ - { - name: "registro_rota_users", - path: "/users", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - router := gin.New() - - RegisterUserRoutes(router) - - req, err := http.NewRequest(http.MethodGet, tt.path, nil) - require.NoError(t, err) - - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - - assert.NotEqual(t, http.StatusNotFound, w.Code) - }) - } -} - -func BenchmarkMapToUserResponse(b *testing.B) { - user := User{ - Name: "Benchmark User", - Email: "benchmark@exemplo.com", - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - mapToUserResponse(user) - } -} - -func BenchmarkMapUsersToResponse(b *testing.B) { - users := []User{ - {Name: "User 1", Email: "user1@exemplo.com"}, - {Name: "User 2", Email: "user2@exemplo.com"}, - {Name: "User 3", Email: "user3@exemplo.com"}, - {Name: "User 4", Email: "user4@exemplo.com"}, - {Name: "User 5", Email: "user5@exemplo.com"}, - } - - b.ResetTimer() - for i := 0; i < b.N; i++ { - mapUsersToResponse(users) - } -} - -func BenchmarkHandlerUsers(b *testing.B) { - router := gin.New() - RegisterUserRoutes(router) - - req, _ := http.NewRequest(http.MethodGet, "/users", nil) - - b.ResetTimer() - for i := 0; i < b.N; i++ { - w := httptest.NewRecorder() - router.ServeHTTP(w, req) - } -} - -// Testes de estrutura de dados - -func TestUserStruct(t *testing.T) { - user := User{ - Name: "Test User", - Email: "test@exemplo.com", - } - - assert.Equal(t, "Test User", user.Name) - assert.Equal(t, "test@exemplo.com", user.Email) -} - -func TestUserResponseStruct(t *testing.T) { - userResponse := UserResponse{ - Name: "Test User", - Email: "test@exemplo.com", - } - - assert.Equal(t, "Test User", userResponse.Name) - assert.Equal(t, "test@exemplo.com", userResponse.Email) - - jsonData, err := json.Marshal(userResponse) - require.NoError(t, err) - - var unmarshaled map[string]interface{} - err = json.Unmarshal(jsonData, &unmarshaled) - require.NoError(t, err) - - assert.Equal(t, "Test User", unmarshaled["name"]) - assert.Equal(t, "test@exemplo.com", unmarshaled["email"]) -} - -func TestMapUsersToResponseCasosExtremos(t *testing.T) { - t.Run("lista_grande_usuarios", func(t *testing.T) { - var users []User - for i := 0; i < 1000; i++ { - users = append(users, User{ - Name: "User " + string(rune(i)), - Email: "user" + string(rune(i)) + "@exemplo.com", - }) - } - - result := mapUsersToResponse(users) - assert.Len(t, result, 1000) - }) - - t.Run("usuarios_com_caracteres_especiais", func(t *testing.T) { - users := []User{ - {Name: "João José", Email: "joão@exemplo.com"}, - {Name: "María García", Email: "maria@domínio.com"}, - {Name: "张三", Email: "zhang@example.com"}, - } - - result := mapUsersToResponse(users) - assert.Len(t, result, 3) - assert.Equal(t, "João José", result[0].Name) - assert.Equal(t, "María García", result[1].Name) - assert.Equal(t, "张三", result[2].Name) - }) -} diff --git a/internal/usr/router/usr_router.go b/internal/usr/router/usr_router.go new file mode 100644 index 0000000..8506882 --- /dev/null +++ b/internal/usr/router/usr_router.go @@ -0,0 +1,17 @@ +package usr_router + +import ( + "github.com/gin-gonic/gin" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/handler" +) + +func RegisterUserRoutes(group *gin.RouterGroup, h *handler.UserHandler) { + users := group.Group("/users") + { + users.POST("", h.CreateUser) + users.GET("", h.ListUsers) + users.GET("/:email", h.GetUser) + users.PATCH("/:email", h.UpdateUser) + users.DELETE("/:email", h.DeleteUser) + } +} diff --git a/internal/usr/router/usr_router_test.go b/internal/usr/router/usr_router_test.go new file mode 100644 index 0000000..0867d1e --- /dev/null +++ b/internal/usr/router/usr_router_test.go @@ -0,0 +1,56 @@ +package usr_router + +import ( + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/williamkoller/cloud-architecture-golang/internal/usr/handler" +) + +func TestRegisterUserRoutes_RegistersAllExpectedRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + + r := gin.New() + api := r.Group("/api/v1") + + h := &handler.UserHandler{} + + RegisterUserRoutes(api, h) + + routes := r.Routes() + + type exp struct { + method string + path string + wantFn string + } + expected := []exp{ + {method: "POST", path: "/api/v1/users", wantFn: ".CreateUser"}, + {method: "GET", path: "/api/v1/users", wantFn: ".ListUsers"}, + {method: "GET", path: "/api/v1/users/:email", wantFn: ".GetUser"}, + {method: "PATCH", path: "/api/v1/users/:email", wantFn: ".UpdateUser"}, + {method: "DELETE", path: "/api/v1/users/:email", wantFn: ".DeleteUser"}, + } + + for _, e := range expected { + ri, ok := findRoute(routes, e.method, e.path) + if !ok { + t.Fatalf("route not found: %s %s", e.method, e.path) + } + + if !strings.Contains(ri.Handler, e.wantFn) { + t.Fatalf("handler mismatch for %s %s:\n got: %q\nwant to contain: %q", + e.method, e.path, ri.Handler, e.wantFn) + } + } +} + +func findRoute(routes []gin.RouteInfo, method, path string) (gin.RouteInfo, bool) { + for _, r := range routes { + if r.Method == method && r.Path == path { + return r, true + } + } + return gin.RouteInfo{}, false +} diff --git a/internal/usr/validation/usr_validation.go b/internal/usr/validation/usr_validation.go new file mode 100644 index 0000000..ca926be --- /dev/null +++ b/internal/usr/validation/usr_validation.go @@ -0,0 +1,13 @@ +package validation + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func RespondValidationError(c *gin.Context, err error) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) +} diff --git a/internal/usr/validation/usr_validation_test.go b/internal/usr/validation/usr_validation_test.go new file mode 100644 index 0000000..cf30a31 --- /dev/null +++ b/internal/usr/validation/usr_validation_test.go @@ -0,0 +1,57 @@ +package validation + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +type errResp struct { + Error string `json:"error"` +} + +func TestRespondValidationError_Returns400AndJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + RespondValidationError(c, errors.New("bad input")) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status code: got %d, want %d", w.Code, http.StatusBadRequest) + } + + ct := w.Header().Get("Content-Type") + if !strings.HasPrefix(ct, "application/json") { + t.Fatalf("content-type: got %q, want prefix %q", ct, "application/json") + } + + var body errResp + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if body.Error != "bad input" { + t.Fatalf(`body.error: got %q, want %q`, body.Error, "bad input") + } +} + +func TestRespondValidationError_WithNilError_Panics(t *testing.T) { + gin.SetMode(gin.TestMode) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic when err == nil, but did not panic") + } + }() + + RespondValidationError(c, nil) +}