feat(ios): add error handling and integration tests

Implement comprehensive error handling and integration test suite:

Error Handling (Section 8):
- Add iOS-specific error codes to DailyNotificationErrorCodes:
  - NOTIFICATION_PERMISSION_DENIED
  - PENDING_NOTIFICATION_LIMIT_EXCEEDED
  - BG_TASK_NOT_REGISTERED
  - BG_TASK_EXECUTION_FAILED
  - BACKGROUND_REFRESH_DISABLED
- Add helper methods for iOS-specific error responses
- Enhance error handling in ReactivationManager:
  - Database errors handled gracefully (non-fatal)
  - Notification center errors handled gracefully (non-fatal)
  - Scheduling errors handled gracefully (non-fatal)
  - All errors logged, app continues normally
  - Partial results returned when operations fail
- Update plugin methods to use iOS-specific error codes:
  - getNotificationPermissionStatus uses NOTIFICATION_PERMISSION_DENIED

Integration Tests (Section 9.2):
- Add DailyNotificationRecoveryIntegrationTests:
  - Full recovery flow tests (cold start, termination)
  - Error handling tests (database, notification center, scheduling)
  - App stability tests (no crashes, concurrent operations)
  - Partial recovery tests
  - Timeout handling tests
- Test coverage:
  - 10 integration tests covering recovery scenarios
  - Error handling verification
  - App stability verification
  - Concurrent operation safety

Completes sections 8.1, 8.2, and 9.2 of iOS implementation checklist.
This commit is contained in:
Matthew
2025-12-09 02:46:13 -08:00
parent 12d8536588
commit 3649e76c49
5 changed files with 650 additions and 29 deletions

View File

@@ -26,6 +26,13 @@ struct DailyNotificationErrorCodes {
static let BACKGROUND_REFRESH_DISABLED = "background_refresh_disabled"
static let PERMISSION_DENIED = "permission_denied"
// MARK: - iOS-Specific Error Codes
static let NOTIFICATION_PERMISSION_DENIED = "notification_permission_denied"
static let PENDING_NOTIFICATION_LIMIT_EXCEEDED = "pending_notification_limit_exceeded"
static let BG_TASK_NOT_REGISTERED = "bg_task_not_registered"
static let BG_TASK_EXECUTION_FAILED = "bg_task_execution_failed"
// MARK: - Configuration Errors
static let INVALID_TIME_FORMAT = "invalid_time_format"
@@ -108,5 +115,67 @@ struct DailyNotificationErrorCodes {
message: "Notification permissions denied"
)
}
// MARK: - iOS-Specific Error Helpers
/**
* Create error response for notification permission denied
*
* @return Error response dictionary
*/
static func notificationPermissionDenied() -> [String: Any] {
return createErrorResponse(
code: NOTIFICATION_PERMISSION_DENIED,
message: "Notification permission denied. User must grant permission in Settings."
)
}
/**
* Create error response for pending notification limit exceeded
*
* @return Error response dictionary
*/
static func pendingNotificationLimitExceeded() -> [String: Any] {
return createErrorResponse(
code: PENDING_NOTIFICATION_LIMIT_EXCEEDED,
message: "Pending notification limit exceeded. iOS allows maximum 64 pending notifications."
)
}
/**
* Create error response for background task not registered
*
* @return Error response dictionary
*/
static func bgTaskNotRegistered() -> [String: Any] {
return createErrorResponse(
code: BG_TASK_NOT_REGISTERED,
message: "Background task not registered. Ensure BGTaskScheduler is properly configured."
)
}
/**
* Create error response for background task execution failed
*
* @return Error response dictionary
*/
static func bgTaskExecutionFailed() -> [String: Any] {
return createErrorResponse(
code: BG_TASK_EXECUTION_FAILED,
message: "Background task execution failed. Check Background App Refresh settings."
)
}
/**
* Create error response for background refresh disabled
*
* @return Error response dictionary
*/
static func backgroundRefreshDisabled() -> [String: Any] {
return createErrorResponse(
code: BACKGROUND_REFRESH_DISABLED,
message: "Background App Refresh is disabled. Enable it in Settings > General > Background App Refresh."
)
}
}

View File

