Files
honeyDueAPI/internal/services/notification_service_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

1185 lines
38 KiB
Go

package services
import (
"context"
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/treytartt/honeydue-api/internal/models"
"github.com/treytartt/honeydue-api/internal/push"
"github.com/treytartt/honeydue-api/internal/repositories"
"github.com/treytartt/honeydue-api/internal/testutil"
)
func setupNotificationService(t *testing.T) (*NotificationService, *repositories.NotificationRepository) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
// pushClient is nil for testing (no actual push sends)
service := NewNotificationService(notifRepo, nil)
return service, notifRepo
}
// === GetNotifications ===
func TestNotificationService_GetNotifications(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Create some notifications
for i := 0; i < 3; i++ {
notif := &models.Notification{
UserID: user.ID,
NotificationType: models.NotificationTaskDueSoon,
Title: "Test Notification",
Body: "Some task is due soon",
}
err := db.Create(notif).Error
require.NoError(t, err)
}
resp, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Len(t, resp, 3)
}
func TestNotificationService_GetNotifications_Empty(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
resp, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Empty(t, resp)
}
func TestNotificationService_GetNotifications_Pagination(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
for i := 0; i < 5; i++ {
notif := &models.Notification{
UserID: user.ID,
NotificationType: models.NotificationTaskDueSoon,
Title: "Test",
Body: "Body",
}
err := db.Create(notif).Error
require.NoError(t, err)
}
// Get first 2
resp, err := service.GetNotifications(user.ID, 2, 0)
require.NoError(t, err)
assert.Len(t, resp, 2)
}
// === GetUnreadCount ===
func TestNotificationService_GetUnreadCount(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Create 3 unread notifications
for i := 0; i < 3; i++ {
notif := &models.Notification{
UserID: user.ID,
NotificationType: models.NotificationTaskDueSoon,
Title: "Unread",
Body: "Body",
Read: false,
}
err := db.Create(notif).Error
require.NoError(t, err)
}
count, err := service.GetUnreadCount(user.ID)
require.NoError(t, err)
assert.Equal(t, int64(3), count)
}
func TestNotificationService_GetUnreadCount_Zero(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
count, err := service.GetUnreadCount(user.ID)
require.NoError(t, err)
assert.Equal(t, int64(0), count)
}
// === MarkAsRead ===
func TestNotificationService_MarkAsRead(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
notif := &models.Notification{
UserID: user.ID,
NotificationType: models.NotificationTaskDueSoon,
Title: "Test",
Body: "Body",
}
err := db.Create(notif).Error
require.NoError(t, err)
err = service.MarkAsRead(notif.ID, user.ID)
require.NoError(t, err)
// Verify unread count is 0
count, err := service.GetUnreadCount(user.ID)
require.NoError(t, err)
assert.Equal(t, int64(0), count)
}
func TestNotificationService_MarkAsRead_WrongUser(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
other := testutil.CreateTestUser(t, db, "other", "other@test.com", "Password123")
notif := &models.Notification{
UserID: owner.ID,
NotificationType: models.NotificationTaskDueSoon,
Title: "Private",
Body: "Body",
}
err := db.Create(notif).Error
require.NoError(t, err)
err = service.MarkAsRead(notif.ID, other.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.notification_not_found")
}
func TestNotificationService_MarkAsRead_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.MarkAsRead(9999, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.notification_not_found")
}
// === MarkAllAsRead ===
func TestNotificationService_MarkAllAsRead(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Create unread notifications
for i := 0; i < 3; i++ {
notif := &models.Notification{
UserID: user.ID,
NotificationType: models.NotificationTaskDueSoon,
Title: "Unread",
Body: "Body",
}
err := db.Create(notif).Error
require.NoError(t, err)
}
err := service.MarkAllAsRead(user.ID)
require.NoError(t, err)
count, err := service.GetUnreadCount(user.ID)
require.NoError(t, err)
assert.Equal(t, int64(0), count)
}
// === CreateAndSendNotification ===
func TestNotificationService_CreateAndSendNotification(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil) // nil push client = no actual push
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
data := map[string]interface{}{
"task_id": 123,
}
err := service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationTaskDueSoon, "Due Soon", "Fix faucet", data)
require.NoError(t, err)
// Verify notification was created
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Len(t, notifs, 1)
assert.Equal(t, "Due Soon", notifs[0].Title)
assert.Equal(t, "Fix faucet", notifs[0].Body)
}
func TestNotificationService_CreateAndSendNotification_DisabledPreference(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Create preferences with task_due_soon disabled
prefs, err := notifRepo.GetOrCreatePreferences(user.ID)
require.NoError(t, err)
prefs.TaskDueSoon = false
err = notifRepo.UpdatePreferences(prefs)
require.NoError(t, err)
err = service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationTaskDueSoon, "Due Soon", "Fix faucet", nil)
require.NoError(t, err)
// Verify no notification was created (silently skipped)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Empty(t, notifs)
}
// === Preferences ===
func TestNotificationService_GetPreferences(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
resp, err := service.GetPreferences(user.ID)
require.NoError(t, err)
assert.NotNil(t, resp)
// Defaults should all be true
assert.True(t, resp.TaskDueSoon)
assert.True(t, resp.TaskOverdue)
assert.True(t, resp.TaskCompleted)
}
func TestNotificationService_UpdatePreferences(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
falseVal := false
req := &UpdatePreferencesRequest{
TaskDueSoon: &falseVal,
}
resp, err := service.UpdatePreferences(user.ID, req)
require.NoError(t, err)
assert.False(t, resp.TaskDueSoon)
assert.True(t, resp.TaskOverdue) // unchanged
}
func TestNotificationService_UpdatePreferences_InvalidHour(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
invalidHour := 25
req := &UpdatePreferencesRequest{
TaskDueSoonHour: &invalidHour,
}
_, err := service.UpdatePreferences(user.ID, req)
testutil.AssertAppErrorCode(t, err, http.StatusBadRequest)
}
func TestNotificationService_UpdatePreferences_ValidHour(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
hour := 9
req := &UpdatePreferencesRequest{
TaskDueSoonHour: &hour,
}
resp, err := service.UpdatePreferences(user.ID, req)
require.NoError(t, err)
assert.Equal(t, 9, *resp.TaskDueSoonHour)
}
// === RegisterDevice ===
func TestNotificationService_RegisterDevice_iOS(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
req := &RegisterDeviceRequest{
Name: "iPhone 15",
DeviceID: "device-abc",
RegistrationID: "token-xyz",
Platform: push.PlatformIOS,
}
resp, err := service.RegisterDevice(user.ID, req)
require.NoError(t, err)
assert.Equal(t, "iPhone 15", resp.Name)
assert.Equal(t, push.PlatformIOS, resp.Platform)
assert.True(t, resp.Active)
}
func TestNotificationService_RegisterDevice_Android(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
req := &RegisterDeviceRequest{
Name: "Pixel 8",
DeviceID: "device-def",
RegistrationID: "token-abc",
Platform: push.PlatformAndroid,
}
resp, err := service.RegisterDevice(user.ID, req)
require.NoError(t, err)
assert.Equal(t, "Pixel 8", resp.Name)
assert.Equal(t, push.PlatformAndroid, resp.Platform)
assert.True(t, resp.Active)
}
func TestNotificationService_RegisterDevice_InvalidPlatform(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
req := &RegisterDeviceRequest{
Name: "Unknown",
DeviceID: "device-bad",
RegistrationID: "token-bad",
Platform: "windows",
}
_, err := service.RegisterDevice(user.ID, req)
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.invalid_platform")
}
func TestNotificationService_RegisterDevice_UpdateExisting(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Register a device
req := &RegisterDeviceRequest{
Name: "iPhone 15",
DeviceID: "device-abc",
RegistrationID: "token-xyz",
Platform: push.PlatformIOS,
}
_, err := service.RegisterDevice(user.ID, req)
require.NoError(t, err)
// Re-register with same token (should update, not duplicate)
req.Name = "iPhone 15 Pro"
resp, err := service.RegisterDevice(user.ID, req)
require.NoError(t, err)
assert.Equal(t, "iPhone 15 Pro", resp.Name)
}
// === ListDevices ===
func TestNotificationService_ListDevices(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Register iOS and Android devices
iosDevice := &models.APNSDevice{
UserID: &user.ID,
Name: "iPhone",
DeviceID: "d1",
RegistrationID: "t1",
Active: true,
}
err := db.Create(iosDevice).Error
require.NoError(t, err)
androidDevice := &models.GCMDevice{
UserID: &user.ID,
Name: "Pixel",
DeviceID: "d2",
RegistrationID: "t2",
CloudMessageType: "FCM",
Active: true,
}
err = db.Create(androidDevice).Error
require.NoError(t, err)
resp, err := service.ListDevices(user.ID)
require.NoError(t, err)
assert.Len(t, resp, 2)
}
func TestNotificationService_ListDevices_Empty(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
resp, err := service.ListDevices(user.ID)
require.NoError(t, err)
assert.Empty(t, resp)
}
// === DeleteDevice - Invalid Platform ===
func TestDeleteDevice_InvalidPlatform_Returns400(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.DeleteDevice(1, "windows", user.ID)
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.invalid_platform")
}
// === UnregisterDevice ===
func TestNotificationService_UnregisterDevice_iOS(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
device := &models.APNSDevice{
UserID: &user.ID,
Name: "iPhone",
DeviceID: "d1",
RegistrationID: "reg-token-ios",
Active: true,
}
err := db.Create(device).Error
require.NoError(t, err)
err = service.UnregisterDevice("reg-token-ios", push.PlatformIOS, user.ID)
require.NoError(t, err)
// Verify device is deactivated
var found models.APNSDevice
err = db.First(&found, device.ID).Error
require.NoError(t, err)
assert.False(t, found.Active)
}
func TestNotificationService_UnregisterDevice_Android(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
device := &models.GCMDevice{
UserID: &user.ID,
Name: "Pixel",
DeviceID: "d2",
RegistrationID: "reg-token-android",
CloudMessageType: "FCM",
Active: true,
}
err := db.Create(device).Error
require.NoError(t, err)
err = service.UnregisterDevice("reg-token-android", push.PlatformAndroid, user.ID)
require.NoError(t, err)
var found models.GCMDevice
err = db.First(&found, device.ID).Error
require.NoError(t, err)
assert.False(t, found.Active)
}
func TestNotificationService_UnregisterDevice_NotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.UnregisterDevice("nonexistent-token", push.PlatformIOS, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.device_not_found")
}
func TestNotificationService_UnregisterDevice_WrongUser(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
attacker := testutil.CreateTestUser(t, db, "attacker", "attacker@test.com", "Password123")
device := &models.APNSDevice{
UserID: &owner.ID,
Name: "iPhone",
DeviceID: "d1",
RegistrationID: "owner-token",
Active: true,
}
err := db.Create(device).Error
require.NoError(t, err)
err = service.UnregisterDevice("owner-token", push.PlatformIOS, attacker.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.device_not_found")
}
func TestNotificationService_UnregisterDevice_InvalidPlatform(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.UnregisterDevice("some-token", "windows", user.ID)
testutil.AssertAppError(t, err, http.StatusBadRequest, "error.invalid_platform")
}
// === UpdateUserTimezone ===
func TestNotificationService_UpdateUserTimezone(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Should not panic, just silently update
service.UpdateUserTimezone(user.ID, "America/Los_Angeles")
// Verify timezone was stored
prefs, err := notifRepo.GetOrCreatePreferences(user.ID)
require.NoError(t, err)
require.NotNil(t, prefs.Timezone)
assert.Equal(t, "America/Los_Angeles", *prefs.Timezone)
}
func TestNotificationService_UpdateUserTimezone_Invalid(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Invalid timezone should be silently ignored
service.UpdateUserTimezone(user.ID, "Invalid/Timezone")
prefs, err := notifRepo.GetOrCreatePreferences(user.ID)
require.NoError(t, err)
assert.Nil(t, prefs.Timezone) // Should not have been set
}
func TestNotificationService_UpdateUserTimezone_NoChangeSkipsWrite(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
// Set timezone
service.UpdateUserTimezone(user.ID, "America/New_York")
// Set same timezone again — should be a no-op
service.UpdateUserTimezone(user.ID, "America/New_York")
prefs, err := notifRepo.GetOrCreatePreferences(user.ID)
require.NoError(t, err)
require.NotNil(t, prefs.Timezone)
assert.Equal(t, "America/New_York", *prefs.Timezone)
}
func TestDeleteDevice_WrongUser_Returns403(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
attacker := testutil.CreateTestUser(t, db, "attacker", "attacker@test.com", "password")
// Register an iOS device for the owner
device := &models.APNSDevice{
UserID: &owner.ID,
Name: "Owner iPhone",
DeviceID: "device-123",
RegistrationID: "token-abc",
Active: true,
}
err := db.Create(device).Error
require.NoError(t, err)
// Attacker tries to deactivate the owner's device
err = service.DeleteDevice(device.ID, push.PlatformIOS, attacker.ID)
require.Error(t, err, "should not allow deleting another user's device")
testutil.AssertAppErrorCode(t, err, http.StatusForbidden)
// Verify the device is still active
var found models.APNSDevice
err = db.First(&found, device.ID).Error
require.NoError(t, err)
assert.True(t, found.Active, "device should still be active after failed deletion")
}
func TestDeleteDevice_CorrectUser_Succeeds(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
// Register an iOS device for the owner
device := &models.APNSDevice{
UserID: &owner.ID,
Name: "Owner iPhone",
DeviceID: "device-123",
RegistrationID: "token-abc",
Active: true,
}
err := db.Create(device).Error
require.NoError(t, err)
// Owner deactivates their own device
err = service.DeleteDevice(device.ID, push.PlatformIOS, owner.ID)
require.NoError(t, err, "owner should be able to deactivate their own device")
// Verify the device is now inactive
var found models.APNSDevice
err = db.First(&found, device.ID).Error
require.NoError(t, err)
assert.False(t, found.Active, "device should be deactivated")
}
func TestDeleteDevice_WrongUser_Android_Returns403(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
attacker := testutil.CreateTestUser(t, db, "attacker", "attacker@test.com", "password")
// Register an Android device for the owner
device := &models.GCMDevice{
UserID: &owner.ID,
Name: "Owner Pixel",
DeviceID: "device-456",
RegistrationID: "token-def",
CloudMessageType: "FCM",
Active: true,
}
err := db.Create(device).Error
require.NoError(t, err)
// Attacker tries to deactivate the owner's Android device
err = service.DeleteDevice(device.ID, push.PlatformAndroid, attacker.ID)
require.Error(t, err, "should not allow deleting another user's Android device")
testutil.AssertAppErrorCode(t, err, http.StatusForbidden)
// Verify the device is still active
var found models.GCMDevice
err = db.First(&found, device.ID).Error
require.NoError(t, err)
assert.True(t, found.Active, "Android device should still be active after failed deletion")
}
func TestDeleteDevice_NonExistent_Returns404(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "password")
err := service.DeleteDevice(99999, push.PlatformIOS, user.ID)
require.Error(t, err, "should return error for non-existent device")
testutil.AssertAppErrorCode(t, err, http.StatusNotFound)
}
// === CreateAndSendNotification — all notification types ===
func TestNotificationService_CreateAndSend_TaskOverdue(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationTaskOverdue, "Overdue", "Task is overdue", nil)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Len(t, notifs, 1)
assert.Equal(t, "Overdue", notifs[0].Title)
}
func TestNotificationService_CreateAndSend_TaskCompleted(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationTaskCompleted, "Completed", "Task done", nil)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Len(t, notifs, 1)
}
func TestNotificationService_CreateAndSend_TaskAssigned(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationTaskAssigned, "Assigned", "Task assigned to you", nil)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Len(t, notifs, 1)
}
func TestNotificationService_CreateAndSend_ResidenceShared(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationResidenceShared, "Shared", "Someone shared a home", nil)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Len(t, notifs, 1)
}
func TestNotificationService_CreateAndSend_WarrantyExpiring(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationWarrantyExpiring, "Expiring", "Warranty expiring soon", nil)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Len(t, notifs, 1)
}
// === CreateAndSendNotification — disabled preference for each type ===
func TestNotificationService_DisabledPrefs_TaskOverdue(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
prefs, err := notifRepo.GetOrCreatePreferences(user.ID)
require.NoError(t, err)
prefs.TaskOverdue = false
err = notifRepo.UpdatePreferences(prefs)
require.NoError(t, err)
err = service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationTaskOverdue, "Overdue", "Overdue task", nil)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Empty(t, notifs)
}
func TestNotificationService_DisabledPrefs_TaskCompleted(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
prefs, err := notifRepo.GetOrCreatePreferences(user.ID)
require.NoError(t, err)
prefs.TaskCompleted = false
err = notifRepo.UpdatePreferences(prefs)
require.NoError(t, err)
err = service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationTaskCompleted, "Completed", "Task done", nil)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Empty(t, notifs)
}
func TestNotificationService_DisabledPrefs_TaskAssigned(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
prefs, err := notifRepo.GetOrCreatePreferences(user.ID)
require.NoError(t, err)
prefs.TaskAssigned = false
err = notifRepo.UpdatePreferences(prefs)
require.NoError(t, err)
err = service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationTaskAssigned, "Assigned", "Task assigned", nil)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Empty(t, notifs)
}
func TestNotificationService_DisabledPrefs_ResidenceShared(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
prefs, err := notifRepo.GetOrCreatePreferences(user.ID)
require.NoError(t, err)
prefs.ResidenceShared = false
err = notifRepo.UpdatePreferences(prefs)
require.NoError(t, err)
err = service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationResidenceShared, "Shared", "Home shared", nil)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Empty(t, notifs)
}
func TestNotificationService_DisabledPrefs_WarrantyExpiring(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
prefs, err := notifRepo.GetOrCreatePreferences(user.ID)
require.NoError(t, err)
prefs.WarrantyExpiring = false
err = notifRepo.UpdatePreferences(prefs)
require.NoError(t, err)
err = service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationWarrantyExpiring, "Warranty", "Expiring", nil)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Empty(t, notifs)
}
// === CreateAndSendNotification — unknown type defaults to enabled ===
func TestNotificationService_CreateAndSend_UnknownTypeDefaultsEnabled(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationType("unknown_type"), "Unknown", "Unknown notification", nil)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Len(t, notifs, 1)
}
// === CreateAndSendNotification with string and non-string data values ===
func TestNotificationService_CreateAndSend_WithMixedDataTypes(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
data := map[string]interface{}{
"task_id": uint(42),
"task_name": "Fix faucet",
"residence_id": uint(10),
"extra_data": map[string]string{"foo": "bar"},
}
err := service.CreateAndSendNotification(context.Background(), user.ID, models.NotificationTaskDueSoon, "Due Soon", "Fix faucet", data)
require.NoError(t, err)
notifs, err := service.GetNotifications(user.ID, 10, 0)
require.NoError(t, err)
assert.Len(t, notifs, 1)
assert.NotNil(t, notifs[0].Data)
}
// === UpdatePreferences — partial update with multiple fields ===
func TestNotificationService_UpdatePreferences_MultipleFields(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
falseVal := false
trueVal := true
hour8 := 8
hour14 := 14
req := &UpdatePreferencesRequest{
TaskDueSoon: &falseVal,
TaskOverdue: &falseVal,
TaskCompleted: &trueVal,
TaskAssigned: &trueVal,
ResidenceShared: &falseVal,
WarrantyExpiring: &trueVal,
DailyDigest: &falseVal,
EmailTaskCompleted: &trueVal,
TaskDueSoonHour: &hour8,
TaskOverdueHour: &hour14,
}
resp, err := service.UpdatePreferences(user.ID, req)
require.NoError(t, err)
assert.False(t, resp.TaskDueSoon)
assert.False(t, resp.TaskOverdue)
assert.True(t, resp.TaskCompleted)
assert.True(t, resp.TaskAssigned)
assert.False(t, resp.ResidenceShared)
assert.True(t, resp.WarrantyExpiring)
assert.False(t, resp.DailyDigest)
assert.True(t, resp.EmailTaskCompleted)
assert.Equal(t, 8, *resp.TaskDueSoonHour)
assert.Equal(t, 14, *resp.TaskOverdueHour)
}
// === UpdatePreferences — negative hour ===
func TestNotificationService_UpdatePreferences_NegativeHour(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
negHour := -1
req := &UpdatePreferencesRequest{
TaskOverdueHour: &negHour,
}
_, err := service.UpdatePreferences(user.ID, req)
testutil.AssertAppErrorCode(t, err, http.StatusBadRequest)
}
// === RegisterDevice — re-register Android device with same token ===
func TestNotificationService_RegisterDevice_UpdateExistingAndroid(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
req := &RegisterDeviceRequest{
Name: "Pixel 8",
DeviceID: "device-android",
RegistrationID: "token-android-1",
Platform: push.PlatformAndroid,
}
_, err := service.RegisterDevice(user.ID, req)
require.NoError(t, err)
// Re-register with same token but new name
req.Name = "Pixel 8 Pro"
resp, err := service.RegisterDevice(user.ID, req)
require.NoError(t, err)
assert.Equal(t, "Pixel 8 Pro", resp.Name)
assert.Equal(t, push.PlatformAndroid, resp.Platform)
}
// === DeleteDevice — Android not found ===
func TestDeleteDevice_AndroidNotFound_Returns404(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.DeleteDevice(99999, push.PlatformAndroid, user.ID)
testutil.AssertAppErrorCode(t, err, http.StatusNotFound)
}
// === DeleteDevice — Android correct user succeeds ===
func TestDeleteDevice_CorrectUser_Android_Succeeds(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
device := &models.GCMDevice{
UserID: &owner.ID,
Name: "Pixel",
DeviceID: "device-android-1",
RegistrationID: "token-android-1",
CloudMessageType: "FCM",
Active: true,
}
err := db.Create(device).Error
require.NoError(t, err)
err = service.DeleteDevice(device.ID, push.PlatformAndroid, owner.ID)
require.NoError(t, err)
var found models.GCMDevice
err = db.First(&found, device.ID).Error
require.NoError(t, err)
assert.False(t, found.Active)
}
// === UnregisterDevice — Android wrong user ===
func TestNotificationService_UnregisterDevice_WrongUser_Android(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
owner := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
attacker := testutil.CreateTestUser(t, db, "attacker", "attacker@test.com", "Password123")
device := &models.GCMDevice{
UserID: &owner.ID,
Name: "Pixel",
DeviceID: "d2",
RegistrationID: "owner-android-token",
CloudMessageType: "FCM",
Active: true,
}
err := db.Create(device).Error
require.NoError(t, err)
err = service.UnregisterDevice("owner-android-token", push.PlatformAndroid, attacker.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.device_not_found")
}
// === UnregisterDevice — Android not found ===
func TestNotificationService_UnregisterDevice_AndroidNotFound(t *testing.T) {
db := testutil.SetupTestDB(t)
notifRepo := repositories.NewNotificationRepository(db)
service := NewNotificationService(notifRepo, nil)
user := testutil.CreateTestUser(t, db, "owner", "owner@test.com", "Password123")
err := service.UnregisterDevice("nonexistent-android", push.PlatformAndroid, user.ID)
testutil.AssertAppError(t, err, http.StatusNotFound, "error.device_not_found")
}
// === NewNotificationResponse with data and dates ===
func TestNewNotificationResponse_WithDataAndDates(t *testing.T) {
now := time.Now()
readAt := now.Add(-1 * time.Hour)
sentAt := now.Add(-2 * time.Hour)
n := &models.Notification{
UserID: 1,
NotificationType: models.NotificationTaskDueSoon,
Title: "Test",
Body: "Body",
Data: `{"task_id": 42}`,
Read: true,
ReadAt: &readAt,
Sent: true,
SentAt: &sentAt,
}
n.CreatedAt = now
resp := NewNotificationResponse(n)
assert.Equal(t, "Test", resp.Title)
assert.True(t, resp.Read)
assert.True(t, resp.Sent)
assert.NotNil(t, resp.ReadAt)
assert.NotNil(t, resp.SentAt)
assert.NotNil(t, resp.Data)
assert.Equal(t, float64(42), resp.Data["task_id"])
}
func TestNewNotificationResponse_EmptyData(t *testing.T) {
now := time.Now()
n := &models.Notification{
UserID: 1,
NotificationType: models.NotificationTaskDueSoon,
Title: "Test",
Body: "Body",
Data: "",
}
n.CreatedAt = now
resp := NewNotificationResponse(n)
assert.Nil(t, resp.Data)
assert.Nil(t, resp.ReadAt)
assert.Nil(t, resp.SentAt)
}
// === validateHourField ===
func TestValidateHourField_BoundaryValues(t *testing.T) {
zero := 0
twentyThree := 23
twentyFour := 24
assert.NoError(t, validateHourField(&zero, "test"))
assert.NoError(t, validateHourField(&twentyThree, "test"))
assert.Error(t, validateHourField(&twentyFour, "test"))
assert.NoError(t, validateHourField(nil, "test"))
}