diff --git a/go.mod b/go.mod index ecbd927..d881232 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/go-goyave/goyave-blog-example go 1.22 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/go-faker/faker/v4 v4.4.1 github.com/google/uuid v1.6.0 github.com/gosimple/slug v1.14.0 @@ -10,9 +11,10 @@ require ( github.com/samber/lo v1.39.0 github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.23.0 + gorm.io/driver/postgres v1.5.7 gorm.io/gorm v1.25.10 goyave.dev/filter v0.6.1-0.20240510154020-982e23d0cc78 - goyave.dev/goyave/v5 v5.0.0-rc11 + goyave.dev/goyave/v5 v5.0.0-rc9.0.20240513133605-118aa7b9b81f ) require ( @@ -31,6 +33,5 @@ require ( golang.org/x/sync v0.7.0 // indirect golang.org/x/text v0.15.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gorm.io/driver/postgres v1.5.7 // indirect goyave.dev/copier v0.4.3 // indirect ) diff --git a/go.sum b/go.sum index c07c6c1..05b9269 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/Code-Hex/uniseg v0.2.0 h1:QB/2UJFvEuRLSZqe+Sb1XQBTWjqGVbZoC6oSWzQRKws= github.com/Code-Hex/uniseg v0.2.0/go.mod h1:/ndS2tP+X1lk2HUOcXWGtVTxVq0lWilwgMa4CbzdRsg= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -27,6 +29,7 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -68,5 +71,5 @@ goyave.dev/copier v0.4.3 h1:MxX2wBnhQUbv0mHPXEgw/zS4TZMtTVpzj/aYS3h4amk= goyave.dev/copier v0.4.3/go.mod h1:WJu0Ex81v29f5U0eMWzSNsMTGmuGY6lQ/q5yGlyLDsU= goyave.dev/filter v0.6.1-0.20240510154020-982e23d0cc78 h1:MFv0XiihEpvSLzK3C70amCTVtp+j1Vz8fkC2q2KmQ0c= goyave.dev/filter v0.6.1-0.20240510154020-982e23d0cc78/go.mod h1:zeSUREjru306U6K1Op7xdtQOXWPXDlCCA1vAoGgVzsg= -goyave.dev/goyave/v5 v5.0.0-rc11 h1:D3EhhsBm3fRSlIXgJgnUOyBGtc/H04/8N8a0hBOyvRk= -goyave.dev/goyave/v5 v5.0.0-rc11/go.mod h1:9FZ+9lQa5gzQDWLV9jGT2ZO35vDgYU8pK3Jov5E2zQc= +goyave.dev/goyave/v5 v5.0.0-rc9.0.20240513133605-118aa7b9b81f h1:4FDSIT2asJrCjlED83FxIA/MQ2hsM86QqOWeo4COlOU= +goyave.dev/goyave/v5 v5.0.0-rc9.0.20240513133605-118aa7b9b81f/go.mod h1:9FZ+9lQa5gzQDWLV9jGT2ZO35vDgYU8pK3Jov5E2zQc= diff --git a/http/controller/article/article_test.go b/http/controller/article/article_test.go index 8d2a4ff..1f132e6 100644 --- a/http/controller/article/article_test.go +++ b/http/controller/article/article_test.go @@ -3,6 +3,7 @@ package article import ( "context" "fmt" + "io" "net/http" "net/http/httptest" "testing" @@ -17,6 +18,7 @@ import ( "goyave.dev/goyave/v5/auth" "goyave.dev/goyave/v5/database" "goyave.dev/goyave/v5/middleware/parse" + "goyave.dev/goyave/v5/slog" "goyave.dev/goyave/v5/util/testutil" "goyave.dev/goyave/v5/util/typeutil" ) @@ -26,7 +28,7 @@ type updateArticleDTO struct { Contents string `json:"contents"` } -type ServiceMock struct { +type serviceMock struct { paginator *database.PaginatorDTO[*dto.Article] article *dto.Article err error @@ -37,36 +39,36 @@ type ServiceMock struct { isOwner bool } -func (s *ServiceMock) Index(_ context.Context, _ *filter.Request) (*database.PaginatorDTO[*dto.Article], error) { +func (s *serviceMock) Index(_ context.Context, _ *filter.Request) (*database.PaginatorDTO[*dto.Article], error) { return s.paginator, s.err } -func (s *ServiceMock) GetBySlug(_ context.Context, slug string) (*dto.Article, error) { +func (s *serviceMock) GetBySlug(_ context.Context, slug string) (*dto.Article, error) { if s.article.Slug == slug { return s.article, s.err } return nil, gorm.ErrRecordNotFound } -func (s *ServiceMock) Create(_ context.Context, createDTO *dto.CreateArticle) error { +func (s *serviceMock) Create(_ context.Context, createDTO *dto.CreateArticle) error { s.createCallback(createDTO) return s.err } -func (s *ServiceMock) Update(_ context.Context, _ uint, updateDTO *dto.UpdateArticle) error { +func (s *serviceMock) Update(_ context.Context, _ uint, updateDTO *dto.UpdateArticle) error { s.updateCallback(updateDTO) return s.err } -func (s *ServiceMock) Delete(_ context.Context, _ uint) error { +func (s *serviceMock) Delete(_ context.Context, _ uint) error { return s.err } -func (s *ServiceMock) IsOwner(_ context.Context, _ uint, _ uint) (bool, error) { +func (s *serviceMock) IsOwner(_ context.Context, _ uint, _ uint) (bool, error) { return s.isOwner, nil } -func (s *ServiceMock) Name() string { +func (s *serviceMock) Name() string { return service.Article } @@ -99,9 +101,9 @@ func generatePaginator() *database.PaginatorDTO[*dto.Article] { } } -func setupArticleTest(t *testing.T, service *ServiceMock) *testutil.TestServer { +func setupArticleTest(t *testing.T, service *serviceMock) *testutil.TestServer { server := testutil.NewTestServer(t, "config.test.json") - // server.Logger = slog.New(slog.NewHandler(true, io.Discard)) + server.Logger = slog.New(slog.NewHandler(true, io.Discard)) server.RegisterService(service) server.RegisterRoutes(func(_ *goyave.Server, r *goyave.Router) { r.GlobalMiddleware(&parse.Middleware{}) @@ -112,7 +114,7 @@ func setupArticleTest(t *testing.T, service *ServiceMock) *testutil.TestServer { func TestArticle(t *testing.T) { t.Run("Index", func(t *testing.T) { - service := &ServiceMock{ + service := &serviceMock{ paginator: generatePaginator(), } server := setupArticleTest(t, service) @@ -135,7 +137,7 @@ func TestArticle(t *testing.T) { }) t.Run("Show", func(t *testing.T) { - service := &ServiceMock{ + service := &serviceMock{ article: typeutil.MustConvert[*dto.Article](seed.ArticleGenerator()), } server := setupArticleTest(t, service) @@ -157,7 +159,7 @@ func TestArticle(t *testing.T) { }) t.Run("Create", func(t *testing.T) { - service := &ServiceMock{} + service := &serviceMock{} server := setupArticleTest(t, service) user := &dto.InternalUser{ User: dto.User{ID: 1}, @@ -173,7 +175,7 @@ func TestArticle(t *testing.T) { request.Header.Set("Content-Type", "application/json") service.createCallback = func(createDTO *dto.CreateArticle) { - expected := typeutil.Copy(&dto.CreateArticle{AuthorID: user.ID}, createDTO) + expected := typeutil.Copy(&dto.CreateArticle{AuthorID: user.ID}, requestBody) assert.Equal(t, expected, createDTO) } @@ -201,7 +203,7 @@ func TestArticle(t *testing.T) { }) t.Run("Update", func(t *testing.T) { - service := &ServiceMock{} + service := &serviceMock{} server := setupArticleTest(t, service) user := &dto.InternalUser{ User: dto.User{ID: 1}, @@ -273,7 +275,7 @@ func TestArticle(t *testing.T) { }) t.Run("Delete", func(t *testing.T) { - service := &ServiceMock{} + service := &serviceMock{} server := setupArticleTest(t, service) user := &dto.InternalUser{ User: dto.User{ID: 1}, diff --git a/http/controller/user/user.go b/http/controller/user/user.go index 2691da1..de43a87 100644 --- a/http/controller/user/user.go +++ b/http/controller/user/user.go @@ -70,7 +70,7 @@ func (ctrl *Controller) ShowAvatar(response *goyave.Response, request *goyave.Re } if !user.Avatar.Valid { - response.File(ctrl.StorageService.GetEmbedImagesFS(), "default_profile_picture.png") + response.File(ctrl.StorageService.GetEmbedImagesFS(), "default_profile_picture.jpg") return } diff --git a/http/controller/user/user_test.go b/http/controller/user/user_test.go new file mode 100644 index 0000000..56cbc0f --- /dev/null +++ b/http/controller/user/user_test.go @@ -0,0 +1,302 @@ +package user + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path" + "regexp" + "testing" + "time" + + "github.com/go-goyave/goyave-blog-example/dto" + "github.com/go-goyave/goyave-blog-example/service" + "github.com/go-goyave/goyave-blog-example/service/storage" + "github.com/guregu/null/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "goyave.dev/goyave/v5" + "goyave.dev/goyave/v5/auth" + "goyave.dev/goyave/v5/middleware/parse" + "goyave.dev/goyave/v5/slog" + "goyave.dev/goyave/v5/util/fsutil/osfs" + "goyave.dev/goyave/v5/util/testutil" + "goyave.dev/goyave/v5/util/typeutil" + + "github.com/DATA-DOG/go-sqlmock" +) + +type upsertDTO struct { + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password"` +} + +type serviceMock struct { + user *dto.InternalUser + registerCallback func(*dto.RegisterUser) + updateCallback func(*dto.UpdateUser) + err error +} + +func (s *serviceMock) UniqueScope() func(db *gorm.DB, val any) *gorm.DB { + return func(db *gorm.DB, val any) *gorm.DB { + return db.Table("users").Where("email", val) + } +} + +func (s *serviceMock) GetByID(_ context.Context, _ uint) (*dto.InternalUser, error) { + return s.user, s.err +} + +func (s *serviceMock) Register(_ context.Context, registerDTO *dto.RegisterUser) error { + s.registerCallback(registerDTO) + return s.err +} + +func (s *serviceMock) Update(_ context.Context, _ uint, updateDTO *dto.UpdateUser) error { + s.updateCallback(updateDTO) + return s.err +} + +func (s *serviceMock) Name() string { + return service.User +} + +const mockAuthUserMeta = "mock:authuser" + +type mockAuthMiddleware struct { + goyave.Component +} + +func (m *mockAuthMiddleware) Handle(next goyave.Handler) goyave.Handler { + return func(response *goyave.Response, request *goyave.Request) { + request.User, _ = request.Route.LookupMeta(mockAuthUserMeta) + requireAuth, _ := request.Route.LookupMeta(auth.MetaAuth) + if requireAuth.(bool) && request.User == nil { + response.Status(http.StatusUnauthorized) + return + } + next(response, request) + } +} + +func setupUserTest(t *testing.T, service *serviceMock) *testutil.TestServer { + server := testutil.NewTestServer(t, "config.test.json") + server.Logger = slog.New(slog.NewHandler(true, io.Discard)) + server.RegisterService(service) + + rootDir := testutil.FindRootDirectory() + + imgFS := osfs.New(path.Join(rootDir, "resources/img")) + storageService := storage.NewService(imgFS, imgFS) + server.RegisterService(storageService) + server.RegisterRoutes(func(_ *goyave.Server, r *goyave.Router) { + r.GlobalMiddleware(&parse.Middleware{}) + r.Controller(NewController()) + }) + return server +} + +func setupMock(t *testing.T, server *testutil.TestServer) sqlmock.Sqlmock { + server.Config().Set("database.config.prepareStmt", false) + server.Config().Set("database.connection", "mock") + mockDB, mock, err := sqlmock.New() + require.NoError(t, err) + dialector := postgres.New(postgres.Config{ + DSN: "mock_db", + DriverName: "postgres", + Conn: mockDB, + PreferSimpleProtocol: true, + }) + require.NoError(t, server.ReplaceDB(dialector)) + return mock +} + +func TestUser(t *testing.T) { + t.Run("ShowProfile", func(t *testing.T) { + server := setupUserTest(t, &serviceMock{}) + user := &dto.InternalUser{ + User: dto.User{ + ID: 1, + CreatedAt: time.Now().Round(0).UTC(), + Username: "johndoe", + Email: "johndoe@example.org", + }, + Avatar: null.NewString("img.jpeg", true), + } + server.Router().GlobalMiddleware(&mockAuthMiddleware{}).SetMeta(mockAuthUserMeta, user) + + request := httptest.NewRequest(http.MethodGet, "/users/profile", nil) + response := server.TestRequest(request) + assert.Equal(t, http.StatusOK, response.StatusCode) + profile, err := testutil.ReadJSONBody[*dto.InternalUser](response.Body) + assert.NoError(t, err) + assert.NoError(t, response.Body.Close()) + assert.Equal(t, &dto.InternalUser{User: user.User}, profile) + }) + + t.Run("ShowAvatar", func(t *testing.T) { + service := &serviceMock{} + server := setupUserTest(t, service) + user := &dto.InternalUser{ + User: dto.User{ID: 1}, + Avatar: null.NewString("test_profile_picture.jpg", true), + } + service.user = user + + imgFile, err := osfs.New(path.Join(testutil.FindRootDirectory(), "resources/img")).Open("test_profile_picture.jpg") + require.NoError(t, err) + profilePicture, err := io.ReadAll(imgFile) + assert.NoError(t, imgFile.Close()) + require.NoError(t, err) + + request := httptest.NewRequest(http.MethodGet, "/users/1/avatar", nil) + response := server.TestRequest(request) + assert.Equal(t, http.StatusOK, response.StatusCode) + responseProfilePicture, err := io.ReadAll(response.Body) + assert.NoError(t, err) + assert.NoError(t, response.Body.Close()) + assert.Equal(t, profilePicture, responseProfilePicture) + + t.Run("default_avatar", func(t *testing.T) { + service.user = &dto.InternalUser{User: user.User} + imgFile, err := osfs.New(path.Join(testutil.FindRootDirectory(), "resources/img")).Open("default_profile_picture.jpg") + require.NoError(t, err) + defaultProfilePicture, err := io.ReadAll(imgFile) + assert.NoError(t, imgFile.Close()) + require.NoError(t, err) + + request := httptest.NewRequest(http.MethodGet, "/users/1/avatar", nil) + response := server.TestRequest(request) + assert.Equal(t, http.StatusOK, response.StatusCode) + responseProfilePicture, err := io.ReadAll(response.Body) + assert.NoError(t, err) + assert.NoError(t, response.Body.Close()) + assert.Equal(t, defaultProfilePicture, responseProfilePicture) + }) + + t.Run("invalid_id", func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/users/999999999999999999999999999999999999/avatar", nil) + response := server.TestRequest(request) + assert.Equal(t, http.StatusNotFound, response.StatusCode) + assert.NoError(t, response.Body.Close()) + }) + + t.Run("error", func(t *testing.T) { + service.err = fmt.Errorf("test error") + request := httptest.NewRequest(http.MethodGet, "/users/1/avatar", nil) + response := server.TestRequest(request) + assert.Equal(t, http.StatusInternalServerError, response.StatusCode) + assert.NoError(t, response.Body.Close()) + }) + + t.Run("not_found", func(t *testing.T) { + service.err = gorm.ErrRecordNotFound + request := httptest.NewRequest(http.MethodGet, "/users/1/avatar", nil) + response := server.TestRequest(request) + assert.Equal(t, http.StatusNotFound, response.StatusCode) + assert.NoError(t, response.Body.Close()) + }) + }) + + t.Run("Register", func(t *testing.T) { + service := &serviceMock{} + server := setupUserTest(t, service) + mock := setupMock(t, server) + + requestBody := &upsertDTO{ + Email: "johndoe@example.org", + Username: "johndoe", + Password: "p4ssW0rd_", + } + + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM \"users\" WHERE \"email\" = $1")). + WithArgs(requestBody.Email). + WillReturnRows(sqlmock.NewRows([]string{"count(*)"}).AddRow(0)) + defer func() { + assert.NoError(t, mock.ExpectationsWereMet()) + }() + + request := httptest.NewRequest(http.MethodPost, "/users", testutil.ToJSON(requestBody)) + request.Header.Set("Content-Type", "application/json") + + service.registerCallback = func(registerDTO *dto.RegisterUser) { + expected := typeutil.Copy(&dto.RegisterUser{}, requestBody) + expected.Password = requestBody.Password + assert.Equal(t, expected, registerDTO) + } + + response := server.TestRequest(request) + assert.Equal(t, http.StatusCreated, response.StatusCode) + assert.NoError(t, response.Body.Close()) + + t.Run("error", func(t *testing.T) { + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM \"users\" WHERE \"email\" = $1")). + WithArgs(requestBody.Email). + WillReturnRows(sqlmock.NewRows([]string{"count(*)"}).AddRow(0)) + service.err = fmt.Errorf("test error") + request := httptest.NewRequest(http.MethodPost, "/users", testutil.ToJSON(requestBody)) + request.Header.Set("Content-Type", "application/json") + response := server.TestRequest(request) + assert.Equal(t, http.StatusInternalServerError, response.StatusCode) + assert.NoError(t, response.Body.Close()) + }) + }) + + t.Run("Update", func(t *testing.T) { + service := &serviceMock{} + server := setupUserTest(t, service) + user := &dto.InternalUser{ + User: dto.User{ + ID: 1, + Username: "johndoe", + Email: "johndoe@example.org", + }, + } + server.Router().GlobalMiddleware(&mockAuthMiddleware{}).SetMeta(mockAuthUserMeta, user) + mock := setupMock(t, server) + + requestBody := &upsertDTO{ + Email: "johndoe-updated@example.org", + Username: "johndoe-updated", + Password: "new-p4ssW0rd_", + } + + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM \"users\" WHERE \"email\" = $1")). + WithArgs(requestBody.Email). + WillReturnRows(sqlmock.NewRows([]string{"count(*)"}).AddRow(0)) + defer func() { + assert.NoError(t, mock.ExpectationsWereMet()) + }() + + request := httptest.NewRequest(http.MethodPatch, "/users", testutil.ToJSON(requestBody)) + request.Header.Set("Content-Type", "application/json") + + service.updateCallback = func(registerDTO *dto.UpdateUser) { + expected := typeutil.Copy(&dto.UpdateUser{}, requestBody) + expected.Password = typeutil.NewUndefined(requestBody.Password) + assert.Equal(t, expected, registerDTO) + } + + response := server.TestRequest(request) + assert.Equal(t, http.StatusNoContent, response.StatusCode) + assert.NoError(t, response.Body.Close()) + + t.Run("error", func(t *testing.T) { + mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM \"users\" WHERE \"email\" = $1")). + WithArgs(requestBody.Email). + WillReturnRows(sqlmock.NewRows([]string{"count(*)"}).AddRow(0)) + service.err = fmt.Errorf("test error") + request := httptest.NewRequest(http.MethodPatch, "/users", testutil.ToJSON(requestBody)) + request.Header.Set("Content-Type", "application/json") + response := server.TestRequest(request) + assert.Equal(t, http.StatusInternalServerError, response.StatusCode) + assert.NoError(t, response.Body.Close()) + }) + }) +} diff --git a/resources/img/default_profile_picture.jpg b/resources/img/default_profile_picture.jpg new file mode 100644 index 0000000..1ec7207 Binary files /dev/null and b/resources/img/default_profile_picture.jpg differ diff --git a/resources/img/default_profile_picture.png b/resources/img/default_profile_picture.png deleted file mode 100644 index 31dc931..0000000 Binary files a/resources/img/default_profile_picture.png and /dev/null differ diff --git a/resources/img/test_profile_picture.jpg b/resources/img/test_profile_picture.jpg new file mode 100644 index 0000000..db052a3 Binary files /dev/null and b/resources/img/test_profile_picture.jpg differ