Files
noteplace-server/test/api_ratings_test.go
T
haopengzhan f1909da1ad
Go CI / build (push) Failing after 2m42s
Initial commit
feat: create basic server to manage google oauth, account, sessions, places, attributes and ratings.
2025-09-19 02:43:04 -07:00

339 lines
12 KiB
Go

package test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"git.pengzhan.dev/noteplace-server/internal/api"
"git.pengzhan.dev/noteplace-server/internal/auth"
"git.pengzhan.dev/noteplace-server/internal/models"
"git.pengzhan.dev/noteplace-server/internal/store"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
)
// Dummy users for testing
var (
adminUser = models.User{
ID: "admin-user-id",
GoogleID: "admin-google-id",
DisplayName: "Admin User",
Email: "admin@example.com",
Role: models.ADMIN,
}
regularUser1 = models.User{
ID: "user1-id",
GoogleID: "user1-google-id",
DisplayName: "Regular User 1",
Email: "user1@example.com",
Role: models.USER,
}
regularUser2 = models.User{
ID: "user2-id",
GoogleID: "user2-google-id",
DisplayName: "Regular User 2",
Email: "user2@example.com",
Role: models.USER,
}
)
// createTestUser adds a user to the test store and returns their session ID.
func createTestUser(t *testing.T, s *store.Store, user models.User) string {
err := s.CreateUser(user)
if err != nil {
t.Fatalf("Failed to create test user %s: %v", user.ID, err)
}
session := models.Session{UserID: user.ID, ID: fmt.Sprintf("session-%s", user.ID)}
err = s.CreateSession(session)
if err != nil {
t.Fatalf("Failed to create session for test user %s: %v", user.ID, err)
}
return session.ID
}
// performRequest performs an HTTP request against the test router.
func performRequest(router *gin.Engine, method, url string, body interface{}, sessionID string) *httptest.ResponseRecorder {
var reqBody io.Reader
if body != nil {
jsonBody, _ := json.Marshal(body)
reqBody = bytes.NewBuffer(jsonBody)
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(method, url, reqBody)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if sessionID != "" {
req.Header.Set("Authorization", sessionID)
}
router.ServeHTTP(w, req)
return w
}
func containsRating(ratings []models.Rating, ratingID string) bool {
for _, r := range ratings {
if r.ID == ratingID {
return true
}
}
return false
}
// setupTestRouter initializes a new Gin router with all API routes for testing.
func setupTestRouter(t *testing.T) (*gin.Engine, *store.Store) {
// Load .env file for test environment variables
if err := godotenv.Load("../.env"); err != nil {
t.Logf("No .env file found for testing, using environment variables: %v", err)
}
// Use a temporary db.json for testing
tempDir, err := os.MkdirTemp("", "noteplace_test_db_")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
tempDBPath := fmt.Sprintf("%s/test_db.json", tempDir)
// Clean up the temporary directory and file after tests
t.Cleanup(func() {
_ = os.RemoveAll(tempDir)
})
s, err := store.New(tempDBPath)
if err != nil {
t.Fatalf("Failed to initialize store for testing: %v", err)
}
authenticator, err := auth.NewAuthenticator()
if err != nil {
t.Fatalf("Failed to initialize authenticator for testing: %v", err)
}
gin.SetMode(gin.TestMode)
router := gin.Default()
// Health check endpoint
router.GET("/api/health", func(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"status": "ok"})
})
// Register API handlers
placesHandler := &api.PlacesHandler{Store: s}
router.GET("/api/places", placesHandler.HandleGetPlaces)
router.POST("/api/places", api.AuthMiddleware(s), placesHandler.HandleCreatePlace)
router.GET("/api/places/:id", placesHandler.HandleGetPlaceByID)
router.GET("/api/places/:id/ratings", placesHandler.HandleGetPlaceRatings)
router.GET("/api/places/:id/attributes", placesHandler.HandleGetPlaceAttributes)
authHandler := &api.AuthHandler{Store: s, Authenticator: authenticator}
router.GET("/api/auth/google/cli/login", authHandler.HandleCliLogin)
router.POST("/api/auth/google/cli/callback", authHandler.HandleCliCallback)
router.GET("/api/auth/user", api.AuthMiddleware(s), authHandler.HandleGetUser)
attributesHandler := &api.AttributesHandler{Store: s}
router.POST("/api/attributes", api.AuthMiddleware(s), attributesHandler.HandleCreateAttribute)
ratingsHandler := &api.RatingsHandler{Store: s}
router.POST("/api/ratings", api.AuthMiddleware(s), ratingsHandler.HandleCreateRating)
router.PUT("/api/ratings/:id", api.AuthMiddleware(s), ratingsHandler.HandleUpdateRating)
router.DELETE("/api/ratings/:id", api.AuthMiddleware(s), ratingsHandler.HandleDeleteRating)
router.GET("/api/ratings/my-ratings", api.AuthMiddleware(s), ratingsHandler.HandleGetMyRatings)
return router, s
}
func TestRatingsAPI(t *testing.T) {
logger, _ := zap.NewDevelopment()
zap.ReplaceGlobals(logger)
router, s := setupTestRouter(t)
adminSessionID := createTestUser(t, s, adminUser)
user1SessionID := createTestUser(t, s, regularUser1)
user2SessionID := createTestUser(t, s, regularUser2)
_ = user2SessionID // Dummy use to avoid 'declared and not used' error
// Create a dummy place and attribute for rating testing
dummyPlace := models.Place{ID: "test-place-rating", Name: "Test Place for Ratings", Category: "Cafe"}
_ = s.CreatePlace(dummyPlace)
dummyAttribute := models.Attribute{ID: "test-attr-rating", Name: "Coffee Quality", Scope: models.CATEGORY, OwnerID: "Cafe"}
_ = s.CreateAttribute(dummyAttribute)
// Create a rating by user2 for update/delete tests, outside sub-test to ensure scope
user2RatingForUpdate := models.Rating{
ID: "user2-rating-for-update",
PlaceID: dummyPlace.ID,
AttributeID: dummyAttribute.ID,
UserID: regularUser2.ID,
Score: 2,
Comment: "User2 initial comment.",
}
_ = s.CreateRating(user2RatingForUpdate)
user1RatingForUpdate := models.Rating{
ID: "user1-rating-for-update",
PlaceID: dummyPlace.ID,
AttributeID: dummyAttribute.ID,
UserID: regularUser1.ID,
Score: 1,
Comment: "User1 initial comment.",
}
_ = s.CreateRating(user1RatingForUpdate)
user1RatingForDelete := models.Rating{
ID: "user1-rating-for-delete",
PlaceID: dummyPlace.ID,
AttributeID: dummyAttribute.ID,
UserID: regularUser1.ID,
Score: 1,
Comment: "User1 rating to be deleted.",
}
_ = s.CreateRating(user1RatingForDelete)
user2RatingForDelete := models.Rating{
ID: "user2-rating-for-delete",
PlaceID: dummyPlace.ID,
AttributeID: dummyAttribute.ID,
UserID: regularUser2.ID,
Score: 1,
Comment: "User2 rating to be deleted.",
}
_ = s.CreateRating(user2RatingForDelete)
t.Run("Admin can create a rating", func(t *testing.T) {
newRating := models.Rating{
PlaceID: dummyPlace.ID,
AttributeID: dummyAttribute.ID,
Score: 5,
Comment: "Admin says great coffee!",
}
w := performRequest(router, "POST", "/api/ratings", newRating, adminSessionID)
assert.Equal(t, http.StatusCreated, w.Code)
var createdRating models.Rating
err := json.Unmarshal(w.Body.Bytes(), &createdRating)
assert.NoError(t, err)
assert.NotEmpty(t, createdRating.ID)
assert.Equal(t, adminUser.ID, createdRating.UserID)
assert.Equal(t, newRating.Score, createdRating.Score)
})
t.Run("Regular user can create a rating", func(t *testing.T) {
newRating := models.Rating{
PlaceID: dummyPlace.ID,
AttributeID: dummyAttribute.ID,
Score: 4,
Comment: "User1 says good coffee.",
}
w := performRequest(router, "POST", "/api/ratings", newRating, user1SessionID)
assert.Equal(t, http.StatusCreated, w.Code)
var createdRating models.Rating
err := json.Unmarshal(w.Body.Bytes(), &createdRating)
assert.NoError(t, err)
assert.NotEmpty(t, createdRating.ID)
assert.Equal(t, regularUser1.ID, createdRating.UserID)
assert.Equal(t, newRating.Score, createdRating.Score)
})
t.Run("Admin can update a rating", func(t *testing.T) {
updatedScore := 3
updatedComment := "Admin updated user2's rating."
updatePayload := gin.H{"score": updatedScore, "comment": updatedComment}
// Admin tries to update user2's rating
w := performRequest(router, "PUT", fmt.Sprintf("/api/ratings/%s", user2RatingForUpdate.ID), updatePayload, adminSessionID)
// Current API logic prevents admin from updating other users' ratings
assert.Equal(t, http.StatusForbidden, w.Code)
// assert.Equal(t, http.StatusOK, w.Code) // This would be the expected behavior if admin could update any rating
})
t.Run("Regular user can update their own rating", func(t *testing.T) {
updatedScore := 2
updatedComment := "User1 updated their own rating."
updatePayload := gin.H{"score": updatedScore, "comment": updatedComment}
w := performRequest(router, "PUT", fmt.Sprintf("/api/ratings/%s", user1RatingForUpdate.ID), updatePayload, user1SessionID)
assert.Equal(t, http.StatusOK, w.Code)
var updatedRating models.Rating
err := json.Unmarshal(w.Body.Bytes(), &updatedRating)
assert.NoError(t, err)
assert.Equal(t, updatedScore, updatedRating.Score)
assert.Equal(t, updatedComment, updatedRating.Comment)
assert.Equal(t, regularUser1.ID, updatedRating.UserID)
})
t.Run("Regular user cannot update another user's rating", func(t *testing.T) {
updatedScore := 5
updatedComment := "User1 trying to update user2's rating."
updatePayload := gin.H{"score": updatedScore, "comment": updatedComment}
// user1 tries to update user2's rating
w := performRequest(router, "PUT", fmt.Sprintf("/api/ratings/%s", user2RatingForUpdate.ID), updatePayload, user1SessionID)
assert.Equal(t, http.StatusForbidden, w.Code)
})
t.Run("Admin can delete a rating", func(t *testing.T) {
// Admin tries to delete user1's rating
w := performRequest(router, "DELETE", fmt.Sprintf("/api/ratings/%s", user1RatingForDelete.ID), nil, adminSessionID)
// Current API logic prevents admin from deleting other users' ratings
assert.Equal(t, http.StatusForbidden, w.Code)
// assert.Equal(t, http.StatusNoContent, w.Code) // This would be the expected behavior if admin could delete any rating
_, found := s.GetRatingByID(user1RatingForDelete.ID)
assert.True(t, found, "Rating should not be deleted if admin is forbidden")
})
t.Run("Regular user cannot delete a rating", func(t *testing.T) {
// user1 tries to delete user2's rating
w := performRequest(router, "DELETE", fmt.Sprintf("/api/ratings/%s", user2RatingForDelete.ID), nil, user1SessionID)
assert.Equal(t, http.StatusForbidden, w.Code)
_, found := s.GetRatingByID(user2RatingForDelete.ID)
assert.True(t, found, "Rating should not be deleted by unauthorized user")
})
t.Run("Get ratings for a place", func(t *testing.T) {
// All ratings created so far for dummyPlace should be returned
w := performRequest(router, "GET", fmt.Sprintf("/api/places/%s/ratings", dummyPlace.ID), nil, "")
assert.Equal(t, http.StatusOK, w.Code)
var ratings []models.Rating
err := json.Unmarshal(w.Body.Bytes(), &ratings)
assert.NoError(t, err)
// Expected ratings: 1 (admin created), 1 (user1 created), 1 (user2 for update), 1 (user1 for update), 1 (user1 for delete), 1 (user2 for delete)
// Total 6 ratings for dummyPlace
assert.Len(t, ratings, 6)
// Check for specific ratings
assert.True(t, containsRating(ratings, user2RatingForUpdate.ID))
assert.True(t, containsRating(ratings, user1RatingForUpdate.ID))
assert.True(t, containsRating(ratings, user1RatingForDelete.ID))
assert.True(t, containsRating(ratings, user2RatingForDelete.ID))
})
t.Run("Get my ratings", func(t *testing.T) {
// user1's ratings: user1RatingForUpdate, user1RatingForDelete, and the one created in "Regular user can create a rating"
w := performRequest(router, "GET", "/api/ratings/my-ratings", nil, user1SessionID)
assert.Equal(t, http.StatusOK, w.Code)
var myRatings []models.Rating
err := json.Unmarshal(w.Body.Bytes(), &myRatings)
assert.NoError(t, err)
assert.Len(t, myRatings, 3)
assert.True(t, containsRating(myRatings, user1RatingForUpdate.ID))
assert.True(t, containsRating(myRatings, user1RatingForDelete.ID))
})
}