diff --git a/internal/models/migrations/migrations.go b/internal/models/migrations/migrations.go index 8840baef..8163264b 100644 --- a/internal/models/migrations/migrations.go +++ b/internal/models/migrations/migrations.go @@ -8,6 +8,7 @@ func AuthMigrationModels() []interface{} { models.Profile{}, models.Product{}, models.User{}, + models.NewsLetter{}, } // an array of db models, example: User{} } diff --git a/internal/models/newsletter.go b/internal/models/newsletter.go new file mode 100644 index 00000000..7c4d8942 --- /dev/null +++ b/internal/models/newsletter.go @@ -0,0 +1,36 @@ +package models + +import ( + "time" + + "github.com/hngprojects/hng_boilerplate_golang_web/pkg/repository/storage/postgresql" + "github.com/hngprojects/hng_boilerplate_golang_web/utility" + "gorm.io/gorm" +) + +type NewsLetter struct { + ID string `gorm:"primaryKey;type:uuid" json:"id"` + Email string `gorm:"unique;not null" json:"email" validate:"required,email"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (n *NewsLetter) BeforeCreate(tx *gorm.DB) (err error) { + + if n.ID == "" { + n.ID = utility.GenerateUUID() + } + return +} + +func (c *NewsLetter) CreateNewsLetter(db *gorm.DB) error { + + err := postgresql.CreateOneRecord(db, &c) + + if err != nil { + return err + } + + return nil +} diff --git a/pkg/controller/newsletter/post.go b/pkg/controller/newsletter/post.go new file mode 100644 index 00000000..1ee7b0ae --- /dev/null +++ b/pkg/controller/newsletter/post.go @@ -0,0 +1,59 @@ +package newsletter + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/hngprojects/hng_boilerplate_golang_web/external/request" + "github.com/hngprojects/hng_boilerplate_golang_web/internal/models" + "github.com/hngprojects/hng_boilerplate_golang_web/pkg/repository/storage" + service "github.com/hngprojects/hng_boilerplate_golang_web/services/newsletter" + "github.com/hngprojects/hng_boilerplate_golang_web/utility" +) + +type Controller struct { + Db *storage.Database + Validator *validator.Validate + Logger *utility.Logger + ExtReq request.ExternalRequest +} + +func (base *Controller) SubscribeNewsLetter(c *gin.Context) { + var ( + req = models.NewsLetter{} + ) + + err := c.ShouldBind(&req) + if err != nil { + rd := utility.BuildErrorResponse(http.StatusBadRequest, "error", "Failed to parse request body", err, nil) + c.JSON(http.StatusBadRequest, rd) + return + } + + err = base.Validator.Struct(&req) + if err != nil { + rd := utility.BuildErrorResponse(http.StatusUnprocessableEntity, "error", "Validation failed", + utility.ValidationResponse(err, base.Validator), nil) + c.JSON(http.StatusUnprocessableEntity, rd) + return + } + + err = service.NewsLetterSubscribe(&req, base.Db.Postgresql) + if err != nil { + if err == service.ErrEmailAlreadySubscribed { + rd := utility.BuildErrorResponse(http.StatusConflict, "error", "Email already subscribed", nil, nil) + c.JSON(http.StatusConflict, rd) + } else { + rd := utility.BuildErrorResponse(http.StatusInternalServerError, "error", "Failed to subscribe", err, nil) + c.JSON(http.StatusInternalServerError, rd) + } + return + } + + base.Logger.Info("subscribed successfully") + + rd := utility.BuildSuccessResponse(http.StatusCreated, "subscribed successfully", nil) + c.JSON(http.StatusCreated, rd) + +} diff --git a/pkg/controller/product/search.go b/pkg/controller/product/search.go new file mode 100644 index 00000000..e6ae0918 --- /dev/null +++ b/pkg/controller/product/search.go @@ -0,0 +1 @@ +package product diff --git a/pkg/repository/storage/postgresql/delete.go b/pkg/repository/storage/postgresql/delete.go index 416ce5ed..bcd0bc48 100644 --- a/pkg/repository/storage/postgresql/delete.go +++ b/pkg/repository/storage/postgresql/delete.go @@ -6,3 +6,8 @@ func DeleteRecordFromDb(db *gorm.DB, record interface{}) error { tx := db.Delete(record) return tx.Error } + +func HardDeleteRecordFromDb(db *gorm.DB, record interface{}) error { + tx := db.Unscoped().Delete(record) + return tx.Error +} diff --git a/pkg/router/newsletter.go b/pkg/router/newsletter.go new file mode 100644 index 00000000..ce0b6612 --- /dev/null +++ b/pkg/router/newsletter.go @@ -0,0 +1,23 @@ +package router + +import ( + "fmt" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/hngprojects/hng_boilerplate_golang_web/external/request" + "github.com/hngprojects/hng_boilerplate_golang_web/pkg/controller/newsletter" + "github.com/hngprojects/hng_boilerplate_golang_web/pkg/repository/storage" + "github.com/hngprojects/hng_boilerplate_golang_web/utility" +) + +func Newsletter(r *gin.Engine, ApiVersion string, validator *validator.Validate, db *storage.Database, logger *utility.Logger) *gin.Engine { + extReq := request.ExternalRequest{Logger: logger, Test: false} + newsLetter := newsletter.Controller{Db: db, Validator: validator, Logger: logger, ExtReq: extReq} + + newsLetterUrl := r.Group(fmt.Sprintf("%v", ApiVersion)) + { + newsLetterUrl.POST("/newsletter", newsLetter.SubscribeNewsLetter) + } + return r +} diff --git a/pkg/router/router.go b/pkg/router/router.go index 4db191d8..be81dcaf 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -37,6 +37,7 @@ func Setup(logger *utility.Logger, validator *validator.Validate, db *storage.Da Seed(r, ApiVersion, validator, db, logger) User(r, ApiVersion, validator, db, logger) Organisation(r, ApiVersion, validator, db, logger) + Newsletter(r, ApiVersion, validator, db, logger) r.GET("/", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ diff --git a/services/newsletter/newsletter.go b/services/newsletter/newsletter.go new file mode 100644 index 00000000..698c8a41 --- /dev/null +++ b/services/newsletter/newsletter.go @@ -0,0 +1,24 @@ +package service + +import ( + "errors" + + "github.com/hngprojects/hng_boilerplate_golang_web/internal/models" + "github.com/hngprojects/hng_boilerplate_golang_web/pkg/repository/storage/postgresql" + "gorm.io/gorm" +) + +var ErrEmailAlreadySubscribed = errors.New("email already subscribed") + +func NewsLetterSubscribe(newsletter *models.NewsLetter, db *gorm.DB) error { + + if postgresql.CheckExists(db, newsletter, "email = ?", newsletter.Email) { + return ErrEmailAlreadySubscribed + } + + if err := newsletter.CreateNewsLetter(db); err != nil { + return err + } + + return nil +} diff --git a/tests/newsletter_test.go b/tests/newsletter_test.go new file mode 100644 index 00000000..018925f4 --- /dev/null +++ b/tests/newsletter_test.go @@ -0,0 +1,149 @@ +package tests + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "github.com/hngprojects/hng_boilerplate_golang_web/internal/models" + "github.com/hngprojects/hng_boilerplate_golang_web/pkg/controller/newsletter" + "github.com/hngprojects/hng_boilerplate_golang_web/pkg/repository/storage" +) + +func setupNewsLetterTestRouter() (*gin.Engine, *newsletter.Controller) { + gin.SetMode(gin.TestMode) + + logger := Setup() + db := storage.Connection() + validator := validator.New() + + newsController := &newsletter.Controller{ + Db: db, + Validator: validator, + Logger: logger, + } + + r := gin.Default() + SetupNewsLetterRoutes(r, newsController) + return r, newsController +} + +func SetupNewsLetterRoutes(r *gin.Engine, newsController *newsletter.Controller) { + r.POST("/api/v1/newsletter", newsController.SubscribeNewsLetter) +} + +func TestE2ENewsletterSubscription(t *testing.T) { + router, _ := setupNewsLetterTestRouter() + + // Test POST /newsletter + body := models.NewsLetter{ + Email: "e2e_test@example.com", + } + jsonBody, err := json.Marshal(body) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + + req, err := http.NewRequest(http.MethodPost, "/api/v1/newsletter", bytes.NewBuffer(jsonBody)) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + AssertStatusCode(t, resp.Code, http.StatusCreated) + + response := ParseResponse(resp) + AssertResponseMessage(t, response["message"].(string), "subscribed successfully") +} + +func TestPostNewsletter_ValidateEmail(t *testing.T) { + router, _ := setupNewsLetterTestRouter() + + body := models.NewsLetter{ + Email: "invalid-email", + } + jsonBody, _ := json.Marshal(body) + + req, _ := http.NewRequest(http.MethodPost, "/api/v1/newsletter", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + response := ParseResponse(resp) + AssertStatusCode(t, resp.Code, http.StatusUnprocessableEntity) + AssertResponseMessage(t, response["message"].(string), "Validation failed") +} + +func TestPostNewsletter_CheckDuplicateEmail(t *testing.T) { + router, newsController := setupNewsLetterTestRouter() + + db := newsController.Db.Postgresql + db.Create(&models.NewsLetter{Email: "test@example.com"}) + + body := models.NewsLetter{ + Email: "test@example.com", + } + jsonBody, _ := json.Marshal(body) + + req, _ := http.NewRequest(http.MethodPost, "/api/v1/newsletter", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + response := ParseResponse(resp) + AssertStatusCode(t, resp.Code, http.StatusConflict) + AssertResponseMessage(t, response["message"].(string), "Email already subscribed") +} + +func TestPostNewsletter_SaveData(t *testing.T) { + router, newsController := setupNewsLetterTestRouter() + + body := models.NewsLetter{ + Email: "test2@example.com", + } + jsonBody, _ := json.Marshal(body) + + req, _ := http.NewRequest(http.MethodPost, "/api/v1/newsletter", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + response := ParseResponse(resp) + AssertStatusCode(t, resp.Code, http.StatusCreated) + AssertResponseMessage(t, response["message"].(string), "subscribed successfully") + + var newsletter models.NewsLetter + newsController.Db.Postgresql.First(&newsletter, "email = ?", "test2@example.com") + if newsletter.Email != "test2@example.com" { + t.Errorf("data not saved correctly to the database: expected email %s, got %s", "test2@example.com", newsletter.Email) + } +} + +func TestPostNewsletter_ResponseAndStatusCode(t *testing.T) { + router, _ := setupNewsLetterTestRouter() + + body := models.NewsLetter{ + Email: "test3@example.com", + } + jsonBody, _ := json.Marshal(body) + + req, _ := http.NewRequest(http.MethodPost, "/api/v1/newsletter", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + response := ParseResponse(resp) + AssertStatusCode(t, resp.Code, http.StatusCreated) + AssertResponseMessage(t, response["message"].(string), "subscribed successfully") +} diff --git a/utility/response.go b/utility/response.go index f9c5adee..9e24e617 100644 --- a/utility/response.go +++ b/utility/response.go @@ -13,7 +13,7 @@ import ( type Response struct { Status string `json:"status,omitempty"` - Code int `json:"code,omitempty"` + StatusCode int `json:"status_code,omitempty"` Name string `json:"name,omitempty"` //name of the error Message string `json:"message,omitempty"` Error interface{} `json:"error,omitempty"` //for errors that occur even if request is successful @@ -47,7 +47,7 @@ func ResponseMessage(code int, status string, name string, message string, err i } res := Response{ - Code: code, + StatusCode: code, Name: name, Status: status, Message: message,