diff --git a/Dockerfile b/Dockerfile index 3faca3c..a64fefc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,19 +8,21 @@ WORKDIR /app COPY . . -RUN make build +RUN go mod download +RUN go build -o main app/main.go # Distribution FROM alpine:latest RUN apk update && apk upgrade && \ - apk --update --no-cache add tzdata && \ + apk --update --no-cache add tzdata ca-certificates && \ mkdir /app WORKDIR /app EXPOSE 9090 -COPY --from=builder /app/engine /app/ +COPY --from=builder /app/main /app/ +COPY --from=builder /app/.env /app/ -CMD /app/engine \ No newline at end of file +CMD ["./main"] \ No newline at end of file diff --git a/app/main.go b/app/main.go index edf7a87..16617c2 100644 --- a/app/main.go +++ b/app/main.go @@ -15,6 +15,7 @@ import ( mysqlRepo "github.com/bxcodec/go-clean-arch/internal/repository/mysql" "github.com/bxcodec/go-clean-arch/article" + "github.com/bxcodec/go-clean-arch/user" "github.com/bxcodec/go-clean-arch/internal/rest" "github.com/bxcodec/go-clean-arch/internal/rest/middleware" "github.com/joho/godotenv" @@ -75,10 +76,13 @@ func main() { // Prepare Repository authorRepo := mysqlRepo.NewAuthorRepository(dbConn) articleRepo := mysqlRepo.NewArticleRepository(dbConn) + userRepo := mysqlRepo.NewUserRepository(dbConn) // Build service Layer - svc := article.NewService(articleRepo, authorRepo) - rest.NewArticleHandler(e, svc) + articleSvc := article.NewService(articleRepo, authorRepo) + userSvc := user.NewService(userRepo) + rest.NewArticleHandler(e, articleSvc) + rest.NewUserHandler(e, userSvc) // Start Server address := os.Getenv("SERVER_ADDRESS") diff --git a/compose.yaml b/compose.yaml index 35b5c86..32c8ca6 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,15 +1,21 @@ version: "3.7" services: web: - image: go-clean-arch - container_name: article_management_api + build: . + container_name: go_clean_arch_api ports: - 9090:9090 depends_on: mysql: condition: service_healthy - volumes: - - ./config.json:/app/config.json + environment: + - DATABASE_HOST=mysql + - DATABASE_PORT=3306 + - DATABASE_USER=user + - DATABASE_PASS=password + - DATABASE_NAME=article + - SERVER_ADDRESS=:9090 + - CONTEXT_TIMEOUT=30 mysql: image: mysql:8.3 @@ -17,6 +23,7 @@ services: command: mysqld --user=root volumes: - ./article.sql:/docker-entrypoint-initdb.d/init.sql + - ./user.sql:/docker-entrypoint-initdb.d/user.sql ports: - 3306:3306 environment: diff --git a/domain/user.go b/domain/user.go new file mode 100644 index 0000000..29650ef --- /dev/null +++ b/domain/user.go @@ -0,0 +1,28 @@ +package domain + +import ( + "time" +) + +// User representing the User data struct +type User struct { + ID int64 `json:"id"` + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password,omitempty" validate:"required,min=6"` + UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at"` +} + +// UserLoginRequest representing the login request struct +type UserLoginRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} + +// UserRegisterRequest representing the register request struct +type UserRegisterRequest struct { + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=6"` +} diff --git a/internal/repository/mysql/user.go b/internal/repository/mysql/user.go new file mode 100644 index 0000000..f81d44b --- /dev/null +++ b/internal/repository/mysql/user.go @@ -0,0 +1,182 @@ +package mysql + +import ( + "context" + "database/sql" + "fmt" + + "github.com/sirupsen/logrus" + + "github.com/bxcodec/go-clean-arch/domain" + "github.com/bxcodec/go-clean-arch/internal/repository" +) + +type UserRepository struct { + Conn *sql.DB +} + +// NewUserRepository will create an object that represent the user.Repository interface +func NewUserRepository(conn *sql.DB) *UserRepository { + return &UserRepository{conn} +} + +func (m *UserRepository) fetch(ctx context.Context, query string, args ...interface{}) (result []domain.User, err error) { + rows, err := m.Conn.QueryContext(ctx, query, args...) + if err != nil { + logrus.Error(err) + return nil, err + } + + defer func() { + errRow := rows.Close() + if errRow != nil { + logrus.Error(errRow) + } + }() + + result = make([]domain.User, 0) + for rows.Next() { + t := domain.User{} + err = rows.Scan( + &t.ID, + &t.Name, + &t.Email, + &t.Password, + &t.UpdatedAt, + &t.CreatedAt, + ) + + if err != nil { + logrus.Error(err) + return nil, err + } + result = append(result, t) + } + + return result, nil +} + +func (m *UserRepository) Fetch(ctx context.Context, cursor string, num int64) (res []domain.User, nextCursor string, err error) { + query := `SELECT id, name, email, password, updated_at, created_at + FROM users WHERE created_at > ? ORDER BY created_at LIMIT ? ` + + decodedCursor, err := repository.DecodeCursor(cursor) + if err != nil && cursor != "" { + return nil, "", domain.ErrBadParamInput + } + + res, err = m.fetch(ctx, query, decodedCursor, num) + if err != nil { + return nil, "", err + } + + if len(res) == int(num) { + nextCursor = repository.EncodeCursor(res[len(res)-1].CreatedAt) + } + + return +} + +func (m *UserRepository) GetByID(ctx context.Context, id int64) (res domain.User, err error) { + query := `SELECT id, name, email, password, updated_at, created_at + FROM users WHERE ID = ?` + + list, err := m.fetch(ctx, query, id) + if err != nil { + return domain.User{}, err + } + + if len(list) > 0 { + res = list[0] + } else { + return res, domain.ErrNotFound + } + + return +} + +func (m *UserRepository) GetByEmail(ctx context.Context, email string) (res domain.User, err error) { + query := `SELECT id, name, email, password, updated_at, created_at + FROM users WHERE email = ?` + + list, err := m.fetch(ctx, query, email) + if err != nil { + return + } + + if len(list) > 0 { + res = list[0] + } else { + return res, domain.ErrNotFound + } + return +} + +func (m *UserRepository) Store(ctx context.Context, u *domain.User) (err error) { + query := `INSERT users SET name=?, email=?, password=?, updated_at=?, created_at=?` + stmt, err := m.Conn.PrepareContext(ctx, query) + if err != nil { + return + } + + res, err := stmt.ExecContext(ctx, u.Name, u.Email, u.Password, u.UpdatedAt, u.CreatedAt) + if err != nil { + return + } + lastID, err := res.LastInsertId() + if err != nil { + return + } + u.ID = lastID + return +} + +func (m *UserRepository) Delete(ctx context.Context, id int64) (err error) { + query := "DELETE FROM users WHERE id = ?" + + stmt, err := m.Conn.PrepareContext(ctx, query) + if err != nil { + return + } + + res, err := stmt.ExecContext(ctx, id) + if err != nil { + return + } + + rowsAfected, err := res.RowsAffected() + if err != nil { + return + } + + if rowsAfected != 1 { + err = fmt.Errorf("weird Behavior. Total Affected: %d", rowsAfected) + return + } + + return +} + +func (m *UserRepository) Update(ctx context.Context, u *domain.User) (err error) { + query := `UPDATE users set name=?, email=?, password=?, updated_at=? WHERE ID = ?` + + stmt, err := m.Conn.PrepareContext(ctx, query) + if err != nil { + return + } + + res, err := stmt.ExecContext(ctx, u.Name, u.Email, u.Password, u.UpdatedAt, u.ID) + if err != nil { + return + } + affect, err := res.RowsAffected() + if err != nil { + return + } + if affect != 1 { + err = fmt.Errorf("weird Behavior. Total Affected: %d", affect) + return + } + + return +} diff --git a/internal/repository/mysql/user_test.go b/internal/repository/mysql/user_test.go new file mode 100644 index 0000000..579b446 --- /dev/null +++ b/internal/repository/mysql/user_test.go @@ -0,0 +1,221 @@ +package mysql + +import ( + "context" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/assert" + + "github.com/bxcodec/go-clean-arch/domain" +) + +func TestUserRepository_Fetch(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewUserRepository(db) + + t.Run("success", func(t *testing.T) { + rows := sqlmock.NewRows([]string{"id", "name", "email", "password", "updated_at", "created_at"}). + AddRow(1, "John Doe", "john@example.com", "password123", time.Now(), time.Now()). + AddRow(2, "Jane Doe", "jane@example.com", "password456", time.Now(), time.Now()) + + mock.ExpectQuery("SELECT id, name, email, password, updated_at, created_at"). + WillReturnRows(rows) + + users, nextCursor, err := repo.Fetch(context.TODO(), "", 10) + + assert.NoError(t, err) + assert.NotEmpty(t, users) + assert.Equal(t, 2, len(users)) + assert.Equal(t, "John Doe", users[0].Name) + assert.Equal(t, "john@example.com", users[0].Email) + }) + + t.Run("error", func(t *testing.T) { + mock.ExpectQuery("SELECT id, name, email, password, updated_at, created_at"). + WillReturnError(assert.AnError) + + users, nextCursor, err := repo.Fetch(context.TODO(), "", 10) + + assert.Error(t, err) + assert.Empty(t, users) + assert.Empty(t, nextCursor) + }) +} + +func TestUserRepository_GetByID(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewUserRepository(db) + + t.Run("success", func(t *testing.T) { + rows := sqlmock.NewRows([]string{"id", "name", "email", "password", "updated_at", "created_at"}). + AddRow(1, "John Doe", "john@example.com", "password123", time.Now(), time.Now()) + + mock.ExpectQuery("SELECT id, name, email, password, updated_at, created_at"). + WithArgs(1). + WillReturnRows(rows) + + user, err := repo.GetByID(context.TODO(), 1) + + assert.NoError(t, err) + assert.Equal(t, int64(1), user.ID) + assert.Equal(t, "John Doe", user.Name) + assert.Equal(t, "john@example.com", user.Email) + }) + + t.Run("not found", func(t *testing.T) { + rows := sqlmock.NewRows([]string{"id", "name", "email", "password", "updated_at", "created_at"}) + + mock.ExpectQuery("SELECT id, name, email, password, updated_at, created_at"). + WithArgs(999). + WillReturnRows(rows) + + user, err := repo.GetByID(context.TODO(), 999) + + assert.Error(t, err) + assert.Equal(t, domain.ErrNotFound, err) + assert.Equal(t, domain.User{}, user) + }) +} + +func TestUserRepository_GetByEmail(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewUserRepository(db) + + t.Run("success", func(t *testing.T) { + rows := sqlmock.NewRows([]string{"id", "name", "email", "password", "updated_at", "created_at"}). + AddRow(1, "John Doe", "john@example.com", "password123", time.Now(), time.Now()) + + mock.ExpectQuery("SELECT id, name, email, password, updated_at, created_at"). + WithArgs("john@example.com"). + WillReturnRows(rows) + + user, err := repo.GetByEmail(context.TODO(), "john@example.com") + + assert.NoError(t, err) + assert.Equal(t, int64(1), user.ID) + assert.Equal(t, "John Doe", user.Name) + assert.Equal(t, "john@example.com", user.Email) + }) + + t.Run("not found", func(t *testing.T) { + rows := sqlmock.NewRows([]string{"id", "name", "email", "password", "updated_at", "created_at"}) + + mock.ExpectQuery("SELECT id, name, email, password, updated_at, created_at"). + WithArgs("notfound@example.com"). + WillReturnRows(rows) + + user, err := repo.GetByEmail(context.TODO(), "notfound@example.com") + + assert.Error(t, err) + assert.Equal(t, domain.ErrNotFound, err) + assert.Equal(t, domain.User{}, user) + }) +} + +func TestUserRepository_Store(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewUserRepository(db) + + t.Run("success", func(t *testing.T) { + mock.ExpectPrepare("INSERT users SET name=?, email=?, password=?, updated_at=?, created_at=?"). + ExpectExec(). + WithArgs("John Doe", "john@example.com", "password123", sqlmock.AnyArg(), sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + + user := &domain.User{ + Name: "John Doe", + Email: "john@example.com", + Password: "password123", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err := repo.Store(context.TODO(), user) + + assert.NoError(t, err) + assert.Equal(t, int64(1), user.ID) + }) +} + +func TestUserRepository_Update(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewUserRepository(db) + + t.Run("success", func(t *testing.T) { + mock.ExpectPrepare("UPDATE users set name=?, email=?, password=?, updated_at=? WHERE ID = ?"). + ExpectExec(). + WithArgs("John Updated", "john@example.com", "newpassword", sqlmock.AnyArg(), 1). + WillReturnResult(sqlmock.NewResult(0, 1)) + + user := &domain.User{ + ID: 1, + Name: "John Updated", + Email: "john@example.com", + Password: "newpassword", + UpdatedAt: time.Now(), + } + + err := repo.Update(context.TODO(), user) + + assert.NoError(t, err) + }) +} + +func TestUserRepository_Delete(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + repo := NewUserRepository(db) + + t.Run("success", func(t *testing.T) { + mock.ExpectPrepare("DELETE FROM users WHERE id = ?"). + ExpectExec(). + WithArgs(1). + WillReturnResult(sqlmock.NewResult(0, 1)) + + err := repo.Delete(context.TODO(), 1) + + assert.NoError(t, err) + }) + + t.Run("not found", func(t *testing.T) { + mock.ExpectPrepare("DELETE FROM users WHERE id = ?"). + ExpectExec(). + WithArgs(999). + WillReturnResult(sqlmock.NewResult(0, 0)) + + err := repo.Delete(context.TODO(), 999) + + assert.Error(t, err) + }) +} diff --git a/internal/rest/user.go b/internal/rest/user.go new file mode 100644 index 0000000..2a8e99f --- /dev/null +++ b/internal/rest/user.go @@ -0,0 +1,196 @@ +package rest + +import ( + "context" + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + validator "gopkg.in/go-playground/validator.v9" + + "github.com/bxcodec/go-clean-arch/domain" +) + +// UserService represent the user's usecases +// +//go:generate mockery --name UserService +type UserService interface { + Fetch(ctx context.Context, cursor string, num int64) ([]domain.User, string, error) + GetByID(ctx context.Context, id int64) (domain.User, error) + Update(ctx context.Context, u *domain.User) error + GetByEmail(ctx context.Context, email string) (domain.User, error) + Store(context.Context, *domain.User) error + Delete(ctx context.Context, id int64) error + Register(ctx context.Context, req *domain.UserRegisterRequest) (domain.User, error) + Login(ctx context.Context, req *domain.UserLoginRequest) (domain.User, error) +} + +// UserHandler represent the httphandler for user +type UserHandler struct { + Service UserService +} + +const userDefaultNum = 10 + +// NewUserHandler will initialize the users/ resources endpoint +func NewUserHandler(e *echo.Echo, svc UserService) { + handler := &UserHandler{ + Service: svc, + } + e.GET("/users", handler.FetchUser) + e.POST("/users", handler.Store) + e.POST("/users/register", handler.Register) + e.POST("/users/login", handler.Login) + e.GET("/users/:id", handler.GetByID) + e.DELETE("/users/:id", handler.Delete) +} + +// FetchUser will fetch the user based on given params +func (u *UserHandler) FetchUser(c echo.Context) error { + + numS := c.QueryParam("num") + num, err := strconv.Atoi(numS) + if err != nil || num == 0 { + num = userDefaultNum + } + + cursor := c.QueryParam("cursor") + ctx := c.Request().Context() + + listUser, nextCursor, err := u.Service.Fetch(ctx, cursor, int64(num)) + if err != nil { + return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()}) + } + + c.Response().Header().Set(`X-Cursor`, nextCursor) + return c.JSON(http.StatusOK, listUser) +} + +// GetByID will get user by given id +func (u *UserHandler) GetByID(c echo.Context) error { + idP, err := strconv.Atoi(c.Param("id")) + if err != nil { + return c.JSON(http.StatusNotFound, domain.ErrNotFound.Error()) + } + + id := int64(idP) + ctx := c.Request().Context() + + user, err := u.Service.GetByID(ctx, id) + if err != nil { + return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()}) + } + + return c.JSON(http.StatusOK, user) +} + +func isUserRequestValid(m *domain.User) (bool, error) { + validate := validator.New() + err := validate.Struct(m) + if err != nil { + return false, err + } + return true, nil +} + +func isUserRegisterRequestValid(m *domain.UserRegisterRequest) (bool, error) { + validate := validator.New() + err := validate.Struct(m) + if err != nil { + return false, err + } + return true, nil +} + +func isUserLoginRequestValid(m *domain.UserLoginRequest) (bool, error) { + validate := validator.New() + err := validate.Struct(m) + if err != nil { + return false, err + } + return true, nil +} + +// Store will store the user by given request body +func (u *UserHandler) Store(c echo.Context) (err error) { + var user domain.User + err = c.Bind(&user) + if err != nil { + return c.JSON(http.StatusUnprocessableEntity, err.Error()) + } + + var ok bool + if ok, err = isUserRequestValid(&user); !ok { + return c.JSON(http.StatusBadRequest, err.Error()) + } + + ctx := c.Request().Context() + err = u.Service.Store(ctx, &user) + if err != nil { + return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()}) + } + + return c.JSON(http.StatusCreated, user) +} + +// Register will register a new user +func (u *UserHandler) Register(c echo.Context) (err error) { + var req domain.UserRegisterRequest + err = c.Bind(&req) + if err != nil { + return c.JSON(http.StatusUnprocessableEntity, err.Error()) + } + + var ok bool + if ok, err = isUserRegisterRequestValid(&req); !ok { + return c.JSON(http.StatusBadRequest, err.Error()) + } + + ctx := c.Request().Context() + user, err := u.Service.Register(ctx, &req) + if err != nil { + return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()}) + } + + return c.JSON(http.StatusCreated, user) +} + +// Login will authenticate a user +func (u *UserHandler) Login(c echo.Context) (err error) { + var req domain.UserLoginRequest + err = c.Bind(&req) + if err != nil { + return c.JSON(http.StatusUnprocessableEntity, err.Error()) + } + + var ok bool + if ok, err = isUserLoginRequestValid(&req); !ok { + return c.JSON(http.StatusBadRequest, err.Error()) + } + + ctx := c.Request().Context() + user, err := u.Service.Login(ctx, &req) + if err != nil { + return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()}) + } + + return c.JSON(http.StatusOK, user) +} + +// Delete will delete user by given param +func (u *UserHandler) Delete(c echo.Context) error { + idP, err := strconv.Atoi(c.Param("id")) + if err != nil { + return c.JSON(http.StatusNotFound, domain.ErrNotFound.Error()) + } + + id := int64(idP) + ctx := c.Request().Context() + + err = u.Service.Delete(ctx, id) + if err != nil { + return c.JSON(getStatusCode(err), ResponseError{Message: err.Error()}) + } + + return c.NoContent(http.StatusNoContent) +} diff --git a/internal/rest/user_test.go b/internal/rest/user_test.go new file mode 100644 index 0000000..7b457fa --- /dev/null +++ b/internal/rest/user_test.go @@ -0,0 +1,358 @@ +package rest + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/bxcodec/go-clean-arch/domain" +) + +func TestUserHandler_FetchUser(t *testing.T) { + mockService := new(MockUserService) + handler := &UserHandler{Service: mockService} + + e := echo.New() + + t.Run("success", func(t *testing.T) { + expectedUsers := []domain.User{ + {ID: 1, Name: "John Doe", Email: "john@example.com"}, + {ID: 2, Name: "Jane Doe", Email: "jane@example.com"}, + } + + mockService.On("Fetch", mock.Anything, "", int64(10)).Return(expectedUsers, "next_cursor", nil) + + req := httptest.NewRequest(http.MethodGet, "/users", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := handler.FetchUser(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "next_cursor", rec.Header().Get("X-Cursor")) + + var response []domain.User + err = json.Unmarshal(rec.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, expectedUsers, response) + + mockService.AssertExpectations(t) + }) + + t.Run("error", func(t *testing.T) { + mockService.On("Fetch", mock.Anything, "", int64(10)).Return(nil, "", assert.AnError) + + req := httptest.NewRequest(http.MethodGet, "/users", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := handler.FetchUser(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusInternalServerError, rec.Code) + + mockService.AssertExpectations(t) + }) +} + +func TestUserHandler_GetByID(t *testing.T) { + mockService := new(MockUserService) + handler := &UserHandler{Service: mockService} + + e := echo.New() + + t.Run("success", func(t *testing.T) { + expectedUser := domain.User{ID: 1, Name: "John Doe", Email: "john@example.com"} + + mockService.On("GetByID", mock.Anything, int64(1)).Return(expectedUser, nil) + + req := httptest.NewRequest(http.MethodGet, "/users/1", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/users/:id") + c.SetParamNames("id") + c.SetParamValues("1") + + err := handler.GetByID(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + + var response domain.User + err = json.Unmarshal(rec.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, expectedUser, response) + + mockService.AssertExpectations(t) + }) + + t.Run("not found", func(t *testing.T) { + mockService.On("GetByID", mock.Anything, int64(999)).Return(domain.User{}, domain.ErrNotFound) + + req := httptest.NewRequest(http.MethodGet, "/users/999", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/users/:id") + c.SetParamNames("id") + c.SetParamValues("999") + + err := handler.GetByID(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusNotFound, rec.Code) + + mockService.AssertExpectations(t) + }) + + t.Run("invalid id", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/users/invalid", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/users/:id") + c.SetParamNames("id") + c.SetParamValues("invalid") + + err := handler.GetByID(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) +} + +func TestUserHandler_Store(t *testing.T) { + mockService := new(MockUserService) + handler := &UserHandler{Service: mockService} + + e := echo.New() + + t.Run("success", func(t *testing.T) { + user := domain.User{ + Name: "John Doe", + Email: "john@example.com", + Password: "password123", + } + + mockService.On("Store", mock.Anything, mock.AnythingOfType("*domain.User")).Return(nil) + + jsonBody, _ := json.Marshal(user) + req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBuffer(jsonBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := handler.Store(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusCreated, rec.Code) + + mockService.AssertExpectations(t) + }) + + t.Run("invalid request", func(t *testing.T) { + invalidUser := domain.User{ + Name: "", // Invalid: required field is empty + } + + jsonBody, _ := json.Marshal(invalidUser) + req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewBuffer(jsonBody)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + err := handler.Store(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +} + +func TestUserHandler_Register(t *testing.T) { + mockService := new(MockUserService) + handler := &UserHandler{Service: mockService} + + e := echo.New() + + t.Run("success", func(t *testing.T) { + req := domain.UserRegisterRequest{ + Name: "John Doe", + Email: "john@example.com", + Password: "password123", + } + + expectedUser := domain.User{ + ID: 1, + Name: "John Doe", + Email: "john@example.com", + } + + mockService.On("Register", mock.Anything, mock.AnythingOfType("*domain.UserRegisterRequest")).Return(expectedUser, nil) + + jsonBody, _ := json.Marshal(req) + httpReq := httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewBuffer(jsonBody)) + httpReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(httpReq, rec) + + err := handler.Register(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusCreated, rec.Code) + + var response domain.User + err = json.Unmarshal(rec.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, expectedUser, response) + + mockService.AssertExpectations(t) + }) + + t.Run("conflict", func(t *testing.T) { + req := domain.UserRegisterRequest{ + Name: "John Doe", + Email: "john@example.com", + Password: "password123", + } + + mockService.On("Register", mock.Anything, mock.AnythingOfType("*domain.UserRegisterRequest")).Return(domain.User{}, domain.ErrConflict) + + jsonBody, _ := json.Marshal(req) + httpReq := httptest.NewRequest(http.MethodPost, "/users/register", bytes.NewBuffer(jsonBody)) + httpReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(httpReq, rec) + + err := handler.Register(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusConflict, rec.Code) + + mockService.AssertExpectations(t) + }) +} + +func TestUserHandler_Login(t *testing.T) { + mockService := new(MockUserService) + handler := &UserHandler{Service: mockService} + + e := echo.New() + + t.Run("success", func(t *testing.T) { + req := domain.UserLoginRequest{ + Email: "john@example.com", + Password: "password123", + } + + expectedUser := domain.User{ + ID: 1, + Name: "John Doe", + Email: "john@example.com", + } + + mockService.On("Login", mock.Anything, mock.AnythingOfType("*domain.UserLoginRequest")).Return(expectedUser, nil) + + jsonBody, _ := json.Marshal(req) + httpReq := httptest.NewRequest(http.MethodPost, "/users/login", bytes.NewBuffer(jsonBody)) + httpReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(httpReq, rec) + + err := handler.Login(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + + var response domain.User + err = json.Unmarshal(rec.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, expectedUser, response) + + mockService.AssertExpectations(t) + }) + + t.Run("not found", func(t *testing.T) { + req := domain.UserLoginRequest{ + Email: "notfound@example.com", + Password: "password123", + } + + mockService.On("Login", mock.Anything, mock.AnythingOfType("*domain.UserLoginRequest")).Return(domain.User{}, domain.ErrNotFound) + + jsonBody, _ := json.Marshal(req) + httpReq := httptest.NewRequest(http.MethodPost, "/users/login", bytes.NewBuffer(jsonBody)) + httpReq.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + c := e.NewContext(httpReq, rec) + + err := handler.Login(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusNotFound, rec.Code) + + mockService.AssertExpectations(t) + }) +} + +func TestUserHandler_Delete(t *testing.T) { + mockService := new(MockUserService) + handler := &UserHandler{Service: mockService} + + e := echo.New() + + t.Run("success", func(t *testing.T) { + mockService.On("Delete", mock.Anything, int64(1)).Return(nil) + + req := httptest.NewRequest(http.MethodDelete, "/users/1", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/users/:id") + c.SetParamNames("id") + c.SetParamValues("1") + + err := handler.Delete(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + + mockService.AssertExpectations(t) + }) + + t.Run("not found", func(t *testing.T) { + mockService.On("Delete", mock.Anything, int64(999)).Return(domain.ErrNotFound) + + req := httptest.NewRequest(http.MethodDelete, "/users/999", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/users/:id") + c.SetParamNames("id") + c.SetParamValues("999") + + err := handler.Delete(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusNotFound, rec.Code) + + mockService.AssertExpectations(t) + }) + + t.Run("invalid id", func(t *testing.T) { + req := httptest.NewRequest(http.MethodDelete, "/users/invalid", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/users/:id") + c.SetParamNames("id") + c.SetParamValues("invalid") + + err := handler.Delete(c) + + assert.NoError(t, err) + assert.Equal(t, http.StatusNotFound, rec.Code) + }) +} diff --git a/user.sql b/user.sql new file mode 100644 index 0000000..567f9da --- /dev/null +++ b/user.sql @@ -0,0 +1,16 @@ +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- Insert sample users +INSERT INTO users (name, email, password) VALUES +('John Doe', 'john@example.com', 'password123'), +('Jane Smith', 'jane@example.com', 'password456'), +('Bob Johnson', 'bob@example.com', 'password789') +ON DUPLICATE KEY UPDATE name=VALUES(name); diff --git a/user/service.go b/user/service.go new file mode 100644 index 0000000..1415f17 --- /dev/null +++ b/user/service.go @@ -0,0 +1,122 @@ +package user + +import ( + "context" + "time" + + "github.com/sirupsen/logrus" + + "github.com/bxcodec/go-clean-arch/domain" +) + +// UserRepository represent the user's repository contract +// +//go:generate mockery --name UserRepository +type UserRepository interface { + Fetch(ctx context.Context, cursor string, num int64) (res []domain.User, nextCursor string, err error) + GetByID(ctx context.Context, id int64) (domain.User, error) + GetByEmail(ctx context.Context, email string) (domain.User, error) + Update(ctx context.Context, u *domain.User) error + Store(ctx context.Context, u *domain.User) error + Delete(ctx context.Context, id int64) error +} + +type Service struct { + userRepo UserRepository +} + +// NewService will create a new user service object +func NewService(u UserRepository) *Service { + return &Service{ + userRepo: u, + } +} + +func (u *Service) Fetch(ctx context.Context, cursor string, num int64) (res []domain.User, nextCursor string, err error) { + res, nextCursor, err = u.userRepo.Fetch(ctx, cursor, num) + if err != nil { + return nil, "", err + } + return +} + +func (u *Service) GetByID(ctx context.Context, id int64) (res domain.User, err error) { + res, err = u.userRepo.GetByID(ctx, id) + if err != nil { + return + } + return +} + +func (u *Service) GetByEmail(ctx context.Context, email string) (res domain.User, err error) { + res, err = u.userRepo.GetByEmail(ctx, email) + if err != nil { + return + } + return +} + +func (s *Service) Update(ctx context.Context, u *domain.User) (err error) { + u.UpdatedAt = time.Now() + return s.userRepo.Update(ctx, u) +} + +func (u *Service) Store(ctx context.Context, m *domain.User) (err error) { + existedUser, _ := u.GetByEmail(ctx, m.Email) // ignore if any error + if existedUser != (domain.User{}) { + return domain.ErrConflict + } + + err = u.userRepo.Store(ctx, m) + return +} + +func (u *Service) Delete(ctx context.Context, id int64) (err error) { + existedUser, err := u.userRepo.GetByID(ctx, id) + if err != nil { + return + } + if existedUser == (domain.User{}) { + return domain.ErrNotFound + } + return u.userRepo.Delete(ctx, id) +} + +func (u *Service) Register(ctx context.Context, req *domain.UserRegisterRequest) (res domain.User, err error) { + user := &domain.User{ + Name: req.Name, + Email: req.Email, + Password: req.Password, // In production, this should be hashed + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + err = u.Store(ctx, user) + if err != nil { + return domain.User{}, err + } + + // Return user without password + res = *user + res.Password = "" + return +} + +func (u *Service) Login(ctx context.Context, req *domain.UserLoginRequest) (res domain.User, err error) { + user, err := u.GetByEmail(ctx, req.Email) + if err != nil { + logrus.Error("User not found:", err) + return domain.User{}, domain.ErrNotFound + } + + // In production, compare hashed passwords + if user.Password != req.Password { + logrus.Error("Invalid password") + return domain.User{}, domain.ErrBadParamInput + } + + // Return user without password + res = user + res.Password = "" + return +} diff --git a/user/service_test.go b/user/service_test.go new file mode 100644 index 0000000..b5b9ca8 --- /dev/null +++ b/user/service_test.go @@ -0,0 +1,301 @@ +package user + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "github.com/bxcodec/go-clean-arch/domain" +) + +func TestService_Fetch(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewService(mockRepo) + + t.Run("success", func(t *testing.T) { + expectedUsers := []domain.User{ + {ID: 1, Name: "John Doe", Email: "john@example.com"}, + {ID: 2, Name: "Jane Doe", Email: "jane@example.com"}, + } + + mockRepo.On("Fetch", mock.Anything, "", int64(10)).Return(expectedUsers, "next_cursor", nil) + + users, nextCursor, err := service.Fetch(context.TODO(), "", 10) + + assert.NoError(t, err) + assert.Equal(t, expectedUsers, users) + assert.Equal(t, "next_cursor", nextCursor) + mockRepo.AssertExpectations(t) + }) + + t.Run("error", func(t *testing.T) { + mockRepo.On("Fetch", mock.Anything, "", int64(10)).Return(nil, "", assert.AnError) + + users, nextCursor, err := service.Fetch(context.TODO(), "", 10) + + assert.Error(t, err) + assert.Nil(t, users) + assert.Empty(t, nextCursor) + mockRepo.AssertExpectations(t) + }) +} + +func TestService_GetByID(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewService(mockRepo) + + t.Run("success", func(t *testing.T) { + expectedUser := domain.User{ID: 1, Name: "John Doe", Email: "john@example.com"} + + mockRepo.On("GetByID", mock.Anything, int64(1)).Return(expectedUser, nil) + + user, err := service.GetByID(context.TODO(), 1) + + assert.NoError(t, err) + assert.Equal(t, expectedUser, user) + mockRepo.AssertExpectations(t) + }) + + t.Run("not found", func(t *testing.T) { + mockRepo.On("GetByID", mock.Anything, int64(999)).Return(domain.User{}, domain.ErrNotFound) + + user, err := service.GetByID(context.TODO(), 999) + + assert.Error(t, err) + assert.Equal(t, domain.ErrNotFound, err) + assert.Equal(t, domain.User{}, user) + mockRepo.AssertExpectations(t) + }) +} + +func TestService_GetByEmail(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewService(mockRepo) + + t.Run("success", func(t *testing.T) { + expectedUser := domain.User{ID: 1, Name: "John Doe", Email: "john@example.com"} + + mockRepo.On("GetByEmail", mock.Anything, "john@example.com").Return(expectedUser, nil) + + user, err := service.GetByEmail(context.TODO(), "john@example.com") + + assert.NoError(t, err) + assert.Equal(t, expectedUser, user) + mockRepo.AssertExpectations(t) + }) + + t.Run("not found", func(t *testing.T) { + mockRepo.On("GetByEmail", mock.Anything, "notfound@example.com").Return(domain.User{}, domain.ErrNotFound) + + user, err := service.GetByEmail(context.TODO(), "notfound@example.com") + + assert.Error(t, err) + assert.Equal(t, domain.ErrNotFound, err) + assert.Equal(t, domain.User{}, user) + mockRepo.AssertExpectations(t) + }) +} + +func TestService_Store(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewService(mockRepo) + + t.Run("success", func(t *testing.T) { + user := &domain.User{ + Name: "John Doe", + Email: "john@example.com", + Password: "password123", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + mockRepo.On("GetByEmail", mock.Anything, "john@example.com").Return(domain.User{}, domain.ErrNotFound) + mockRepo.On("Store", mock.Anything, user).Return(nil) + + err := service.Store(context.TODO(), user) + + assert.NoError(t, err) + mockRepo.AssertExpectations(t) + }) + + t.Run("conflict", func(t *testing.T) { + existingUser := domain.User{ID: 1, Name: "John Doe", Email: "john@example.com"} + user := &domain.User{ + Name: "John Doe", + Email: "john@example.com", + Password: "password123", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + mockRepo.On("GetByEmail", mock.Anything, "john@example.com").Return(existingUser, nil) + + err := service.Store(context.TODO(), user) + + assert.Error(t, err) + assert.Equal(t, domain.ErrConflict, err) + mockRepo.AssertExpectations(t) + }) +} + +func TestService_Update(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewService(mockRepo) + + t.Run("success", func(t *testing.T) { + user := &domain.User{ + ID: 1, + Name: "John Updated", + Email: "john@example.com", + Password: "newpassword", + UpdatedAt: time.Now(), + } + + mockRepo.On("Update", mock.Anything, user).Return(nil) + + err := service.Update(context.TODO(), user) + + assert.NoError(t, err) + mockRepo.AssertExpectations(t) + }) +} + +func TestService_Delete(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewService(mockRepo) + + t.Run("success", func(t *testing.T) { + existingUser := domain.User{ID: 1, Name: "John Doe", Email: "john@example.com"} + + mockRepo.On("GetByID", mock.Anything, int64(1)).Return(existingUser, nil) + mockRepo.On("Delete", mock.Anything, int64(1)).Return(nil) + + err := service.Delete(context.TODO(), 1) + + assert.NoError(t, err) + mockRepo.AssertExpectations(t) + }) + + t.Run("not found", func(t *testing.T) { + mockRepo.On("GetByID", mock.Anything, int64(999)).Return(domain.User{}, domain.ErrNotFound) + + err := service.Delete(context.TODO(), 999) + + assert.Error(t, err) + assert.Equal(t, domain.ErrNotFound, err) + mockRepo.AssertExpectations(t) + }) +} + +func TestService_Register(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewService(mockRepo) + + t.Run("success", func(t *testing.T) { + req := &domain.UserRegisterRequest{ + Name: "John Doe", + Email: "john@example.com", + Password: "password123", + } + + mockRepo.On("GetByEmail", mock.Anything, "john@example.com").Return(domain.User{}, domain.ErrNotFound) + mockRepo.On("Store", mock.Anything, mock.AnythingOfType("*domain.User")).Return(nil) + + user, err := service.Register(context.TODO(), req) + + assert.NoError(t, err) + assert.Equal(t, "John Doe", user.Name) + assert.Equal(t, "john@example.com", user.Email) + assert.Empty(t, user.Password) // Password should be omitted in response + mockRepo.AssertExpectations(t) + }) + + t.Run("conflict", func(t *testing.T) { + req := &domain.UserRegisterRequest{ + Name: "John Doe", + Email: "john@example.com", + Password: "password123", + } + + existingUser := domain.User{ID: 1, Name: "John Doe", Email: "john@example.com"} + mockRepo.On("GetByEmail", mock.Anything, "john@example.com").Return(existingUser, nil) + + user, err := service.Register(context.TODO(), req) + + assert.Error(t, err) + assert.Equal(t, domain.ErrConflict, err) + assert.Equal(t, domain.User{}, user) + mockRepo.AssertExpectations(t) + }) +} + +func TestService_Login(t *testing.T) { + mockRepo := new(MockUserRepository) + service := NewService(mockRepo) + + t.Run("success", func(t *testing.T) { + req := &domain.UserLoginRequest{ + Email: "john@example.com", + Password: "password123", + } + + user := domain.User{ + ID: 1, + Name: "John Doe", + Email: "john@example.com", + Password: "password123", + } + + mockRepo.On("GetByEmail", mock.Anything, "john@example.com").Return(user, nil) + + result, err := service.Login(context.TODO(), req) + + assert.NoError(t, err) + assert.Equal(t, "John Doe", result.Name) + assert.Equal(t, "john@example.com", result.Email) + assert.Empty(t, result.Password) // Password should be omitted in response + mockRepo.AssertExpectations(t) + }) + + t.Run("user not found", func(t *testing.T) { + req := &domain.UserLoginRequest{ + Email: "notfound@example.com", + Password: "password123", + } + + mockRepo.On("GetByEmail", mock.Anything, "notfound@example.com").Return(domain.User{}, domain.ErrNotFound) + + result, err := service.Login(context.TODO(), req) + + assert.Error(t, err) + assert.Equal(t, domain.ErrNotFound, err) + assert.Equal(t, domain.User{}, result) + mockRepo.AssertExpectations(t) + }) + + t.Run("invalid password", func(t *testing.T) { + req := &domain.UserLoginRequest{ + Email: "john@example.com", + Password: "wrongpassword", + } + + user := domain.User{ + ID: 1, + Name: "John Doe", + Email: "john@example.com", + Password: "password123", + } + + mockRepo.On("GetByEmail", mock.Anything, "john@example.com").Return(user, nil) + + result, err := service.Login(context.TODO(), req) + + assert.Error(t, err) + assert.Equal(t, domain.ErrBadParamInput, err) + assert.Equal(t, domain.User{}, result) + mockRepo.AssertExpectations(t) + }) +}