Files
honeyDueAPI/internal/handlers/notification_handler_test.go
Trey T bec880886b Coverage priorities 1-5: test pure functions, extract interfaces, mock-based handler tests
- Priority 1: Test NewSendEmailTask + NewSendPushTask (5 tests)
- Priority 2: Test customHTTPErrorHandler — all 15+ branches (21 tests)
- Priority 3: Extract Enqueuer interface + payload builders in worker pkg (5 tests)
- Priority 4: Extract ClassifyFile/ComputeRelPath in migrate-encrypt (6 tests)
- Priority 5: Define Handler interfaces, refactor to accept them, mock-based tests (14 tests)
- Fix .gitignore: /worker instead of worker to stop ignoring internal/worker/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 20:30:09 -05:00

409 lines
15 KiB
Go

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