package handlers import ( "encoding/json" "fmt" "net/http" "testing" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/repositories" "github.com/treytartt/honeydue-api/internal/services" "github.com/treytartt/honeydue-api/internal/testutil" ) func setupNotificationHandler(t *testing.T) (*NotificationHandler, *echo.Echo, *gorm.DB) { db := testutil.SetupTestDB(t) notifRepo := repositories.NewNotificationRepository(db) notifService := services.NewNotificationService(notifRepo, nil) handler := NewNotificationHandler(notifService) e := testutil.SetupTestRouter() return handler, e, db } func createTestNotifications(t *testing.T, db *gorm.DB, userID uint, count int) { for i := 0; i < count; i++ { notif := &models.Notification{ UserID: userID, NotificationType: models.NotificationTaskDueSoon, Title: fmt.Sprintf("Test Notification %d", i+1), Body: fmt.Sprintf("Body %d", i+1), } err := db.Create(notif).Error require.NoError(t, err) } } func TestNotificationHandler_ListNotifications_LimitCappedAt200(t *testing.T) { handler, e, db := setupNotificationHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password") // Create 210 notifications to exceed the cap createTestNotifications(t, db, user.ID, 210) authGroup := e.Group("/api/notifications") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.GET("/", handler.ListNotifications) t.Run("limit is capped at 200 when user requests more", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/notifications/?limit=999", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) count := int(response["count"].(float64)) assert.Equal(t, 200, count, "response should contain at most 200 notifications when limit exceeds cap") }) t.Run("limit below cap is respected", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/notifications/?limit=10", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) count := int(response["count"].(float64)) assert.Equal(t, 10, count, "response should respect limit when below cap") }) t.Run("default limit is used when no limit param", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/notifications/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) count := int(response["count"].(float64)) assert.Equal(t, 50, count, "response should use default limit of 50") }) } func TestNotificationHandler_ListNotifications_Pagination(t *testing.T) { handler, e, db := setupNotificationHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123") createTestNotifications(t, db, user.ID, 20) authGroup := e.Group("/api/notifications") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.GET("/", handler.ListNotifications) t.Run("offset skips notifications", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/notifications/?limit=5&offset=15", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) count := int(response["count"].(float64)) assert.Equal(t, 5, count, "should return remaining 5 after offset 15") }) t.Run("response has results array", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/notifications/?limit=3", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Contains(t, response, "results") assert.Contains(t, response, "count") results := response["results"].([]interface{}) assert.Len(t, results, 3) }) t.Run("negative limit ignored", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/notifications/?limit=-5", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Negative limit should default to 50 (since -5 > 0 is false) count := int(response["count"].(float64)) assert.Equal(t, 20, count, "should return all 20 with default limit of 50") }) } func TestNotificationHandler_GetUnreadCount(t *testing.T) { handler, e, db := setupNotificationHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123") // Create some unread notifications createTestNotifications(t, db, user.ID, 5) authGroup := e.Group("/api/notifications") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.GET("/unread-count/", handler.GetUnreadCount) t.Run("successful unread count", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/notifications/unread-count/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Contains(t, response, "unread_count") unreadCount := int(response["unread_count"].(float64)) assert.Equal(t, 5, unreadCount) }) t.Run("user with no notifications returns zero", func(t *testing.T) { otherUser := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123") e2 := testutil.SetupTestRouter() authGroup2 := e2.Group("/api/notifications") authGroup2.Use(testutil.MockAuthMiddleware(otherUser)) authGroup2.GET("/unread-count/", handler.GetUnreadCount) w := testutil.MakeRequest(e2, "GET", "/api/notifications/unread-count/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, float64(0), response["unread_count"]) }) } func TestNotificationHandler_MarkAsRead(t *testing.T) { handler, e, db := setupNotificationHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123") // Create a notification notif := &models.Notification{ UserID: user.ID, NotificationType: models.NotificationTaskDueSoon, Title: "Test Notification", Body: "Test Body", } require.NoError(t, db.Create(notif).Error) authGroup := e.Group("/api/notifications") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.POST("/:id/read/", handler.MarkAsRead) t.Run("successful mark as read", func(t *testing.T) { w := testutil.MakeRequest(e, "POST", fmt.Sprintf("/api/notifications/%d/read/", notif.ID), nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Contains(t, response, "message") }) t.Run("invalid id returns 400", func(t *testing.T) { w := testutil.MakeRequest(e, "POST", "/api/notifications/invalid/read/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) t.Run("not found returns 404", func(t *testing.T) { w := testutil.MakeRequest(e, "POST", "/api/notifications/99999/read/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusNotFound) }) } func TestNotificationHandler_MarkAllAsRead(t *testing.T) { handler, e, db := setupNotificationHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123") createTestNotifications(t, db, user.ID, 5) authGroup := e.Group("/api/notifications") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.POST("/mark-all-read/", handler.MarkAllAsRead) authGroup.GET("/unread-count/", handler.GetUnreadCount) t.Run("successful mark all as read", func(t *testing.T) { w := testutil.MakeRequest(e, "POST", "/api/notifications/mark-all-read/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Contains(t, response, "message") }) t.Run("unread count is zero after mark all", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/notifications/unread-count/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, float64(0), response["unread_count"]) }) } func TestNotificationHandler_GetPreferences(t *testing.T) { handler, e, db := setupNotificationHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123") authGroup := e.Group("/api/notifications") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.GET("/preferences/", handler.GetPreferences) t.Run("successful get preferences", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/notifications/preferences/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) // Default preferences should have standard fields assert.Contains(t, response, "task_due_soon") assert.Contains(t, response, "task_overdue") assert.Contains(t, response, "task_completed") }) } func TestNotificationHandler_UpdatePreferences(t *testing.T) { handler, e, db := setupNotificationHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123") authGroup := e.Group("/api/notifications") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.PUT("/preferences/", handler.UpdatePreferences) t.Run("successful update preferences", func(t *testing.T) { req := map[string]interface{}{ "task_due_soon": false, "task_overdue": true, } w := testutil.MakeRequest(e, "PUT", "/api/notifications/preferences/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) var response map[string]interface{} err := json.Unmarshal(w.Body.Bytes(), &response) require.NoError(t, err) assert.Equal(t, false, response["task_due_soon"]) assert.Equal(t, true, response["task_overdue"]) }) } func TestNotificationHandler_RegisterDevice(t *testing.T) { handler, e, db := setupNotificationHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123") authGroup := e.Group("/api/notifications") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.POST("/devices/", handler.RegisterDevice) t.Run("successful device registration", func(t *testing.T) { req := map[string]interface{}{ "name": "iPhone 15", "device_id": "test-device-id-123", "registration_id": "test-registration-id-abc", "platform": "ios", } w := testutil.MakeRequest(e, "POST", "/api/notifications/devices/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusCreated) }) t.Run("missing required fields returns 400", func(t *testing.T) { req := map[string]interface{}{ "name": "iPhone 15", // Missing device_id, registration_id, platform } w := testutil.MakeRequest(e, "POST", "/api/notifications/devices/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) t.Run("invalid platform returns 400", func(t *testing.T) { req := map[string]interface{}{ "device_id": "test-device-id-456", "registration_id": "test-registration-id-def", "platform": "windows", // invalid } w := testutil.MakeRequest(e, "POST", "/api/notifications/devices/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) } func TestNotificationHandler_ListDevices(t *testing.T) { handler, e, db := setupNotificationHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123") authGroup := e.Group("/api/notifications") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.GET("/devices/", handler.ListDevices) t.Run("successful list devices", func(t *testing.T) { w := testutil.MakeRequest(e, "GET", "/api/notifications/devices/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusOK) }) } func TestNotificationHandler_UnregisterDevice(t *testing.T) { handler, e, db := setupNotificationHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123") authGroup := e.Group("/api/notifications") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.POST("/devices/unregister/", handler.UnregisterDevice) t.Run("missing registration_id returns 400", func(t *testing.T) { req := map[string]interface{}{ "platform": "ios", } w := testutil.MakeRequest(e, "POST", "/api/notifications/devices/unregister/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) t.Run("missing platform returns 400", func(t *testing.T) { req := map[string]interface{}{ "registration_id": "test-id", } w := testutil.MakeRequest(e, "POST", "/api/notifications/devices/unregister/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) t.Run("invalid platform returns 400", func(t *testing.T) { req := map[string]interface{}{ "registration_id": "test-id", "platform": "windows", } w := testutil.MakeRequest(e, "POST", "/api/notifications/devices/unregister/", req, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) } func TestNotificationHandler_DeleteDevice(t *testing.T) { handler, e, db := setupNotificationHandler(t) user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123") authGroup := e.Group("/api/notifications") authGroup.Use(testutil.MockAuthMiddleware(user)) authGroup.DELETE("/devices/:id/", handler.DeleteDevice) t.Run("missing platform query param returns 400", func(t *testing.T) { w := testutil.MakeRequest(e, "DELETE", "/api/notifications/devices/1/", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) t.Run("invalid platform returns 400", func(t *testing.T) { w := testutil.MakeRequest(e, "DELETE", "/api/notifications/devices/1/?platform=windows", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) t.Run("invalid id returns 400", func(t *testing.T) { w := testutil.MakeRequest(e, "DELETE", "/api/notifications/devices/invalid/?platform=ios", nil, "test-token") testutil.AssertStatusCode(t, w, http.StatusBadRequest) }) }