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)) }) }