@@ -1290,6 +1290,17 @@ public class DailyNotificationPlugin: CAPPlugin {
let status = await scheduler.checkPermissionStatus()
// Map to iOS-specific error if denied
if status == .denied {
let error = DailyNotificationErrorCodes.notificationPermissionDenied()
let errorMessage = error["message"] as? String ?? "Notification permission denied"
let errorCode = error["error"] as? String ?? DailyNotificationErrorCodes.NOTIFICATION_PERMISSION_DENIED
DispatchQueue.main.async {
call.reject(errorMessage, errorCode)
}
return
}
let result: [String: Any] = [
"authorized": status == .authorized,
"denied": status == .denied,

View File

@@ -189,14 +189,32 @@ class DailyNotificationReactivationManager {
*/
internal func detectScenario() async throws -> RecoveryScenario {
// Step 1: Check if database has notifications
let allNotifications = storage.getAllNotifications()
// Handle storage errors gracefully (non-fatal)
let allNotifications: [NotificationContent]
do {
allNotifications = storage.getAllNotifications()
} catch {
// Non-fatal: Log error and assume empty storage
NSLog("\(Self.TAG): Error getting notifications from storage (non-fatal): \(error.localizedDescription)")
return .none
}
if allNotifications.isEmpty {
return .none // First launch
}
// Step 2: Get pending notifications from UNUserNotificationCenter
let pendingRequests = try await notificationCenter.pendingNotificationRequests()
// Handle notification center errors gracefully (non-fatal)
let pendingRequests: [UNNotificationRequest]
do {
pendingRequests = try await notificationCenter.pendingNotificationRequests()
} catch {
// Non-fatal: Log error and assume no pending notifications
NSLog("\(Self.TAG): Error getting pending notifications (non-fatal): \(error.localizedDescription)")
// Return cold start as safe default - will trigger recovery
return .coldStart
}
let pendingIds = Set(pendingRequests.map { $0.identifier })
// Step 3: Get notification IDs from storage
@@ -317,7 +335,15 @@ class DailyNotificationReactivationManager {
*/
internal func detectMissedNotifications(currentTime: Date) async throws -> [NotificationContent] {
// Get all notifications from storage
let allNotifications = storage.getAllNotifications()
// Handle database/storage errors gracefully (non-fatal)
let allNotifications: [NotificationContent]
do {
allNotifications = storage.getAllNotifications()
} catch {
// Non-fatal: Log error and return empty array
NSLog("\(Self.TAG): Error getting notifications from storage (non-fatal): \(error.localizedDescription)")
return []
}
// Convert currentTime to milliseconds (Int64) for comparison
let currentTimeMs = Int64(currentTime.timeIntervalSince1970 * 1000)
@@ -368,12 +394,46 @@ class DailyNotificationReactivationManager {
*/
internal func verifyFutureNotifications() async throws -> VerificationResult {
// Get pending notifications from UNUserNotificationCenter
let pendingRequests = try await notificationCenter.pendingNotificationRequests()
// Handle notification center errors gracefully (non-fatal)
let pendingRequests: [UNNotificationRequest]
do {
pendingRequests = try await notificationCenter.pendingNotificationRequests()
} catch {
// Non-fatal: Log error and assume no pending notifications
NSLog("\(Self.TAG): Error getting pending notifications (non-fatal): \(error.localizedDescription)")
// Return verification result indicating all are missing
let currentTimeMs = Int64(Date().timeIntervalSince1970 * 1000)
let allNotifications = storage.getAllNotifications()
let futureNotifications = allNotifications.filter { $0.scheduledTime >= currentTimeMs }
let futureIds = Set(futureNotifications.map { $0.id })
return VerificationResult(
totalSchedules: futureNotifications.count,
notificationsFound: 0,
notificationsMissing: futureIds.count,
missingIds: Array(futureIds)
)
}
let pendingIds = Set(pendingRequests.map { $0.identifier })
// Get all notifications from storage that are scheduled for future
// Handle storage errors gracefully (non-fatal)
let currentTimeMs = Int64(Date().timeIntervalSince1970 * 1000)
let allNotifications = storage.getAllNotifications()
let allNotifications: [NotificationContent]
do {
allNotifications = storage.getAllNotifications()
} catch {
// Non-fatal: Log error and return empty verification result
NSLog("\(Self.TAG): Error getting notifications from storage (non-fatal): \(error.localizedDescription)")
return VerificationResult(
totalSchedules: 0,
notificationsFound: pendingIds.count,
notificationsMissing: 0,
missingIds: []
)
}
let futureNotifications = allNotifications.filter { $0.scheduledTime >= currentTimeMs }
let futureIds = Set(futureNotifications.map { $0.id })
@@ -397,14 +457,27 @@ class DailyNotificationReactivationManager {
*/
private func rescheduleMissingNotification(id: String) async throws {
// Get notification content from storage
guard let notification = storage.getNotificationContent(id: id) else {
// Handle storage errors gracefully (non-fatal)
let notification: NotificationContent?
do {
notification = storage.getNotificationContent(id: id)
} catch {
// Non-fatal: Log error and throw to be caught by caller
NSLog("\(Self.TAG): Error getting notification from storage (non-fatal): \(error.localizedDescription)")
throw ReactivationError.notificationNotFound(id: id)
}
guard let notification = notification else {
throw ReactivationError.notificationNotFound(id: id)
}
// Reschedule using scheduler
// Handle scheduling errors gracefully (non-fatal)
let success = await scheduler.scheduleNotification(notification)
if !success {
// Non-fatal: Log error and throw to be caught by caller
NSLog("\(Self.TAG): Failed to reschedule notification \(id) (non-fatal)")
throw ReactivationError.rescheduleFailed(id: id)
}
}