diff --git a/pkg/models/review.go b/pkg/models/review.go new file mode 100644 index 0000000..2a586a5 --- /dev/null +++ b/pkg/models/review.go @@ -0,0 +1,17 @@ +package models + +import "time" + +type ReviewModel struct { + Model + Title string `db:"title" json:"title"` + EventID string `db:"event_id" json:"event_id"` + AuthorID string `db:"author_id" json:"author_id"` + Body string `db:"body" json:"body"` +} + +// BeforeUpdated overrides model lifecycle hook, updating the updated_at time. +func (m *ReviewModel) BeforeUpdated() error { + m.UpdatedAt = time.Now() + return nil +} diff --git a/pkg/repository/review.go b/pkg/repository/review.go new file mode 100644 index 0000000..95f74bd --- /dev/null +++ b/pkg/repository/review.go @@ -0,0 +1,121 @@ +package repository + +import ( + "database/sql" + "errors" + "fmt" + "net" + "reflect" + + "github.com/BEOpenSourceCollabs/EventManagementCore/pkg/models" +) + +type ReviewRepository interface { + CreateReview(review *models.ReviewModel) error + GetReviewByID(id string) (*models.ReviewModel, error) + DeleteReview(id string) error + UpdateReview(review *models.ReviewModel) error +} + +type sqlReviewRepository struct { + database *sql.DB +} + +func NewSQLReviewRepository(database *sql.DB) ReviewRepository { + return &sqlReviewRepository{database: database} +} + +func (r *sqlReviewRepository) handleError(err error) error { + if errors.Is(err, sql.ErrNoRows) { + return ErrReviewNotFound + } + if reflect.TypeOf(err) == reflect.TypeOf(&net.OpError{}) { + return ErrRepoConnErr + } + return err +} + +// CreateReview inserts a new review into the database +func (r *sqlReviewRepository) CreateReview(review *models.ReviewModel) error { + review.BeforeCreate() + query := `INSERT INTO public.reviews (title, event_id, author_id, body) + VALUES ($1, $2, $3, $4) RETURNING id` + err := r.database.QueryRow(query, + review.Title, + review.EventID, + review.AuthorID, + review.Body, + ).Scan(&review.ID) + if err != nil { + return fmt.Errorf("failed to create review: %w", err) + } + review.AfterCreate() + return nil +} + +// GetReviewByID retrieves a review from the database by its unique ID +func (r *sqlReviewRepository) GetReviewByID(id string) (*models.ReviewModel, error) { + query := `SELECT id, title, event_id, author_id, body, created_at, updated_at FROM public.reviews WHERE id = $1` + review := &models.ReviewModel{} + err := r.database.QueryRow(query, id).Scan( + &review.ID, + &review.Title, + &review.EventID, + &review.AuthorID, + &review.Body, + &review.CreatedAt, + &review.UpdatedAt, + ) + if err != nil { + return nil, r.handleError(err) + } + return review, nil +} + +// UpdateReview updates a review in the database +func (r *sqlReviewRepository) UpdateReview(review *models.ReviewModel) error { + review.BeforeUpdate() + query := `UPDATE public.reviews SET title = $1, event_id = $2, author_id = $3, body = $4 WHERE id = $5` + if review.CreatedAt.Unix() == 0 { + return fmt.Errorf("unable to update a review that was not loaded from the database") + } + rs, err := r.database.Exec( + query, + review.Title, + review.EventID, + review.AuthorID, + review.Body, + review.ID, + ) + if err != nil { + return r.handleError(err) + } + if affected, err := rs.RowsAffected(); affected < 1 { + if err != nil { + return err + } + return ErrReviewNotFound + } + review.AfterUpdate() + return nil +} + +// DeleteReview deletes a review from the database +func (r *sqlReviewRepository) DeleteReview(id string) error { + query := `DELETE FROM public.reviews WHERE id = $1` + rs, err := r.database.Exec(query, id) + if err != nil { + return r.handleError(err) + } + if affected, err := rs.RowsAffected(); affected < 1 { + if err != nil { + return err + } + return ErrReviewNotFound + } + return nil +} + +var ( + ErrReviewNotFound = errors.New("review not found") // ErrReviewNotFound is returned when a review is not found in the database. +) diff --git a/pkg/repository/review_test.go b/pkg/repository/review_test.go new file mode 100644 index 0000000..727b554 --- /dev/null +++ b/pkg/repository/review_test.go @@ -0,0 +1,167 @@ +package repository_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/BEOpenSourceCollabs/EventManagementCore/pkg/models" + "github.com/BEOpenSourceCollabs/EventManagementCore/pkg/repository" + "github.com/BEOpenSourceCollabs/EventManagementCore/pkg/test" + "github.com/BEOpenSourceCollabs/EventManagementCore/pkg/types" +) + +var ( + reviews = []models.ReviewModel{ + { + Title: "Great Event", + Body: "This was an amazing event!", + }, + { + Title: "Good Experience", + Body: "I enjoyed this event, but there's room for improvement.", + }, + { + Title: "Disappointing", + Body: "The event didn't meet my expectations.", + }, + } +) + +func TestReviewRepository_KitchenSink(t *testing.T) { + container, db, err := test.NewTestDatabaseWithContainer(test.TestDatabaseConfiguration{ + RootRelativePath: "../../", + }) + if err != nil { + t.Fatal(err) + } + defer container.Terminate(context.Background()) + + reviewRepo := repository.NewSQLReviewRepository(db) + userRepo := repository.NewSQLUserRepository(db) + eventRepo := repository.NewSQLEventRepository(db) + + author := models.UserModel{ + Username: "author0", + FirstName: sql.NullString{ + String: "author", + Valid: true, + }, + LastName: sql.NullString{ + String: "0", + Valid: true, + }, + Email: "author0@example.co.uk", + Verified: true, + Role: types.UserRole, + } + + organizer := models.UserModel{ + Username: "org0", + FirstName: sql.NullString{ + String: "org", + Valid: true, + }, + LastName: sql.NullString{ + String: "0", + Valid: true, + }, + Email: "org0@example.co.uk", + Verified: true, + Role: types.OrganizerRole, + } + + event := models.EventModel{ + Name: "Coding Meetup", + Type: types.BothEventType, + Description: "A test networking event", + StartDate: time.Now().Add(time.Hour + 48), + EndDate: time.Now().Add(time.Hour + 49), + IsPaid: false, + CountryISO: "GB", + City: "London", + } + + t.Run("Create review author", func(t *testing.T) { + if err := userRepo.CreateUser(&author); err != nil { + t.Errorf("expected no error when creating review author but got %v", err) + } + }) + + t.Run("Create event organizer", func(t *testing.T) { + if err := userRepo.CreateUser(&organizer); err != nil { + t.Errorf("expected no error when creating organizer but got %v", err) + } + }) + + t.Run("Create event", func(t *testing.T) { + event.Organizer = organizer.ID + if err := eventRepo.CreateEvent(&event); err != nil { + t.Errorf("expected no error when creating event but got %v", err) + } + }) + + t.Run("Create reviews", func(t *testing.T) { + for i := range reviews { + reviews[i].AuthorID = author.ID // FKC requires a valid author id + reviews[i].EventID = event.ID // FKC requires a valid event id + + if err := reviewRepo.CreateReview(&reviews[i]); err != nil { + t.Errorf("expected no error when creating review but got %v", err) + } + if reviews[i].ID == "" { + t.Error("expected review ID to be set after creation") + } + } + }) + + t.Run("Update review without loading first", func(t *testing.T) { + if err := reviewRepo.UpdateReview(&models.ReviewModel{Model: models.Model{ID: reviews[0].ID}, Title: "Updated"}); err == nil { + t.Error("expected error while attempting to update a review without fully loading the review first") + } + }) + + t.Run("Update review", func(t *testing.T) { + reviews[0].Title = "Updated Title" + reviews[0].Body = "Updated body content" + if err := reviewRepo.UpdateReview(&reviews[0]); err != nil { + t.Errorf("expected no error when updating review but got %v", err) + } + loaded, err := reviewRepo.GetReviewByID(reviews[0].ID) + if err != nil { + t.Errorf("expected no error when getting review by id but got %v", err) + } + if loaded.Title != "Updated Title" { + t.Errorf("expected loaded review's title to be updated to 'Updated Title' but was '%s'", loaded.Title) + } + if loaded.Body != "Updated body content" { + t.Errorf("expected loaded review's body to be updated but was '%s'", loaded.Body) + } + }) + + t.Run("Get review by ID", func(t *testing.T) { + loaded, err := reviewRepo.GetReviewByID(reviews[1].ID) + if err != nil { + t.Errorf("expected no error when getting review by ID but got %v", err) + } + if loaded.ID != reviews[1].ID { + t.Errorf("expected loaded review's id to match '%s' but was '%s'", reviews[1].ID, loaded.ID) + } + }) + + t.Run("Delete reviews", func(t *testing.T) { + for _, review := range reviews { + if err := reviewRepo.DeleteReview(review.ID); err != nil { + t.Errorf("expected no error when deleting review but got %v", err) + } + deletedReview, err := reviewRepo.GetReviewByID(review.ID) + if deletedReview != nil { + t.Errorf("expected deleted review to be nil but was %v", deletedReview) + } + if err == nil { + t.Errorf("expected error when attempting to get review by id after deletion") + } + } + }) +}