diff --git a/.env.example b/.env.example index 86e4c7c..025e515 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,3 @@ POSTGRES_SSL_MODE=disable ## Server X_API_KEY=supersecret -POSTGRES_PASSWORD=postgres diff --git a/.gitignore b/.gitignore index dd86ddc..fd5d660 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ postgres_data internal/.DS_Store **/.terraform kubeconfig +test.out diff --git a/README.md b/README.md index 41f809d..8471481 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,26 @@ -# Simple CRUD Interface +# Cruder – Simple User CRUD API -Rewrite the README according to the application. +A simple user management CRUD API built with Go (Gin). +Features include: +- JSON structured logging middleware +- API key authentication (`X-Api-Key`) +- Auto-generated Swagger documentation at https://cruder.sytes.net/swagger/index.html + +The original task description can be found [here](./TASK.md). -The task itself can be found [here](/TASK.md) ## Prerequisites +- [Go](https://go.dev/learn/) - [Docker](https://www.docker.com/get-started/) -- [Goose](https://github.com/pressly/goose) -- [Gosec](https://github.com/securego/gosec) +- [Goose](https://github.com/pressly/goose) (migrations) +- [Gosec](https://github.com/securego/gosec) (security analysis) ## Getting Started -1. Start database +1. Make a copy of .env.example and name it .env + +2. Start database ``` ## Via Makefile @@ -22,7 +30,7 @@ make db docker-compose up -d db ``` -2. Run migrations +3. Run migrations ``` ## Via Makefile @@ -34,21 +42,40 @@ DB_STRING="host=localhost port=5432 user=postgres password=postgres dbname=postg goose -dir ./migrations $(DB_DRIVER) $(DB_STRING) up ``` -3. Run application +4. Run application ``` go run cmd/main.go ``` -## API +5. Generate API documentation + +``` +make swagger +``` + +6. Run tests + +``` +make test +``` + +## Infrastructure + +The project also contains terraform scripts for setting up an AKS cluster in Azure. ([Read more](./platform/terraform/README.md)) -The project features a simple CRUD API for users. It has a JSON logger middleware and an X-Api-Key middleware for authentication. +There is also k8s-ingress setup which allows any deployment in the AKS cluster to be accessible through https and with a DNS. ([Read more](./platform/k8s-ingress/README.md)) -The project also contains terraform scripts for setting up AKS cluster in Azure. (Read more from readme at ./platform/terraform) +## Deployment -There is also k8s-ingress setup which allows any deployment in the AKS cluster to be accessible through https and with a DNS. (Read more from readme at ./platform/terraform) +The API itself has been deployed to the AKS cluster with two replicas. -The API itself has been deployed to the AKS cluster, manifests are in k8s folder. +The kubernetes manifests are located in [k8s folder](./k8s/) +- cruder.yaml - deployment, service, configmap and ingress for the API +- postgres.yaml - deployment, service and pvc for postgres +- db-migrate + - base -> migrate.yaml - base for running goose migrations + - overlays -> overlays for running either up (apply) or down (rollback) migrations ## Pipeline @@ -64,6 +91,6 @@ There are three workflows - builds a docker image - pushes image to docker hub - runs database migrations with goose - - applies kubernetes manifests to deploy API and postgres + - applies kubernetes manifests to deploy API and postgres to AKS - db-migrate-down - manually startable workflow in case a db rollback is required \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 73302f7..a011fd5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,8 +2,6 @@ // @version 1.0 // @description This is a CRUD API for users. -// @BasePath /api/v1 - // @securityDefinitions.apikey ApiKeyAuth // @in header // @name X-Api-Key diff --git a/docs/docs.go b/docs/docs.go index 9e52716..9d71593 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -355,7 +355,7 @@ const docTemplate = `{ var SwaggerInfo = &swag.Spec{ Version: "1.0", Host: "", - BasePath: "/api/v1", + BasePath: "", Schemes: []string{}, Title: "Users API", Description: "This is a CRUD API for users.", diff --git a/docs/swagger.json b/docs/swagger.json index 11d6457..2acbf4a 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -6,7 +6,6 @@ "contact": {}, "version": "1.0" }, - "basePath": "/api/v1", "paths": { "/users": { "get": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7c4bc48..09749ae 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,4 +1,3 @@ -basePath: /api/v1 definitions: model.ErrorResponse: properties: diff --git a/go.mod b/go.mod index 48fa2be..71d7033 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/PuerkitoBio/purell v1.2.1 // indirect github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect diff --git a/go.sum b/go.sum index 0c00f29..a70359e 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,6 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +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/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= @@ -90,6 +92,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= diff --git a/internal/controller/users_test.go b/internal/controller/users_test.go new file mode 100644 index 0000000..1cdf251 --- /dev/null +++ b/internal/controller/users_test.go @@ -0,0 +1,404 @@ +package controller + +import ( + "bytes" + mock_service "cruder/internal/mocks/service" + "cruder/internal/model" + "cruder/internal/repository" + "cruder/internal/service" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" +) + +func setupRouter(c *UserController) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.Default() + r.GET("/users", c.GetAllUsers) + r.GET("/users/username/:username", c.GetUserByUsername) + r.GET("/users/id/:id", c.GetUserByID) + r.POST("/users", c.CreateUser) + r.DELETE("/users/:id", c.DeleteUser) + r.PUT("/users/:id", c.UpdateUser) + return r +} + +func TestGetAllUsers_Success(t *testing.T) { + // Given: service returns a list of users + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockSvc := mock_service.NewMockUserService(ctrl) + users := []model.User{{ID: 1, Username: "john"}} + mockSvc.EXPECT().GetAll().Return(users, nil) + + controller := NewUserController(mockSvc) + router := setupRouter(controller) + + // When: GET /users is called + req, _ := http.NewRequest("GET", "/users", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Then: response should contain the users + assert.Equal(t, http.StatusOK, w.Code) + var got []model.User + err := json.Unmarshal(w.Body.Bytes(), &got) + assert.NoError(t, err) + assert.Equal(t, users, got) +} + +func TestGetUserByUsername_Success(t *testing.T) { + // Given: service returns a user for username "john_doe" + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockSvc := mock_service.NewMockUserService(ctrl) + expected := &model.User{ID: 1, Username: "john_doe", Email: "john@doe.ee", FullName: "John Doe"} + mockSvc.EXPECT().GetByUsername("john_doe").Return(expected, nil) + + controller := NewUserController(mockSvc) + router := setupRouter(controller) + + // When: GET /users/username/john_doe is called + req, _ := http.NewRequest("GET", "/users/username/john_doe", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Then: response should be 200 with the expected user + assert.Equal(t, http.StatusOK, w.Code) + var got model.User + err := json.Unmarshal(w.Body.Bytes(), &got) + assert.NoError(t, err) + assert.Equal(t, *expected, got) +} + +func TestGetUserByUsername_NotFound(t *testing.T) { + // Given: service returns ErrUserNotFound + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockSvc := mock_service.NewMockUserService(ctrl) + mockSvc.EXPECT().GetByUsername("missing").Return(nil, repository.ErrUserNotFound) + + controller := NewUserController(mockSvc) + router := setupRouter(controller) + + // When: GET /users/username/missing is called + req, _ := http.NewRequest("GET", "/users/username/missing", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Then: response should be 404 with error + assert.Equal(t, http.StatusNotFound, w.Code) + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, "user not found", got.Error) +} + +func TestGetUserByID_Success(t *testing.T) { + // Given: service returns a user for ID 1 + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockSvc := mock_service.NewMockUserService(ctrl) + expected := &model.User{ID: 1, Username: "john_doe", Email: "john@doe.ee", FullName: "John Doe"} + mockSvc.EXPECT().GetByID(int64(1)).Return(expected, nil) + + controller := NewUserController(mockSvc) + router := setupRouter(controller) + + // When: GET /users/id/1 is called + req, _ := http.NewRequest("GET", "/users/id/1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Then: response should be 200 with the expected user + assert.Equal(t, http.StatusOK, w.Code) + var got model.User + err := json.Unmarshal(w.Body.Bytes(), &got) + assert.NoError(t, err) + assert.Equal(t, *expected, got) +} + +func TestGetUserByID_InvalidID(t *testing.T) { + // Given: invalid ID in path + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockSvc := mock_service.NewMockUserService(ctrl) + + controller := NewUserController(mockSvc) + router := setupRouter(controller) + + // When: GET /users/id/abc is called + req, _ := http.NewRequest("GET", "/users/id/abc", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Then: response should be 400 with error + assert.Equal(t, http.StatusBadRequest, w.Code) + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, "invalid id", got.Error) +} + +func TestCreateUser_Success(t *testing.T) { + // Given: valid user and service returns created user + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockSvc := mock_service.NewMockUserService(ctrl) + input := &model.User{Username: "john", Email: "john@doe.ee", FullName: "John Doe"} + created := &model.User{ID: 1, Username: "john", Email: "john@doe.ee", FullName: "John Doe"} + mockSvc.EXPECT().Create(input).Return(created, nil) + + controller := NewUserController(mockSvc) + router := setupRouter(controller) + + body, _ := json.Marshal(input) + + // When: POST /users is called + req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Then: response should be 201 with created user + assert.Equal(t, http.StatusCreated, w.Code) + var got model.User + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, *created, got) +} + +func TestCreateUser_InvalidBody(t *testing.T) { + // Given: malformed JSON body + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockSvc := mock_service.NewMockUserService(ctrl) + + controller := NewUserController(mockSvc) + router := setupRouter(controller) + + // When: POST /users with invalid JSON + req, _ := http.NewRequest("POST", "/users", bytes.NewBuffer([]byte("{bad json"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Then: response should be 400 with error + assert.Equal(t, http.StatusBadRequest, w.Code) + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, "invalid request body", got.Error) +} + +func TestDeleteUser_NotFound(t *testing.T) { + // Given: service returns ErrUserNotFound + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockSvc := mock_service.NewMockUserService(ctrl) + mockSvc.EXPECT().Delete(int64(99)).Return(repository.ErrUserNotFound) + + controller := NewUserController(mockSvc) + router := setupRouter(controller) + + // When: DELETE /users/99 is called + req, _ := http.NewRequest("DELETE", "/users/99", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Then: response should be 404 with error + assert.Equal(t, http.StatusNotFound, w.Code) + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, "user not found", got.Error) +} + +func TestUpdateUser_IDMismatch(t *testing.T) { + // Given: ID in path and body do not match + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockSvc := mock_service.NewMockUserService(ctrl) + + controller := NewUserController(mockSvc) + router := setupRouter(controller) + + user := model.User{ID: 2, Username: "john", Email: "john@doe.ee", FullName: "John Doe"} + body, _ := json.Marshal(user) + + // When: PUT /users/1 with body ID 2 + req, _ := http.NewRequest("PUT", "/users/1", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Then: response should be 400 with mismatch error + assert.Equal(t, http.StatusBadRequest, w.Code) + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, "id in path and body do not match", got.Error) +} + +func TestUpdateUser_Success(t *testing.T) { + // Given: valid update request and service returns updated user + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockSvc := mock_service.NewMockUserService(ctrl) + input := &model.User{ID: 1, Username: "john", Email: "john@doe.ee", FullName: "John Doe"} + updated := &model.User{ID: 1, Username: "johnny", Email: "johnny@doe.ee", FullName: "Johnny Doe"} + mockSvc.EXPECT().Update(input).Return(updated, nil) + + controller := NewUserController(mockSvc) + router := setupRouter(controller) + + body, _ := json.Marshal(input) + + // When: PUT /users/1 is called with valid body + req, _ := http.NewRequest("PUT", "/users/1", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Then: response should be 200 with updated user + assert.Equal(t, http.StatusOK, w.Code) + var got model.User + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, *updated, got) +} + +func TestHandleError_UserNotFound(t *testing.T) { + // Given: a context and ErrUserNotFound + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // When: calling handleError with ErrUserNotFound + result := handleError(c, repository.ErrUserNotFound) + + // Then: should return true and response with 404 + correct error message + assert.True(t, result) + assert.Equal(t, http.StatusNotFound, w.Code) + + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, "user not found", got.Error) +} + +func TestHandleError_UserAlreadyExists(t *testing.T) { + // Given: a context and ErrUserAlreadyExists + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // When: calling handleError with ErrUserAlreadyExists + result := handleError(c, repository.ErrUserAlreadyExists) + + // Then: should return true and response with 409 + correct error message + assert.True(t, result) + assert.Equal(t, http.StatusConflict, w.Code) + + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, "user already exists", got.Error) +} + +func TestHandleError_UsernameAlreadyExists(t *testing.T) { + // Given: a context and ErrUsernameAlreadyExists + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // When: calling handleError with ErrUsernameAlreadyExists + result := handleError(c, repository.ErrUsernameAlreadyExists) + + // Then: should return true and response with 409 + correct error message + assert.True(t, result) + assert.Equal(t, http.StatusConflict, w.Code) + + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, "username already exists", got.Error) +} + +func TestHandleError_EmailAlreadyExists(t *testing.T) { + // Given: a context and ErrEmailAlreadyExists + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // When: calling handleError with ErrEmailAlreadyExists + result := handleError(c, repository.ErrEmailAlreadyExists) + + // Then: should return true and response with 409 + correct error message + assert.True(t, result) + assert.Equal(t, http.StatusConflict, w.Code) + + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, "email already exists", got.Error) +} + +func TestHandleError_InvalidEmail(t *testing.T) { + // Given: a context and ErrInvalidEmail + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // When: calling handleError with ErrInvalidEmail + result := handleError(c, service.ErrInvalidEmail) + + // Then: should return true and response with 400 + correct error message + assert.True(t, result) + assert.Equal(t, http.StatusBadRequest, w.Code) + + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Contains(t, got.Error, "invalid email") +} + +func TestHandleError_InvalidUsername(t *testing.T) { + // Given: a context and ErrInvalidUsername + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // When: calling handleError with ErrInvalidUsername + result := handleError(c, service.ErrInvalidUsername) + + // Then: should return true and response with 400 + correct error message + assert.True(t, result) + assert.Equal(t, http.StatusBadRequest, w.Code) + + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Contains(t, got.Error, "invalid username") +} + +func TestHandleError_InvalidFullName(t *testing.T) { + // Given: a context and ErrInvalidFullName + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // When: calling handleError with ErrInvalidFullName + result := handleError(c, service.ErrInvalidFullName) + + // Then: should return true and response with 400 + correct error message + assert.True(t, result) + assert.Equal(t, http.StatusBadRequest, w.Code) + + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Contains(t, got.Error, "invalid full name") +} + +func TestHandleError_UnknownError(t *testing.T) { + // Given: a context and an unknown error + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + unknown := assert.AnError + + // When: calling handleError with unknown error + result := handleError(c, unknown) + + // Then: should return true and response with 500 + generic message + assert.True(t, result) + assert.Equal(t, http.StatusInternalServerError, w.Code) + + var got model.ErrorResponse + _ = json.Unmarshal(w.Body.Bytes(), &got) + assert.Equal(t, "internal server error", got.Error) +} diff --git a/internal/mocks/service/users_mock.go b/internal/mocks/service/users_mock.go new file mode 100644 index 0000000..040b50d --- /dev/null +++ b/internal/mocks/service/users_mock.go @@ -0,0 +1,130 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./internal/service/users.go +// +// Generated by this command: +// +// mockgen -source ./internal/service/users.go -destination ./internal/mocks/service/users_mock.go +// + +// Package mock_service is a generated GoMock package. +package mock_service + +import ( + model "cruder/internal/model" + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockUserService is a mock of UserService interface. +type MockUserService struct { + ctrl *gomock.Controller + recorder *MockUserServiceMockRecorder + isgomock struct{} +} + +// MockUserServiceMockRecorder is the mock recorder for MockUserService. +type MockUserServiceMockRecorder struct { + mock *MockUserService +} + +// NewMockUserService creates a new mock instance. +func NewMockUserService(ctrl *gomock.Controller) *MockUserService { + mock := &MockUserService{ctrl: ctrl} + mock.recorder = &MockUserServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockUserService) EXPECT() *MockUserServiceMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockUserService) Create(user *model.User) (*model.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", user) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockUserServiceMockRecorder) Create(user any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockUserService)(nil).Create), user) +} + +// Delete mocks base method. +func (m *MockUserService) Delete(id int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", id) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockUserServiceMockRecorder) Delete(id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockUserService)(nil).Delete), id) +} + +// GetAll mocks base method. +func (m *MockUserService) GetAll() ([]model.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetAll") + ret0, _ := ret[0].([]model.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetAll indicates an expected call of GetAll. +func (mr *MockUserServiceMockRecorder) GetAll() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAll", reflect.TypeOf((*MockUserService)(nil).GetAll)) +} + +// GetByID mocks base method. +func (m *MockUserService) GetByID(id int64) (*model.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByID", id) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByID indicates an expected call of GetByID. +func (mr *MockUserServiceMockRecorder) GetByID(id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByID", reflect.TypeOf((*MockUserService)(nil).GetByID), id) +} + +// GetByUsername mocks base method. +func (m *MockUserService) GetByUsername(username string) (*model.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetByUsername", username) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetByUsername indicates an expected call of GetByUsername. +func (mr *MockUserServiceMockRecorder) GetByUsername(username any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetByUsername", reflect.TypeOf((*MockUserService)(nil).GetByUsername), username) +} + +// Update mocks base method. +func (m *MockUserService) Update(user *model.User) (*model.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Update", user) + ret0, _ := ret[0].(*model.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Update indicates an expected call of Update. +func (mr *MockUserServiceMockRecorder) Update(user any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockUserService)(nil).Update), user) +} diff --git a/internal/repository/users_test.go b/internal/repository/users_test.go new file mode 100644 index 0000000..c90d30f --- /dev/null +++ b/internal/repository/users_test.go @@ -0,0 +1,260 @@ +package repository + +import ( + "cruder/internal/model" + "database/sql" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" +) + +func newMockDB(t *testing.T) (*sql.DB, sqlmock.Sqlmock) { + db, mock, _ := sqlmock.New() + defer func() { _ = db.Close() }() + return db, mock + +} + +func TestGetAll_Success(t *testing.T) { + // Given: a mock db with two users returned from query + db, mock := newMockDB(t) + repo := NewUserRepository(db) + rows := sqlmock.NewRows([]string{"id", "username", "email", "full_name"}). + AddRow(1, "john_doe", "john@doe.ee", "John Doe"). + AddRow(2, "jane_doe", "jane@doe.ee", "Jane Doe") + mock.ExpectQuery(`SELECT id, username, email, full_name FROM users`). + WillReturnRows(rows) + + // When: calling GetAll + users, err := repo.GetAll() + + // Then: two users should be returned without error + assert.NoError(t, err) + assert.Len(t, users, 2) + assert.Equal(t, "john_doe", users[0].Username) + assert.Equal(t, "jane_doe", users[1].Username) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetByUsername_Success(t *testing.T) { + // Given: a user with username "john_doe" exists + db, mock := newMockDB(t) + repo := NewUserRepository(db) + row := sqlmock.NewRows([]string{"id", "username", "email", "full_name"}). + AddRow(1, "john_doe", "john@doe.ee", "John Doe") + mock.ExpectQuery(`SELECT id, username, email, full_name FROM users WHERE username = \$1`). + WithArgs("john_doe"). + WillReturnRows(row) + + // When: calling GetByUsername with "john_doe" + user, err := repo.GetByUsername("john_doe") + + // Then: the user should be returned with matching ID and username + assert.NoError(t, err) + assert.Equal(t, int64(1), user.ID) + assert.Equal(t, "john_doe", user.Username) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetByUsername_NotFound(t *testing.T) { + // Given: no user exists with username "missing" + db, mock := newMockDB(t) + repo := NewUserRepository(db) + mock.ExpectQuery(`SELECT id, username, email, full_name FROM users WHERE username = \$1`). + WithArgs("missing"). + WillReturnError(sql.ErrNoRows) + + // When: calling GetByUsername with "missing" + user, err := repo.GetByUsername("missing") + + // Then: ErrUserNotFound should be returned and user should be nil + assert.ErrorIs(t, err, ErrUserNotFound) + assert.Nil(t, user) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetByID_Success(t *testing.T) { + // Given: a user with ID 1 exists + db, mock := newMockDB(t) + repo := NewUserRepository(db) + row := sqlmock.NewRows([]string{"id", "username", "email", "full_name"}). + AddRow(1, "john_doe", "john@doe.ee", "John Doe") + mock.ExpectQuery(`SELECT id, username, email, full_name FROM users WHERE id = \$1`). + WithArgs(int64(1)). + WillReturnRows(row) + + // When: calling GetByID with 1 + user, err := repo.GetByID(1) + + // Then: the user should be returned without error + assert.NoError(t, err) + assert.Equal(t, int64(1), user.ID) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestGetByID_NotFound(t *testing.T) { + // Given: no user exists with ID 99 + db, mock := newMockDB(t) + repo := NewUserRepository(db) + mock.ExpectQuery(`SELECT id, username, email, full_name FROM users WHERE id = \$1`). + WithArgs(int64(99)). + WillReturnError(sql.ErrNoRows) + + // When: calling GetByID with 99 + user, err := repo.GetByID(99) + + // Then: ErrUserNotFound should be returned and user should be nil + assert.ErrorIs(t, err, ErrUserNotFound) + assert.Nil(t, user) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestCreateUser_Success(t *testing.T) { + // Given: a new user to be inserted successfully + db, mock := newMockDB(t) + repo := NewUserRepository(db) + newUser := &model.User{Username: "john_doe", Email: "john@doe.ee", FullName: "John Doe"} + row := sqlmock.NewRows([]string{"id", "username", "email", "full_name"}). + AddRow(1, newUser.Username, newUser.Email, newUser.FullName) + mock.ExpectQuery(`INSERT INTO users`). + WithArgs(newUser.Username, newUser.Email, newUser.FullName). + WillReturnRows(row) + + // When: calling Create with newUser + created, err := repo.Create(newUser) + + // Then: user should be returned with ID set + assert.NoError(t, err) + assert.Equal(t, int64(1), created.ID) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestCreateUser_DuplicateUsername(t *testing.T) { + // Given: inserting a user with duplicate username + db, mock := newMockDB(t) + repo := NewUserRepository(db) + newUser := &model.User{Username: "john_doe", Email: "john@doe.ee", FullName: "John Doe"} + pqErr := &pq.Error{Code: "23505", Constraint: "users_username_key"} + mock.ExpectQuery(`INSERT INTO users`). + WithArgs(newUser.Username, newUser.Email, newUser.FullName). + WillReturnError(pqErr) + + // When: calling Create with duplicate username + created, err := repo.Create(newUser) + + // Then: ErrUsernameAlreadyExists should be returned + assert.ErrorIs(t, err, ErrUsernameAlreadyExists) + assert.Nil(t, created) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestCreateUser_DuplicateEmail(t *testing.T) { + // Given: inserting a user with duplicate email + db, mock := newMockDB(t) + repo := NewUserRepository(db) + newUser := &model.User{Username: "john_doe", Email: "john@doe.ee", FullName: "John Doe"} + pqErr := &pq.Error{Code: "23505", Constraint: "users_email_key"} + mock.ExpectQuery(`INSERT INTO users`). + WithArgs(newUser.Username, newUser.Email, newUser.FullName). + WillReturnError(pqErr) + + // When: calling Create with duplicate email + created, err := repo.Create(newUser) + + // Then: ErrEmailAlreadyExists should be returned + assert.ErrorIs(t, err, ErrEmailAlreadyExists) + assert.Nil(t, created) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestCreateUser_DuplicateOther(t *testing.T) { + // Given: inserting a user fails due to other duplicate constraint + db, mock := newMockDB(t) + repo := NewUserRepository(db) + newUser := &model.User{Username: "john_doe", Email: "john@doe.ee", FullName: "John Doe"} + pqErr := &pq.Error{Code: "23505", Constraint: "other_key"} + mock.ExpectQuery(`INSERT INTO users`). + WithArgs(newUser.Username, newUser.Email, newUser.FullName). + WillReturnError(pqErr) + + // When: calling Create with some other duplicate + created, err := repo.Create(newUser) + + // Then: ErrUserAlreadyExists should be returned + assert.ErrorIs(t, err, ErrUserAlreadyExists) + assert.Nil(t, created) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDeleteUser_Success(t *testing.T) { + // Given: a user with ID 1 exists and will be deleted + db, mock := newMockDB(t) + repo := NewUserRepository(db) + rows := sqlmock.NewRows([]string{"id"}).AddRow(1) + mock.ExpectQuery(`DELETE FROM users WHERE id = \$1 RETURNING id`). + WithArgs(int64(1)). + WillReturnRows(rows) + + // When: calling Delete with ID 1 + err := repo.Delete(1) + + // Then: no error should be returned + assert.NoError(t, err) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestDeleteUser_NotFound(t *testing.T) { + // Given: no user exists with ID 99 + db, mock := newMockDB(t) + repo := NewUserRepository(db) + mock.ExpectQuery(`DELETE FROM users WHERE id = \$1 RETURNING id`). + WithArgs(int64(99)). + WillReturnError(sql.ErrNoRows) + + // When: calling Delete with ID 99 + err := repo.Delete(99) + + // Then: ErrUserNotFound should be returned + assert.ErrorIs(t, err, ErrUserNotFound) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpdateUser_Success(t *testing.T) { + // Given: an existing user is updated successfully + db, mock := newMockDB(t) + repo := NewUserRepository(db) + user := &model.User{ID: 1, Username: "john_doe", Email: "john@doe.ee", FullName: "John Doe"} + row := sqlmock.NewRows([]string{"id", "username", "email", "full_name"}). + AddRow(user.ID, user.Username, user.Email, user.FullName) + mock.ExpectQuery(`UPDATE users SET username = \$1, email = \$2, full_name = \$3 WHERE id = \$4 RETURNING id, username, email, full_name`). + WithArgs(user.Username, user.Email, user.FullName, user.ID). + WillReturnRows(row) + + // When: calling Update with existing user + updated, err := repo.Update(user) + + // Then: updated user should be returned without error + assert.NoError(t, err) + assert.Equal(t, int64(1), updated.ID) + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestUpdateUser_NotFound(t *testing.T) { + // Given: no user exists with ID 99 + db, mock := newMockDB(t) + repo := NewUserRepository(db) + user := &model.User{ID: 99, Username: "john_doe", Email: "john@doe.ee", FullName: "John Doe"} + mock.ExpectQuery(`UPDATE users SET username = \$1, email = \$2, full_name = \$3 WHERE id = \$4 RETURNING id, username, email, full_name`). + WithArgs(user.Username, user.Email, user.FullName, user.ID). + WillReturnError(sql.ErrNoRows) + + // When: calling Update with non-existing user + updated, err := repo.Update(user) + + // Then: ErrUserNotFound should be returned + assert.ErrorIs(t, err, ErrUserNotFound) + assert.Nil(t, updated) + assert.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/test.out b/test.out deleted file mode 100644 index 5c24542..0000000 --- a/test.out +++ /dev/null @@ -1,165 +0,0 @@ -mode: set -cruder/internal/service/services.go:9.56,13.2 1 0 -cruder/internal/service/users.go:23.65,25.2 1 1 -cruder/internal/service/users.go:27.54,29.2 1 0 -cruder/internal/service/users.go:31.75,33.2 1 0 -cruder/internal/service/users.go:35.62,37.2 1 0 -cruder/internal/service/users.go:39.69,40.44 1 1 -cruder/internal/service/users.go:40.44,42.3 1 1 -cruder/internal/service/users.go:43.2,43.28 1 1 -cruder/internal/service/users.go:46.46,48.2 1 0 -cruder/internal/service/users.go:50.69,51.44 1 1 -cruder/internal/service/users.go:51.44,53.3 1 1 -cruder/internal/service/users.go:54.2,54.28 1 1 -cruder/internal/service/users.go:57.42,58.41 1 1 -cruder/internal/service/users.go:58.41,60.3 1 1 -cruder/internal/service/users.go:61.2,61.47 1 1 -cruder/internal/service/users.go:61.47,63.3 1 1 -cruder/internal/service/users.go:64.2,64.47 1 1 -cruder/internal/service/users.go:64.47,66.3 1 1 -cruder/internal/service/users.go:67.2,67.12 1 1 -cruder/internal/middleware/apikey.go:10.77,12.2 1 1 -cruder/internal/middleware/apikey.go:14.55,15.30 1 1 -cruder/internal/middleware/apikey.go:15.30,16.35 1 1 -cruder/internal/middleware/apikey.go:16.35,17.28 1 1 -cruder/internal/middleware/apikey.go:17.28,20.5 2 1 -cruder/internal/middleware/apikey.go:23.3,24.24 2 1 -cruder/internal/middleware/apikey.go:24.24,27.4 2 1 -cruder/internal/middleware/apikey.go:28.3,28.31 1 1 -cruder/internal/middleware/apikey.go:28.31,31.4 2 1 -cruder/internal/middleware/apikey.go:32.3,32.11 1 1 -cruder/internal/middleware/logger.go:14.65,16.2 1 1 -cruder/internal/middleware/logger.go:18.55,20.30 1 1 -cruder/internal/middleware/logger.go:20.30,37.27 6 1 -cruder/internal/middleware/logger.go:37.27,39.4 1 1 -cruder/internal/middleware/logger.go:41.3,41.10 1 1 -cruder/internal/middleware/logger.go:42.22,43.44 1 1 -cruder/internal/middleware/logger.go:44.22,45.43 1 1 -cruder/internal/middleware/logger.go:46.11,47.43 1 1 -cruder/internal/config/config.go:18.43,20.20 2 0 -cruder/internal/config/config.go:20.20,22.3 1 0 -cruder/internal/config/config.go:23.2,30.17 2 0 -cruder/internal/repository/connection.go:18.43,20.2 1 0 -cruder/internal/repository/connection.go:22.69,24.16 2 0 -cruder/internal/repository/connection.go:24.16,26.3 1 0 -cruder/internal/repository/connection.go:28.2,28.34 1 0 -cruder/internal/repository/connection.go:28.34,30.3 1 0 -cruder/internal/repository/connection.go:32.2,34.8 1 0 -cruder/internal/repository/repositories.go:9.44,13.2 1 0 -cruder/internal/repository/users.go:25.51,27.2 1 0 -cruder/internal/repository/users.go:29.57,31.16 2 0 -cruder/internal/repository/users.go:31.16,33.3 1 0 -cruder/internal/repository/users.go:35.2,36.18 2 0 -cruder/internal/repository/users.go:36.18,38.78 2 0 -cruder/internal/repository/users.go:38.78,40.4 1 0 -cruder/internal/repository/users.go:41.3,41.27 1 0 -cruder/internal/repository/users.go:44.2,44.35 1 0 -cruder/internal/repository/users.go:44.35,46.3 1 0 -cruder/internal/repository/users.go:48.2,48.37 1 0 -cruder/internal/repository/users.go:48.37,50.3 1 0 -cruder/internal/repository/users.go:52.2,52.19 1 0 -cruder/internal/repository/users.go:55.78,58.63 2 0 -cruder/internal/repository/users.go:58.63,59.27 1 0 -cruder/internal/repository/users.go:59.27,61.4 1 0 -cruder/internal/repository/users.go:62.3,62.18 1 0 -cruder/internal/repository/users.go:64.2,64.16 1 0 -cruder/internal/repository/users.go:67.65,70.63 2 0 -cruder/internal/repository/users.go:70.63,71.27 1 0 -cruder/internal/repository/users.go:71.27,73.4 1 0 -cruder/internal/repository/users.go:74.3,74.18 1 0 -cruder/internal/repository/users.go:76.2,76.16 1 0 -cruder/internal/repository/users.go:79.72,81.75 1 0 -cruder/internal/repository/users.go:81.75,82.64 1 0 -cruder/internal/repository/users.go:82.64,83.28 1 0 -cruder/internal/repository/users.go:84.30,85.41 1 0 -cruder/internal/repository/users.go:86.27,87.38 1 0 -cruder/internal/repository/users.go:88.12,89.37 1 0 -cruder/internal/repository/users.go:92.3,92.18 1 0 -cruder/internal/repository/users.go:94.2,94.18 1 0 -cruder/internal/repository/users.go:97.49,100.30 2 0 -cruder/internal/repository/users.go:100.30,101.27 1 0 -cruder/internal/repository/users.go:101.27,103.4 1 0 -cruder/internal/repository/users.go:103.9,105.4 1 0 -cruder/internal/repository/users.go:107.2,107.18 1 0 -cruder/internal/repository/users.go:107.18,109.3 1 0 -cruder/internal/repository/users.go:110.2,110.12 1 0 -cruder/internal/repository/users.go:113.72,115.75 1 0 -cruder/internal/repository/users.go:115.75,116.27 1 0 -cruder/internal/repository/users.go:116.27,118.4 1 0 -cruder/internal/repository/users.go:118.9,120.4 1 0 -cruder/internal/repository/users.go:122.2,122.18 1 0 -cruder/internal/controller/controllers.go:9.59,13.2 1 0 -cruder/internal/controller/users.go:18.69,20.2 1 0 -cruder/internal/controller/users.go:22.56,24.27 2 0 -cruder/internal/controller/users.go:24.27,26.3 1 0 -cruder/internal/controller/users.go:28.2,28.32 1 0 -cruder/internal/controller/users.go:31.62,35.27 3 0 -cruder/internal/controller/users.go:35.27,37.3 1 0 -cruder/internal/controller/users.go:39.2,39.31 1 0 -cruder/internal/controller/users.go:42.56,45.16 3 0 -cruder/internal/controller/users.go:45.16,48.3 2 0 -cruder/internal/controller/users.go:50.2,51.27 2 0 -cruder/internal/controller/users.go:51.27,53.3 1 0 -cruder/internal/controller/users.go:55.2,55.31 1 0 -cruder/internal/controller/users.go:58.55,61.44 2 0 -cruder/internal/controller/users.go:61.44,64.3 2 0 -cruder/internal/controller/users.go:66.2,67.27 2 0 -cruder/internal/controller/users.go:67.27,69.3 1 0 -cruder/internal/controller/users.go:71.2,71.43 1 0 -cruder/internal/controller/users.go:74.55,76.16 2 0 -cruder/internal/controller/users.go:76.16,79.3 2 0 -cruder/internal/controller/users.go:80.2,82.27 2 0 -cruder/internal/controller/users.go:82.27,84.3 1 0 -cruder/internal/controller/users.go:86.2,86.37 1 0 -cruder/internal/controller/users.go:89.55,92.16 3 0 -cruder/internal/controller/users.go:92.16,95.3 2 0 -cruder/internal/controller/users.go:96.2,96.44 1 0 -cruder/internal/controller/users.go:96.44,99.3 2 0 -cruder/internal/controller/users.go:100.2,100.19 1 0 -cruder/internal/controller/users.go:100.19,103.3 2 0 -cruder/internal/controller/users.go:105.2,106.27 2 0 -cruder/internal/controller/users.go:106.27,108.3 1 0 -cruder/internal/controller/users.go:110.2,110.38 1 0 -cruder/internal/controller/users.go:113.52,114.16 1 0 -cruder/internal/controller/users.go:114.16,116.3 1 0 -cruder/internal/controller/users.go:118.2,118.40 1 0 -cruder/internal/controller/users.go:118.40,121.3 2 0 -cruder/internal/controller/users.go:123.2,124.13 2 0 -cruder/internal/mocks/repository/users_mock.go:32.73,36.2 3 0 -cruder/internal/mocks/repository/users_mock.go:39.71,41.2 1 0 -cruder/internal/mocks/repository/users_mock.go:44.76,50.2 5 0 -cruder/internal/mocks/repository/users_mock.go:53.73,56.2 2 0 -cruder/internal/mocks/repository/users_mock.go:59.53,64.2 4 0 -cruder/internal/mocks/repository/users_mock.go:67.71,70.2 2 0 -cruder/internal/mocks/repository/users_mock.go:73.61,79.2 5 0 -cruder/internal/mocks/repository/users_mock.go:82.65,85.2 2 0 -cruder/internal/mocks/repository/users_mock.go:88.69,94.2 5 0 -cruder/internal/mocks/repository/users_mock.go:97.72,100.2 2 0 -cruder/internal/mocks/repository/users_mock.go:103.82,109.2 5 0 -cruder/internal/mocks/repository/users_mock.go:112.84,115.2 2 0 -cruder/internal/mocks/repository/users_mock.go:118.76,124.2 5 0 -cruder/internal/mocks/repository/users_mock.go:127.73,130.2 2 0 -cruder/cmd/main.go:20.13,23.42 2 0 -cruder/cmd/main.go:23.42,26.17 3 0 -cruder/cmd/main.go:26.17,29.4 2 0 -cruder/cmd/main.go:32.2,33.18 2 0 -cruder/cmd/main.go:33.18,36.3 2 0 -cruder/cmd/main.go:38.2,39.16 2 0 -cruder/cmd/main.go:39.16,42.3 2 0 -cruder/cmd/main.go:44.2,45.16 2 0 -cruder/cmd/main.go:45.16,48.3 2 0 -cruder/cmd/main.go:50.2,51.16 2 0 -cruder/cmd/main.go:51.16,54.3 2 0 -cruder/cmd/main.go:56.2,70.32 12 0 -cruder/cmd/main.go:70.32,72.3 1 0 -cruder/cmd/main.go:75.43,83.45 7 0 -cruder/cmd/main.go:83.45,85.3 1 0 -cruder/cmd/main.go:87.2,88.46 2 0 -cruder/cmd/main.go:88.46,90.3 1 0 -cruder/cmd/main.go:91.2,91.18 1 0 -cruder/internal/handler/router.go:9.85,10.46 1 0 -cruder/internal/handler/router.go:10.46,12.3 1 0 -cruder/internal/handler/router.go:13.2,14.2 2 0 -cruder/internal/handler/router.go:14.2,16.3 2 0 -cruder/internal/handler/router.go:16.3,23.4 6 0 -cruder/internal/handler/router.go:25.2,25.15 1 0