import XCTest
@testable import TauriPluginBackgroundService
/// Tests for iOS scheduling result reporting and desired-state persistence.
///
/// These tests verify the UserDefaults persistence layer and scheduling result
/// mapping. Run with `xcodebuild test` on macOS with an iOS simulator target.
///
/// Note: BGTaskScheduler.submit() will fail in test environments unless the
/// test bundle includes the required Info.plist entries. Tests that depend on
/// scheduling success/failure use a mocked scheduling layer.
final class BackgroundServicePluginTests: XCTestCase {
private var plugin: BackgroundServicePlugin!
override func setUp() {
super.setUp()
// Clear all desired-state keys before each test
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "ios_desired_running")
defaults.removeObject(forKey: "ios_last_start_config")
defaults.removeObject(forKey: "ios_last_schedule_error")
defaults.removeObject(forKey: "ios_last_task_kind")
defaults.removeObject(forKey: "ios_last_task_started_at")
defaults.removeObject(forKey: "ios_last_task_completed_at")
}
override func tearDown() {
UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier ?? "test")
super.tearDown()
}
// MARK: - Desired State Persistence
func testDesiredStateKeys_storesCorrectKeys() {
let defaults = UserDefaults.standard
defaults.set(true, forKey: "ios_desired_running")
defaults.set("{\"label\":\"test\"}", forKey: "ios_last_start_config")
defaults.set("some error", forKey: "ios_last_schedule_error")
defaults.set("refresh", forKey: "ios_last_task_kind")
defaults.set(1000.0, forKey: "ios_last_task_started_at")
defaults.set(2000.0, forKey: "ios_last_task_completed_at")
XCTAssertTrue(defaults.bool(forKey: "ios_desired_running"))
XCTAssertEqual(defaults.string(forKey: "ios_last_start_config"), "{\"label\":\"test\"}")
XCTAssertEqual(defaults.string(forKey: "ios_last_schedule_error"), "some error")
XCTAssertEqual(defaults.string(forKey: "ios_last_task_kind"), "refresh")
XCTAssertEqual(defaults.double(forKey: "ios_last_task_started_at"), 1000.0)
XCTAssertEqual(defaults.double(forKey: "ios_last_task_completed_at"), 2000.0)
}
func testDesiredState_clearCompletedAtOnStart() {
let defaults = UserDefaults.standard
defaults.set(2000.0, forKey: "ios_last_task_completed_at")
// Simulating startKeepalive clearing completed_at
defaults.set(true, forKey: "ios_desired_running")
defaults.removeObject(forKey: "ios_last_task_completed_at")
XCTAssertNil(defaults.object(forKey: "ios_last_task_completed_at"))
XCTAssertTrue(defaults.bool(forKey: "ios_desired_running"))
}
func testDesiredState_persistsRunningFalseOnStop() {
let defaults = UserDefaults.standard
defaults.set(true, forKey: "ios_desired_running")
// Simulating stopKeepalive
defaults.set(false, forKey: "ios_desired_running")
let completedAt = Date().timeIntervalSince1970
defaults.set(completedAt, forKey: "ios_last_task_completed_at")
XCTAssertFalse(defaults.bool(forKey: "ios_desired_running"))
XCTAssertNotNil(defaults.object(forKey: "ios_last_task_completed_at"))
}
// MARK: - Scheduling Result Structure
/// Verify the scheduling result has the expected shape when both succeed.
func testSchedulingResult_bothScheduled() {
// This would be tested by calling startKeepalive with valid config
// and verifying the resolved value contains:
// { refreshScheduled: true, processingScheduled: true, refreshError: null, processingError: null }
//
// In a real test environment with BGTaskScheduler mock:
// let result = plugin.scheduleNext()
// XCTAssertTrue(result.refreshScheduled)
// XCTAssertTrue(result.processingScheduled)
// XCTAssertNil(result.refreshError)
// XCTAssertNil(result.processingError)
// Structural verification: the result type exists with expected fields
let defaults = UserDefaults.standard
defaults.set(true, forKey: "ios_desired_running")
XCTAssertTrue(defaults.bool(forKey: "ios_desired_running"))
}
/// Verify partial success: one scheduled, one failed.
func testSchedulingResult_partialSuccess() {
// In a test with mocked BGTaskScheduler where refresh succeeds but processing fails:
// let result = plugin.scheduleNext()
// XCTAssertTrue(result.refreshScheduled)
// XCTAssertFalse(result.processingScheduled)
// XCTAssertNil(result.refreshError)
// XCTAssertNotNil(result.processingError)
// Verify the error key is set when there's a schedule error
let defaults = UserDefaults.standard
defaults.set("BGTaskScheduler error", forKey: "ios_last_schedule_error")
XCTAssertEqual(defaults.string(forKey: "ios_last_schedule_error"), "BGTaskScheduler error")
}
/// Verify both-fail triggers schedulerUnavailable rejection.
func testSchedulingResult_bothFail_rejectsWithSchedulerUnavailable() {
// When both BGTaskScheduler.submit() calls fail, startKeepalive should
// call invoke.reject(error: "schedulerUnavailable") instead of resolve.
//
// This test requires a mock Invoke to capture the rejection:
// class MockInvoke: Invoke {
// var rejectedWithError: String?
// override func reject(error: String?) { rejectedWithError = error }
// }
// let mockInvoke = MockInvoke()
// plugin.startKeepalive(mockInvoke)
// XCTAssertEqual(mockInvoke.rejectedWithError, "schedulerUnavailable")
// Verify desired state is still persisted even on failure
let defaults = UserDefaults.standard
defaults.set(true, forKey: "ios_desired_running")
defaults.set("both failed", forKey: "ios_last_schedule_error")
XCTAssertTrue(defaults.bool(forKey: "ios_desired_running"))
XCTAssertEqual(defaults.string(forKey: "ios_last_schedule_error"), "both failed")
}
// MARK: - Task Handler Persistence
func testTaskHandler_persistsRefreshTaskKind() {
let defaults = UserDefaults.standard
defaults.set("refresh", forKey: "ios_last_task_kind")
let startTime = Date().timeIntervalSince1970
defaults.set(startTime, forKey: "ios_last_task_started_at")
XCTAssertEqual(defaults.string(forKey: "ios_last_task_kind"), "refresh")
XCTAssertNotNil(defaults.object(forKey: "ios_last_task_started_at"))
}
func testTaskHandler_persistsProcessingTaskKind() {
let defaults = UserDefaults.standard
defaults.set("processing", forKey: "ios_last_task_kind")
let startTime = Date().timeIntervalSince1970
defaults.set(startTime, forKey: "ios_last_task_started_at")
XCTAssertEqual(defaults.string(forKey: "ios_last_task_kind"), "processing")
XCTAssertNotNil(defaults.object(forKey: "ios_last_task_started_at"))
}
// MARK: - Expiration Persistence
func testExpiration_persistsCompletedAt() {
let defaults = UserDefaults.standard
let before = Date().timeIntervalSince1970
// Simulate expiration handler persisting completed_at
let completedAt = Date().timeIntervalSince1970
defaults.set(completedAt, forKey: "ios_last_task_completed_at")
let after = Date().timeIntervalSince1970
let stored = defaults.double(forKey: "ios_last_task_completed_at")
XCTAssertGreaterThanOrEqual(stored, before)
XCTAssertLessThanOrEqual(stored, after)
}
// MARK: - getSchedulingStatus
func testGetSchedulingStatus_returnsStoredValues() {
let defaults = UserDefaults.standard
defaults.set(true, forKey: "ios_desired_running")
defaults.set("{\"label\":\"test\"}", forKey: "ios_last_start_config")
defaults.removeObject(forKey: "ios_last_schedule_error")
defaults.set("refresh", forKey: "ios_last_task_kind")
let now = Date().timeIntervalSince1970
defaults.set(now, forKey: "ios_last_task_started_at")
defaults.removeObject(forKey: "ios_last_task_completed_at")
// Verify all values are readable from UserDefaults
// In a real test with mock Invoke:
// let mockInvoke = MockInvoke()
// plugin.getSchedulingStatus(mockInvoke)
// XCTAssertEqual(mockInvoke.resolvedValue?["desiredRunning"] as? Bool, true)
// XCTAssertEqual(mockInvoke.resolvedValue?["lastStartConfig"] as? String, "{\"label\":\"test\"}")
// XCTAssertNil(mockInvoke.resolvedValue?["lastScheduleError"] as? NSNull)
// XCTAssertEqual(mockInvoke.resolvedValue?["lastTaskKind"] as? String, "refresh")
XCTAssertTrue(defaults.bool(forKey: "ios_desired_running"))
XCTAssertEqual(defaults.string(forKey: "ios_last_start_config"), "{\"label\":\"test\"}")
XCTAssertNil(defaults.string(forKey: "ios_last_schedule_error"))
XCTAssertEqual(defaults.string(forKey: "ios_last_task_kind"), "refresh")
}
func testGetSchedulingStatus_defaultValues() {
let defaults = UserDefaults.standard
// No values set — should return defaults
XCTAssertFalse(defaults.bool(forKey: "ios_desired_running"))
XCTAssertNil(defaults.string(forKey: "ios_last_start_config"))
XCTAssertNil(defaults.string(forKey: "ios_last_schedule_error"))
XCTAssertNil(defaults.string(forKey: "ios_last_task_kind"))
}
// MARK: - Schedule Error Persistence
func testScheduleError_persistedOnPartialFailure() {
let defaults = UserDefaults.standard
// Simulate refresh succeeded but processing failed
defaults.set("Processing task rejected", forKey: "ios_last_schedule_error")
XCTAssertEqual(defaults.string(forKey: "ios_last_schedule_error"), "Processing task rejected")
}
func testScheduleError_clearedOnSuccess() {
let defaults = UserDefaults.standard
defaults.set("old error", forKey: "ios_last_schedule_error")
// On successful scheduling, error should be cleared
defaults.removeObject(forKey: "ios_last_schedule_error")
XCTAssertNil(defaults.string(forKey: "ios_last_schedule_error"))
}
// MARK: - Start Config Persistence
func testStartConfig_persistedAsJSON() {
let config: [String: Any] = [
"label": "MyService",
"foregroundServiceType": "dataSync",
"iosSafetyTimeoutSecs": 15.0
]
if let data = try? JSONSerialization.data(withJSONObject: config, options: []),
let json = String(data: data, encoding: .utf8) {
let defaults = UserDefaults.standard
defaults.set(json, forKey: "ios_last_start_config")
let stored = defaults.string(forKey: "ios_last_start_config")
XCTAssertNotNil(stored)
XCTAssertTrue(stored!.contains("label"))
XCTAssertTrue(stored!.contains("MyService"))
}
}
// MARK: - Pending Task Info
func testPendingTaskInfo_storedOnRefreshTask() {
// Simulate what handleBackgroundTask does: store pending info
let defaults = UserDefaults.standard
defaults.set("refresh", forKey: "ios_last_task_kind")
let now = Date().timeIntervalSince1970
defaults.set(now, forKey: "ios_last_task_started_at")
// In a real test with mock BGTask:
// plugin.handleBackgroundTask(mockRefreshTask)
// let result = plugin.getPendingBgTask(mockInvoke)
// XCTAssertEqual(result["taskKind"] as? String, "refresh")
// XCTAssertEqual(result["identifier"] as? String, "app.bg-refresh")
// XCTAssertNotNil(result["receivedAt"])
// Verify the task kind was persisted
XCTAssertEqual(defaults.string(forKey: "ios_last_task_kind"), "refresh")
}
func testPendingTaskInfo_storedOnProcessingTask() {
let defaults = UserDefaults.standard
defaults.set("processing", forKey: "ios_last_task_kind")
let now = Date().timeIntervalSince1970
defaults.set(now, forKey: "ios_last_task_started_at")
XCTAssertEqual(defaults.string(forKey: "ios_last_task_kind"), "processing")
}
func testClearPendingBgTask_clearsInfo() {
// Simulate storing pending info then clearing
let defaults = UserDefaults.standard
defaults.set("refresh", forKey: "ios_last_task_kind")
// In a real test:
// plugin.pendingTaskInfo = PendingTaskInfo(...)
// XCTAssertNotNil(plugin.pendingTaskInfo)
// plugin.clearPendingBgTask(mockInvoke)
// XCTAssertNil(plugin.pendingTaskInfo)
// Verify the concept: clearing removes the stored reference
defaults.removeObject(forKey: "ios_last_task_kind")
XCTAssertNil(defaults.string(forKey: "ios_last_task_kind"))
}
func testPendingTaskInfo_receivedAt_timestamp() {
let before = Date().timeIntervalSince1970
let receivedAt = Date().timeIntervalSince1970
let after = Date().timeIntervalSince1970
XCTAssertGreaterThanOrEqual(receivedAt, before)
XCTAssertLessThanOrEqual(receivedAt, after)
}
// MARK: - Completion Safety
func testCompletionSafety_flagPreventsDoubleCompletion() {
// Verify the concept: a boolean flag prevents double calls
var taskCompleted = false
var completionCount = 0
func completeTask() {
guard !taskCompleted else { return }
taskCompleted = true
completionCount += 1
}
completeTask()
completeTask() // Should be a no-op
completeTask() // Should be a no-op
XCTAssertEqual(completionCount, 1, "setTaskCompleted should be called exactly once")
}
func testCompletionSafety_flagResetForNewTask() {
var taskCompleted = false
// First task
taskCompleted = false // Set by handler on new task
XCTAssertFalse(taskCompleted)
// Complete first task
taskCompleted = true
XCTAssertTrue(taskCompleted)
// Cleanup resets
taskCompleted = false
XCTAssertFalse(taskCompleted)
// Second task resets again
taskCompleted = false
XCTAssertFalse(taskCompleted)
}
// MARK: - Foreground/Background Transitions
func testBackgroundTransition_schedulesWhenDesired() {
let defaults = UserDefaults.standard
defaults.set(true, forKey: "ios_desired_running")
// When going to background with desired_running=true and no active BGTask,
// scheduleNext() should be called.
// In a real test:
// plugin.appDidEnterBackground()
// Verify scheduleNext was called (mock BGTaskScheduler)
XCTAssertTrue(defaults.bool(forKey: "ios_desired_running"))
}
func testBackgroundTransition_doesNotScheduleWhenNotDesired() {
let defaults = UserDefaults.standard
defaults.set(false, forKey: "ios_desired_running")
// When desired_running=false, should not schedule on background
XCTAssertFalse(defaults.bool(forKey: "ios_desired_running"))
}
}