diff --git a/.gitignore b/.gitignore index f5546f2..a98a44d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,9 @@ go.work # Miscellaneous todo.txt -# Logger +# Logs logs/*.log +internal/handlers/logs/*.log + +# Coverage +internal/handlers/coverage diff --git a/go.mod b/go.mod index fd2671c..de9a601 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,17 @@ module github.com/milwad-dev/do-it go 1.22.0 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/go-chi/chi/v5 v5.2.0 github.com/go-playground/validator/v10 v10.25.0 github.com/go-sql-driver/mysql v1.8.1 github.com/joho/godotenv v1.5.1 + github.com/redis/go-redis/v9 v9.8.0 + github.com/stretchr/testify v1.9.0 github.com/swaggo/http-swagger v1.3.4 github.com/swaggo/swag v1.16.4 + go.uber.org/zap v1.27.0 golang.org/x/crypto v0.32.0 ) @@ -17,6 +21,7 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect @@ -28,10 +33,9 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect - github.com/redis/go-redis/v9 v9.8.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/swaggo/files v1.0.1 // indirect go.uber.org/multierr v1.10.0 // indirect - go.uber.org/zap v1.27.0 // indirect golang.org/x/net v0.34.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect diff --git a/go.sum b/go.sum index 1f67318..e81c62f 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -36,6 +42,7 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -59,6 +66,8 @@ github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4 github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A= github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= diff --git a/internal/handlers/handler_test.go b/internal/handlers/handler_test.go new file mode 100644 index 0000000..53c0168 --- /dev/null +++ b/internal/handlers/handler_test.go @@ -0,0 +1,27 @@ +package handlers + +import ( + "github.com/DATA-DOG/go-sqlmock" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + "testing" +) + +func TestNewDBHandler(t *testing.T) { + // Create a mock sql.DB + db, _, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + // Create a mock redis client (no need to mock connection for this simple test) + redisClient := redis.NewClient(&redis.Options{ + Addr: "localhost:6379", + }) + + // Call constructor + handler := NewDBHandler(db, redisClient) + + // Assertions + require.NotNil(t, handler) + require.Equal(t, db, handler.DB) +} diff --git a/internal/handlers/label_handler.go b/internal/handlers/label_handler.go index 2ea2045..c9fea94 100644 --- a/internal/handlers/label_handler.go +++ b/internal/handlers/label_handler.go @@ -27,11 +27,11 @@ func (db *DBHandler) GetLatestLabels(w http.ResponseWriter, r *http.Request) { query := ` SELECT l.id, l.title, l.color, l.created_at, l.updated_at, l.user_id, - u.id, u.name, COALESCE(u.email, ''), COALESCE(u.phone, ''), u.created_at - FROM labels l - JOIN users u ON l.user_id = u.id - ORDER BY l.created_at DESC - WHERE user_id = ?` + u.id, u.name, COALESCE(u.email, ''), COALESCE(u.phone, ''), u.created_at + FROM labels AS l + JOIN users AS u ON l.user_id = u.id + WHERE l.user_id = ? + ORDER BY l.created_at DESC` rows, err := db.Query(query, userId) if err != nil { data["message"] = err.Error() diff --git a/internal/handlers/label_handler_test.go b/internal/handlers/label_handler_test.go index 539569d..67d6c86 100644 --- a/internal/handlers/label_handler_test.go +++ b/internal/handlers/label_handler_test.go @@ -1,58 +1,176 @@ package handlers import ( - "bytes" + "context" + "github.com/dgrijalva/jwt-go" + "github.com/go-chi/chi/v5" + "github.com/milwad-dev/do-it/internal/logger" "net/http" "net/http/httptest" + "regexp" + "strings" "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/stretchr/testify/require" ) -// Mock dbHandler struct to satisfy the dbHandler interface -type dbHandler struct{} +func TestGetLatestLabels_OK(t *testing.T) { + // Mock DB + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + // Initialize logger in non-production mode for testing (prints JSON to stdout + file) + logger.InitLogger(false) + + // Expectations + mock.ExpectQuery(regexp.QuoteMeta(` + SELECT l.id, l.title, l.color, l.created_at, l.updated_at, l.user_id, + u.id, u.name, COALESCE(u.email, ''), COALESCE(u.phone, ''), u.created_at + FROM labels AS l + JOIN users AS u ON l.user_id = u.id + WHERE l.user_id = ? + ORDER BY l.created_at DESC +`)). + WithArgs(float64(1)). + WillReturnRows(sqlmock.NewRows([]string{ + "id", "title", "color", "created_at", "updated_at", "user_id", + "user_id", "name", "email", "phone", "user_created_at", + }).AddRow( + 1, "Label Title", "#FF0000", "2025-01-01 10:00:00", "2025-01-02 10:00:00", 42, + 42, "User Name", "user@example.com", "1234567890", "2024-12-31 09:00:00", + )) + + // Handler + h := &DBHandler{DB: db} + + // Request setup + req := httptest.NewRequest(http.MethodGet, "/labels", nil) + + // Add Chi route context with id param + req = callContext(req) + + // Recorder & handler call + rr := httptest.NewRecorder() + h.GetLatestLabels(rr, req) + + // Assert + require.Equal(t, http.StatusOK, rr.Code) + require.JSONEq(t, `{ + "data": [ + { + "id": 1, + "title": "Label Title", + "color": "#FF0000", + "created_at": "2025-01-01 10:00:00", + "updated_at": "2025-01-02 10:00:00", + "user": { + "id": 42, + "name": "User Name", + "email": "user@example.com", + "phone": "1234567890", + "emailVerified_at": "0001-01-01T00:00:00Z", + "phoneVerified_at": "0001-01-01T00:00:00Z", + "created_at": "2024-12-31 09:00:00", + "updated_at": "" + } + } + ], + "status": "Success" +}`, rr.Body.String()) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestStoreLabel_OK(t *testing.T) { + // Setup mock DB + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + // Initialize logger if needed + logger.InitLogger(false) -func TestStoreLabel(t *testing.T) { - // Create a mock dbHandler with any necessary dependencies - mockDB := &dbHandler{} // You may need to create a mock dbHandler + // Prepare expected query and arguments + mock.ExpectExec(regexp.QuoteMeta("INSERT INTO labels (title, color, user_id) VALUES (?, ?, ?)")). + WithArgs("Test Label", "#FF00FF", float64(1)). + WillReturnResult(sqlmock.NewResult(1, 1)) - // Create a sample label JSON body - labelJSON := []byte(`{"title": "Test Label", "color": "#FF0000"}`) + // Setup DBHandler with mock DB + h := &DBHandler{DB: db} - // Create a mock HTTP request with the sample label data - req, err := http.NewRequest("POST", "/labels", bytes.NewBuffer(labelJSON)) - if err != nil { - t.Fatal(err) - } + // Create JSON request body + body := `{"title":"Test Label","color":"#FF00FF"}` - // Create a ResponseRecorder to record the response + req := httptest.NewRequest(http.MethodPost, "/labels", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + // Add userID context (matching your GetUserIdFromContext) + req = callContext(req) + + // Record response rr := httptest.NewRecorder() - // Call the storeLabel handler function with the mock dbHandler and mock HTTP request - handler := http.HandlerFunc(mockDB.storeLabel) - handler.ServeHTTP(rr, req) + // Call handler + h.StoreLabel(rr, req) + + // Check response code + require.Equal(t, http.StatusOK, rr.Code) - // Check the status code returned by the handler - if status := rr.Code; status != http.StatusOK { - t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) - } + // Check response body JSON + expected := `{"data":{"message":"The label store successfully."}, "status":"Success"}` + require.JSONEq(t, expected, rr.Body.String()) - // Check if the response body contains the expected message - expectedResponse := `"message":"The label stored successfully."` - if rr.Body.String() != expectedResponse { - t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expectedResponse) - } + // Ensure all expectations were met + require.NoError(t, mock.ExpectationsWereMet()) } -// Mock implementation of storeLabel function to satisfy the dbHandler interface -func (db *dbHandler) storeLabel(w http.ResponseWriter, r *http.Request) { - data := make(map[string]string) - data["message"] = "The label stored successfully." - jsonResponse(w, data, http.StatusOK) +func TestDeleteLabel_OK(t *testing.T) { + // Mock DB + db, mock, err := sqlmock.New() + require.NoError(t, err) + defer db.Close() + + // Expectations + mock.ExpectQuery(`SELECT count\(\*\) FROM labels WHERE id = \? AND user_id = \?`). + WithArgs("42", float64(1)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + mock.ExpectExec(`DELETE FROM labels WHERE id = \? AND user_id = \?`). + WithArgs("42", float64(1)). + WillReturnResult(sqlmock.NewResult(0, 1)) + + // Handler + h := &DBHandler{DB: db} + + // Request setup + req := httptest.NewRequest(http.MethodDelete, "/labels/42", nil) + + // Add Chi route context with id param + req = callContext(req) + + // Recorder & handler call + rr := httptest.NewRecorder() + h.DeleteLabel(rr, req) + + // Assert + require.Equal(t, http.StatusOK, rr.Code) + require.JSONEq(t, `{"data": { +"message":"The label deleted successfully." +}, +"status": "Success" +}`, rr.Body.String()) + require.NoError(t, mock.ExpectationsWereMet()) } -// Mock implementation of jsonResponse function for testing purposes -func jsonResponse(w http.ResponseWriter, data map[string]string, statusCode int) { - w.WriteHeader(statusCode) - for key, value := range data { - w.Write([]byte(`"` + key + `":"` + value + `"`)) - } +func callContext(req *http.Request) *http.Request { + rctx := chi.NewRouteContext() + rctx.URLParams.Add("id", "42") + req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) + + // Add JWT claims to context + req = req.WithContext(context.WithValue(req.Context(), "userID", jwt.MapClaims{ + "user_id": float64(1), + })) + return req } diff --git a/internal/handlers/task_handler_test.go b/internal/handlers/task_handler_test.go new file mode 100644 index 0000000..bdb6ef8 --- /dev/null +++ b/internal/handlers/task_handler_test.go @@ -0,0 +1,131 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "github.com/milwad-dev/do-it/internal/models" + "net/http" + "net/http/httptest" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestGetLatestTasks_Success(t *testing.T) { + // Setup DB mock + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create mock db: %v", err) + } + defer db.Close() + + handler := &DBHandler{DB: db} + + // Mock rows + rows := sqlmock.NewRows([]string{ + "task_id", "task_title", "task_description", "task_status", "user_id", "label_id", "task_completed_at", "task_created_at", + "user_id", "user_name", "user_email", "user_phone", "user_created_at", + "label_id", "label_title", "label_color", "label_created_at", + }).AddRow( + 1, "Test Task", "A task", "open", 10, 100, "", "2023-01-01", + 10, "Milwad", "milwad@example.com", "123456", "2022-12-01", + 100, "Work", "#ffcc00", "2022-11-01", + ) + + mock.ExpectQuery(regexp.QuoteMeta(` + SELECT + tasks.id AS task_id, + tasks.title AS task_title, + tasks.description AS task_description, + tasks.status AS task_status, + tasks.user_id, + tasks.label_id, + COALESCE(tasks.completed_at, '') AS task_completed_at, + tasks.created_at AS task_created_at, + + users.id AS user_id, + users.name AS user_name, + COALESCE(users.email, '') AS user_email, + COALESCE(users.phone, '') AS user_phone, + users.created_at AS user_created_at, + + labels.id AS label_id, + labels.title AS label_title, + labels.color AS label_color, + labels.created_at AS label_created_at + FROM tasks + JOIN users ON tasks.user_id = users.id + JOIN labels ON tasks.label_id = labels.id + WHERE tasks.user_id = ? +`)).WithArgs(float64(1)).WillReturnRows(rows) + + // Create request + req := httptest.NewRequest(http.MethodGet, "/tasks", nil) + req.Header.Set("Content-Type", "application/json") + + // Mock user id from context + req = callContext(req) + + // Record response + rr := httptest.NewRecorder() + + // Call handler + handler.GetLatestTasks(rr, req) + + // Check status + if rr.Code != http.StatusOK { + t.Errorf("Error: %s", rr.Body) + t.Errorf("expected 200, got %d", rr.Code) + } +} + +func TestStoreTask_Success(t *testing.T) { + // Setup sqlmock + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to open sqlmock database: %v", err) + } + defer db.Close() + + handler := &DBHandler{DB: db} + + // Prepare input task as JSON + task := models.Task{ + Title: "Test Task", + Description: "Test Description", + Status: "pending", + LabelId: 1, + } + taskJSON, _ := json.Marshal(task) + + // Create request + req := httptest.NewRequest(http.MethodPost, "/tasks", bytes.NewBuffer(taskJSON)) + req.Header.Set("Content-Type", "application/json") + + // Mock user id from context + req = callContext(req) + + // Record response + w := httptest.NewRecorder() + + // Mock the label exists query returning count = 1 + mock.ExpectQuery("SELECT count\\(\\*\\) FROM labels WHERE id = \\? AND user_id = \\?"). + WithArgs(task.LabelId, float64(1)). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + + // Mock the INSERT query + mock.ExpectExec("INSERT INTO tasks"). + WithArgs(task.Title, task.Description, task.Status, task.LabelId, float64(1)). + WillReturnResult(sqlmock.NewResult(1, 1)) + + // Call the function + handler.StoreTask(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200 OK but got %d", resp.StatusCode) + } +} diff --git a/internal/handlers/user_handler_test.go b/internal/handlers/user_handler_test.go new file mode 100644 index 0000000..bdb7fc2 --- /dev/null +++ b/internal/handlers/user_handler_test.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "encoding/json" + "github.com/DATA-DOG/go-sqlmock" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetLatestUsers_Success(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("mocking error: %v", err) + } + defer db.Close() + + rows := sqlmock.NewRows([]string{"id", "name", "email", "phone", "created_at"}). + AddRow(1, "Alice", "alice@example.com", "123456", "2024-01-01T00:00:00Z"). + AddRow(2, "Bob", "bob@example.com", "654321", "2024-01-02T00:00:00Z") + + mock.ExpectQuery("SELECT id, name, COALESCE").WillReturnRows(rows) + + handler := &DBHandler{DB: db} + + req := httptest.NewRequest(http.MethodGet, "/users", nil) + w := httptest.NewRecorder() + + handler.GetLatestUsers(w, req) + + resp := w.Result() + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } + + var response map[string]interface{} + json.NewDecoder(resp.Body).Decode(&response) + + if _, ok := response["data"]; !ok { + t.Error("expected data in response") + } +}