// // DailyNotificationBackgroundTaskTestHarness.swift // DailyNotificationPlugin // // Test harness for BGTaskScheduler prefetch functionality // Reference implementation demonstrating task registration, scheduling, and handling // // See: doc/test-app-ios/IOS_PREFETCH_TESTING.md for testing procedures // import Foundation import BackgroundTasks import UIKit import os.log /// Minimal BGTaskScheduler test harness for DailyNotificationPlugin prefetch testing /// /// This is a reference implementation demonstrating: /// - Task registration /// - Task scheduling /// - Task handler implementation /// - Expiration handling /// - Completion reporting /// /// **Usage:** /// - Reference this when implementing actual prefetch logic in `DailyNotificationBackgroundTaskManager.swift` /// - Use in test app for debugging BGTaskScheduler behavior /// - See `doc/test-app-ios/IOS_PREFETCH_TESTING.md` for comprehensive testing guide /// /// **Info.plist Requirements:** /// ```xml /// BGTaskSchedulerPermittedIdentifiers /// /// com.timesafari.dailynotification.fetch /// /// ``` /// /// **Background Modes (Xcode Capabilities):** /// - Background fetch /// - Background processing (if using BGProcessingTask) class DailyNotificationBackgroundTaskTestHarness { // MARK: - Constants static let prefetchTaskIdentifier = "com.timesafari.dailynotification.fetch" // MARK: - Structured Logging /// OSLog categories for structured logging (iOS 13.0+ compatible) private static let pluginLog = OSLog(subsystem: "com.timesafari.dailynotification", category: "plugin") private static let fetchLog = OSLog(subsystem: "com.timesafari.dailynotification", category: "fetch") private static let schedulerLog = OSLog(subsystem: "com.timesafari.dailynotification", category: "scheduler") private static let storageLog = OSLog(subsystem: "com.timesafari.dailynotification", category: "storage") /// Log telemetry snapshot for validation static func logTelemetrySnapshot(prefix: String = "DNP-") { // Phase 2: Capture telemetry counters from structured logs os_log("[DNP-FETCH] Telemetry snapshot: %{public}@prefetch_scheduled_total, %{public}@prefetch_executed_total, %{public}@prefetch_success_total", log: fetchLog, type: .info, prefix, prefix, prefix) } // MARK: - Registration /// Register BGTaskScheduler task handler /// /// Call this in AppDelegate.application(_:didFinishLaunchingWithOptions:) /// before app finishes launching. static func registerBackgroundTasks() { BGTaskScheduler.shared.register( forTaskWithIdentifier: prefetchTaskIdentifier, using: nil ) { task in // This closure is called when the task is launched by the system handlePrefetchTask(task: task as! BGAppRefreshTask) } print("[DNP-FETCH] Registered BGTaskScheduler with id=\(prefetchTaskIdentifier)") } // MARK: - Scheduling /// Schedule a BGAppRefreshTask for prefetch /// /// **Validation:** /// - Ensures earliestBeginDate is at least 60 seconds in future (iOS requirement) /// - Cancels any existing pending task for this notification (one active task rule) /// - Handles simulator limitations gracefully (Code=1 error is expected) /// /// - Parameter earliestOffsetSeconds: Seconds from now when task can begin /// - Parameter notificationId: Optional notification ID to cancel existing task /// - Returns: true if scheduling succeeded, false otherwise @discardableResult static func schedulePrefetchTask(earliestOffsetSeconds: TimeInterval, notificationId: String? = nil) -> Bool { // Validate minimum lead time (iOS requires at least 60 seconds) guard earliestOffsetSeconds >= 60 else { print("[DNP-FETCH] ERROR: earliestOffsetSeconds must be >= 60 (iOS requirement)") return false } // One Active Task Rule: Cancel any existing pending task for this notification if let notificationId = notificationId { cancelPendingTask(for: notificationId) } let request = BGAppRefreshTaskRequest(identifier: prefetchTaskIdentifier) request.earliestBeginDate = Date(timeIntervalSinceNow: earliestOffsetSeconds) do { try BGTaskScheduler.shared.submit(request) os_log("[DNP-FETCH] BGAppRefreshTask scheduled (earliestBeginDate=%{public}@)", log: fetchLog, type: .info, String(describing: request.earliestBeginDate)) return true } catch { // Handle simulator limitation (Code=1 is expected on simulator) if let nsError = error as NSError?, nsError.domain == "BGTaskSchedulerErrorDomain", nsError.code == 1 { os_log("[DNP-FETCH] BGTask scheduling failed on simulator (expected): Code=1 notPermitted", log: fetchLog, type: .default) print("[DNP-FETCH] NOTE: BGTaskScheduler doesn't work on simulator - this is expected. Use Xcode → Debug → Simulate Background Fetch for testing.") return false } os_log("[DNP-FETCH] Failed to schedule BGAppRefreshTask: %{public}@", log: fetchLog, type: .error, error.localizedDescription) print("[DNP-FETCH] Failed to schedule BGAppRefreshTask: \(error)") return false } } /// Cancel pending task for a specific notification (one active task rule) private static func cancelPendingTask(for notificationId: String) { // Get all pending tasks BGTaskScheduler.shared.getPendingTaskRequests { requests in for request in requests { if request.identifier == prefetchTaskIdentifier { // In real implementation, check if this request matches the notificationId // For now, cancel all prefetch tasks (Phase 1: single notification) BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: prefetchTaskIdentifier) os_log("[DNP-FETCH] Cancelled existing pending task for notificationId=%{public}@", log: fetchLog, type: .info, notificationId) } } } } /// Verify only one active task is pending (debug helper) static func verifyOneActiveTask() -> Bool { var pendingCount = 0 let semaphore = DispatchSemaphore(value: 0) BGTaskScheduler.shared.getPendingTaskRequests { requests in pendingCount = requests.filter { $0.identifier == prefetchTaskIdentifier }.count semaphore.signal() } semaphore.wait() if pendingCount > 1 { os_log("[DNP-FETCH] WARNING: Multiple pending tasks detected (%d) - expected 1", log: fetchLog, type: .default, pendingCount) return false } return true } // MARK: - Time Warp Simulation (Testing) /// Simulate time warp for accelerated testing /// /// Useful to accelerate DST, T-Lead, and cache TTL tests without waiting in real time. /// - Parameter minutesForward: Number of minutes to advance simulated time static func simulateTimeWarp(minutesForward: Int) { let timeWarpOffset = TimeInterval(minutesForward * 60) // Store time warp offset in UserDefaults for test harness use UserDefaults.standard.set(timeWarpOffset, forKey: "DNP_TimeWarpOffset") print("[DNP-FETCH] Time warp simulated: +\(minutesForward) minutes") } /// Get current time with time warp applied (testing only) static func getWarpedTime() -> Date { let offset = UserDefaults.standard.double(forKey: "DNP_TimeWarpOffset") return Date().addingTimeInterval(offset) } // MARK: - Force Reschedule /// Force reschedule all BGTasks and notifications /// /// Forces re-registration of BGTasks and notifications. /// Useful when testing repeated failures or BGTask recovery behavior. static func forceRescheduleAll() { print("[DNP-FETCH] Force rescheduling all tasks and notifications") // Cancel existing tasks BGTaskScheduler.shared.cancelAllTaskRequests() // Re-register and reschedule registerBackgroundTasks() // Trigger reschedule logic (implementation-specific) } // MARK: - Handler /// Handle BGAppRefreshTask execution /// /// **Apple Best Practice Pattern:** /// 1. Schedule next task immediately (at start of execution) /// 2. Initiate async work /// 3. Mark task as complete /// 4. Use expiration handler to cancel if needed /// /// This is called by the system when the background task is launched. /// Replace PrefetchOperation with your actual prefetch logic. private static func handlePrefetchTask(task: BGAppRefreshTask) { os_log("[DNP-FETCH] BGTask handler invoked (task.identifier=%{public}@)", log: fetchLog, type: .info, task.identifier) // STEP 1: Schedule the next task IMMEDIATELY (Apple best practice) // This ensures continuity even if app is terminated shortly after // In real implementation, calculate next schedule based on notification time schedulePrefetchTask(earliestOffsetSeconds: 60 * 30) // 30 minutes later, for example // Define the work let queue = OperationQueue() queue.maxConcurrentOperationCount = 1 let operation = PrefetchOperation() var taskCompleted = false // STEP 4: Set expiration handler (called if iOS terminates task early, ~30 seconds) task.expirationHandler = { os_log("[DNP-FETCH] Task expired - cancelling operations", log: fetchLog, type: .default) operation.cancel() // Ensure task is marked complete even on expiration if !taskCompleted { taskCompleted = true task.setTaskCompleted(success: false) os_log("[DNP-FETCH] Task marked complete (success=false) due to expiration", log: fetchLog, type: .info) } } // STEP 2: Initiate async work // STEP 3: Mark task as complete when done operation.completionBlock = { let success = !operation.isCancelled && !operation.isFailed // Ensure setTaskCompleted is called exactly once if !taskCompleted { taskCompleted = true task.setTaskCompleted(success: success) os_log("[DNP-FETCH] Task marked complete (success=%{public}@)", log: fetchLog, type: .info, success ? "true" : "false") } else { os_log("[DNP-FETCH] WARNING: Attempted to complete task twice", log: fetchLog, type: .default) } } queue.addOperation(operation) } } // MARK: - Prefetch Operation /// Simple Operation example for testing /// /// Replace this with your actual prefetch logic: /// - HTTP fetch from TimeSafari API /// - JWT signing /// - ETag validation /// - Content caching /// - Error handling class PrefetchOperation: Operation { var isFailed = false private static let fetchLog = OSLog(subsystem: "com.timesafari.dailynotification", category: "fetch") override func main() { if isCancelled { return } os_log("[DNP-FETCH] PrefetchOperation: starting fetch...", log: Self.fetchLog, type: .info) // Simulate some work // In real implementation, this would be: // - Make HTTP request // - Parse response // - Validate TTL and scheduled_for match notificationTime // - Cache content (ensure persisted before completion) // - Update database // - Handle errors (network, auth, etc.) // Simulate network delay Thread.sleep(forTimeInterval: 2) if isCancelled { return } // Simulate success/failure (in real code, check HTTP response) let success = true // Replace with actual fetch result if success { os_log("[DNP-FETCH] PrefetchOperation: fetch success", log: Self.fetchLog, type: .info) // In real implementation: persist cache here before completion } else { isFailed = true os_log("[DNP-FETCH] PrefetchOperation: fetch failed", log: Self.fetchLog, type: .error) } } } // MARK: - AppDelegate Integration Example /* Example integration in AppDelegate.swift: import UIKit import BackgroundTasks @main class AppDelegate: UIResponder, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { // Register background tasks BEFORE app finishes launching DailyNotificationBackgroundTaskTestHarness.registerBackgroundTasks() // Schedule initial task (for testing) DailyNotificationBackgroundTaskTestHarness.schedulePrefetchTask(earliestOffsetSeconds: 5 * 60) // 5 minutes return true } } */ // MARK: - Testing in Simulator /* To test in simulator: 1. Run app in Xcode 2. Background the app (Home button / Cmd+Shift+H) 3. In Xcode menu: - Debug → Simulate Background Fetch, or - Debug → Simulate Background Refresh 4. Check console logs for [DNP-FETCH] messages Expected logs: - [DNP-FETCH] Registered BGTaskScheduler with id=... - [DNP-FETCH] BGAppRefreshTask scheduled (earliestBeginDate=...) - [DNP-FETCH] BGTask handler invoked (task.identifier=...) - [DNP-FETCH] PrefetchOperation: starting fake fetch... - [DNP-FETCH] PrefetchOperation: finished fake fetch. - [DNP-FETCH] Task completionBlock (success=true) */ // MARK: - Testing on Real Device /* To test on real device: 1. Install app on iPhone 2. Enable Background App Refresh in Settings → [Your App] 3. Schedule a notification with prefetch 4. Lock device and leave idle (plugged in for best results) 5. Monitor logs via: - Xcode → Devices & Simulators → device → open console - Or os_log aggregator / remote logging Note: Real device timing is heuristic, not deterministic. iOS will run the task when it determines it's appropriate, not necessarily at the exact earliestBeginDate. */