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