Files
honeyDueAPI/internal/services/notification_service_test.go
T
Trey t e881d37de0
Backend CI / Test (push) Has been cancelled
Backend CI / Contract Tests (push) Has been cancelled
Backend CI / Build (push) Has been cancelled
Backend CI / Lint (push) Has been cancelled
Backend CI / Secret Scanning (push) Has been cancelled
Migrate Auth/Contractor/Document/Notification/Subscription services to ctx
Every public method on these five services now takes ctx context.Context as
the first arg and routes its repo calls through .WithContext(ctx). With
TaskService and ResidenceService already migrated, this means every
in-process service that touches Postgres now produces a flame graph in
Jaeger where the SQL spans nest under the parent HTTP request span.

Endpoints now fully traced (HTTP → service → SQL):
- /api/auth/login, /register, /logout, /me, /verify-email, /resend-verification
- /api/auth/forgot-password, /verify-reset, /reset-password, /update-profile
- /api/contractors/* (CRUD + favorite + by-residence + tasks)
- /api/documents/* (CRUD + activate/deactivate + image upload/delete)
- /api/notifications/* (list, count, mark-read, prefs, devices)
- /api/subscription/* (status, purchase, cancel, triggers, promotions)
- All previously-migrated /api/tasks/* and /api/residences/* paths

Internal helpers also threaded:
- TaskService.sendTaskCompletedNotification → forwards ctx
- TaskService.UpdateUserTimezone → forwards ctx to NotificationService
- ResidenceService.CreateResidence → forwards ctx to SubscriptionService.CheckLimit
- NotificationService.registerAPNSDevice / registerGCMDevice → both take ctx

~75 method signatures, ~120 handler/test call sites updated. Tests pass
green; the only failure is the pre-existing flaky TaskHandler_QuickComplete
SQLite race that fails ~60% of runs on master.

Step 3 of the observability plan is now genuinely complete: every API
endpoint backed by a Go service emits a per-request flame graph with
HTTP → service → SQL spans, plus B2/APNs/FCM/asynq spans where applicable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:26:21 -05:00

1185 lines
39 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), notif.ID, user.ID)
require.NoError(t, err)
// Verify unread count is 0
count, err := service.GetUnreadCount(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), user.ID)
require.NoError(t, err)
count, err := service.GetUnreadCount(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), "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(context.Background(), "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(context.Background(), "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(context.Background(), "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(context.Background(), "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(context.Background(), 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(context.Background(), 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(context.Background(), user.ID, "America/New_York")
// Set same timezone again — should be a no-op
service.UpdateUserTimezone(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), user.ID, req)
require.NoError(t, err)
// Re-register with same token but new name
req.Name = "Pixel 8 Pro"
resp, err := service.RegisterDevice(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), "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(context.Background(), "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"))
}