diff --git a/HomeAssistant.xcodeproj/project.pbxproj b/HomeAssistant.xcodeproj/project.pbxproj index 3c6881a27..28aab74cd 100644 --- a/HomeAssistant.xcodeproj/project.pbxproj +++ b/HomeAssistant.xcodeproj/project.pbxproj @@ -827,6 +827,7 @@ 42881BD42DDF12340079BDCB /* SwiftUI+SafeArea.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42881BD32DDF12340079BDCB /* SwiftUI+SafeArea.swift */; }; 428830EB2C6E3A8D0012373D /* WatchHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428830EA2C6E3A8D0012373D /* WatchHomeView.swift */; }; 428830ED2C6E3A9A0012373D /* WatchHomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428830EC2C6E3A9A0012373D /* WatchHomeViewModel.swift */; }; + 4288635F2EF96B2900319CF4 /* NotificationManagerLocalPushInterfaceExtension.test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4288635E2EF96B2900319CF4 /* NotificationManagerLocalPushInterfaceExtension.test.swift */; }; 428863552EF963FF00319CF4 /* CameraListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428863522EF963FF00319CF4 /* CameraListView.swift */; }; 428863562EF963FF00319CF4 /* CameraListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428863532EF963FF00319CF4 /* CameraListViewModel.swift */; }; 428863592EF9641400319CF4 /* OpenCameraListAppIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428863572EF9641400319CF4 /* OpenCameraListAppIntent.swift */; }; @@ -2395,6 +2396,7 @@ 42881BD32DDF12340079BDCB /* SwiftUI+SafeArea.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SwiftUI+SafeArea.swift"; sourceTree = ""; }; 428830EA2C6E3A8D0012373D /* WatchHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHomeView.swift; sourceTree = ""; }; 428830EC2C6E3A9A0012373D /* WatchHomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHomeViewModel.swift; sourceTree = ""; }; + 4288635E2EF96B2900319CF4 /* NotificationManagerLocalPushInterfaceExtension.test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManagerLocalPushInterfaceExtension.test.swift; sourceTree = ""; }; 428863522EF963FF00319CF4 /* CameraListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraListView.swift; sourceTree = ""; }; 428863532EF963FF00319CF4 /* CameraListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraListViewModel.swift; sourceTree = ""; }; 428863572EF9641400319CF4 /* OpenCameraListAppIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenCameraListAppIntent.swift; sourceTree = ""; }; @@ -6338,6 +6340,7 @@ B657A8FF1CA646EB00121384 /* App */ = { isa = PBXGroup; children = ( + 4288635E2EF96B2900319CF4 /* NotificationManagerLocalPushInterfaceExtension.test.swift */, 42B89EAB2E080494000224A2 /* AppConstants.test.swift */, 42646B762E0BE6F100F6B367 /* BackgroundTask.test.swift */, 11EFD3C1272642FC000AF78B /* Additions */, @@ -9060,6 +9063,7 @@ 42A47A8A2C452DB500C9B43D /* MockWebViewController.swift in Sources */, 42ACC2A72E9F9B300045A3FD /* URLExtensions.test.swift in Sources */, 42ACC2862E9E74C80045A3FD /* BaseOnboardingView.test.swift in Sources */, + 4288635F2EF96B2900319CF4 /* NotificationManagerLocalPushInterfaceExtension.test.swift in Sources */, 11A71C7324A4FC8A00D9565F /* ZoneManagerEquatableRegion.test.swift in Sources */, 429481EB2DA93FA000A8B468 /* WebViewJavascriptCommandsTests.swift in Sources */, 119C786725CF845800D41734 /* LocalizedStrings.test.swift in Sources */, diff --git a/Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift b/Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift index 89c2a1c8b..d1da315cf 100644 --- a/Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift +++ b/Sources/App/Notifications/NotificationManagerLocalPushInterfaceExtension.swift @@ -576,3 +576,47 @@ extension NotificationManagerLocalPushInterfaceExtension { } } } + +// MARK: - Testing Support + +#if DEBUG +extension NotificationManagerLocalPushInterfaceExtension { + /// Test-only access to reconnection state for verification + var testReconnectionAttempt: Int { + reconnectionAttempt + } + + /// Test-only access to check if reconnection timer is active + var testHasActiveReconnectionTimer: Bool { + reconnectionTimer != nil + } + + /// Test-only access to disconnected servers set + var testDisconnectedServers: Set> { + disconnectedServers + } + + /// Test-only access to reconnection delays for verification + var testReconnectionDelays: [TimeInterval] { + reconnectionDelays + } + + /// Test-only method to manually trigger reconnection scheduling + /// This allows tests to verify the reconnection flow without waiting for real disconnections + func testScheduleReconnection() { + scheduleReconnection() + } + + /// Test-only method to manually trigger reconnection attempt + /// This allows tests to verify the attempt counter increment and reload logic + func testAttemptReconnection() { + attemptReconnection() + } + + /// Test-only method to manually cancel reconnection + /// This allows tests to verify cleanup logic + func testCancelReconnection() { + cancelReconnection() + } +} +#endif diff --git a/Tests/App/NotificationManagerLocalPushInterfaceExtension.test.swift b/Tests/App/NotificationManagerLocalPushInterfaceExtension.test.swift new file mode 100644 index 000000000..13b095ce2 --- /dev/null +++ b/Tests/App/NotificationManagerLocalPushInterfaceExtension.test.swift @@ -0,0 +1,278 @@ +import Foundation +import HAKit +@testable import HomeAssistant +import NetworkExtension +import PromiseKit +@testable import Shared +import XCTest + +final class NotificationManagerLocalPushInterfaceExtensionTests: XCTestCase { + private var interface: NotificationManagerLocalPushInterfaceExtension! + private var fakeServers: FakeServerManager! + + override func setUp() { + super.setUp() + + fakeServers = FakeServerManager() + Current.servers = fakeServers + + interface = NotificationManagerLocalPushInterfaceExtension() + } + + override func tearDown() { + super.tearDown() + + interface = nil + } + + // MARK: - Reconnection Backoff Tests + + func testReconnectionDelaysAreCorrect() { + // Verify the reconnection delays array contains expected values: 5s, 10s, 30s + let delays = interface.testReconnectionDelays + XCTAssertEqual(delays.count, 3, "Should have 3 delay values") + XCTAssertEqual(delays[0], 5, "First delay should be 5 seconds") + XCTAssertEqual(delays[1], 10, "Second delay should be 10 seconds") + XCTAssertEqual(delays[2], 30, "Third delay should be 30 seconds (cap)") + } + + func testReconnectionAttemptCounterStartsAtZero() { + // Initial state should have no reconnection attempts + XCTAssertEqual(interface.testReconnectionAttempt, 0, "Reconnection attempt should start at 0") + XCTAssertFalse(interface.testHasActiveReconnectionTimer, "No timer should be active initially") + } + + func testScheduleReconnectionCreatesTimer() { + // Scheduling a reconnection should create an active timer + interface.testScheduleReconnection() + + XCTAssertTrue(interface.testHasActiveReconnectionTimer, "Timer should be active after scheduling") + XCTAssertEqual(interface.testReconnectionAttempt, 0, "Attempt counter should still be 0 after scheduling") + } + + func testAttemptReconnectionIncrementsCounter() { + // Attempting reconnection should increment the counter + let initialAttempt = interface.testReconnectionAttempt + interface.testAttemptReconnection() + + XCTAssertEqual( + interface.testReconnectionAttempt, + initialAttempt + 1, + "Attempt counter should increment by 1" + ) + } + + func testMultipleReconnectionAttemptsIncrementCorrectly() { + // Multiple attempts should keep incrementing + XCTAssertEqual(interface.testReconnectionAttempt, 0) + + interface.testAttemptReconnection() + XCTAssertEqual(interface.testReconnectionAttempt, 1) + + interface.testAttemptReconnection() + XCTAssertEqual(interface.testReconnectionAttempt, 2) + + interface.testAttemptReconnection() + XCTAssertEqual(interface.testReconnectionAttempt, 3) + + // Even after many attempts, counter should keep incrementing + // The delay will be capped at 30s but counter continues + } + + func testReconnectionDelaySelectionLogic() { + // This test documents the delay selection algorithm: + // delayIndex = min(reconnectionAttempt, reconnectionDelays.count - 1) + // With delays = [5, 10, 30]: + // - Attempt 0 -> index min(0, 2) = 0 -> 5s + // - Attempt 1 -> index min(1, 2) = 1 -> 10s + // - Attempt 2 -> index min(2, 2) = 2 -> 30s + // - Attempt 3+ -> index min(3+, 2) = 2 -> 30s (capped) + + let delays = interface.testReconnectionDelays + let maxIndex = delays.count - 1 + + // Simulate delay selection for different attempt counts + for attempt in 0 ..< 10 { + let delayIndex = min(attempt, maxIndex) + let expectedDelay = delays[delayIndex] + + if attempt == 0 { + XCTAssertEqual(expectedDelay, 5, "Attempt 0 should use 5s delay") + } else if attempt == 1 { + XCTAssertEqual(expectedDelay, 10, "Attempt 1 should use 10s delay") + } else { + XCTAssertEqual(expectedDelay, 30, "Attempt \(attempt) should use 30s delay (capped)") + } + } + } + + // MARK: - Timer Cancellation Tests + + func testCancelReconnectionClearsTimer() { + // Schedule a reconnection to create a timer + interface.testScheduleReconnection() + XCTAssertTrue(interface.testHasActiveReconnectionTimer, "Timer should be active") + + // Cancel should clear the timer + interface.testCancelReconnection() + XCTAssertFalse(interface.testHasActiveReconnectionTimer, "Timer should be cleared after cancellation") + } + + func testCancelReconnectionResetsAttemptCounter() { + // Increment attempt counter + interface.testAttemptReconnection() + interface.testAttemptReconnection() + XCTAssertEqual(interface.testReconnectionAttempt, 2) + + // Cancel should reset counter to 0 + interface.testCancelReconnection() + XCTAssertEqual(interface.testReconnectionAttempt, 0, "Attempt counter should be reset to 0") + } + + func testCancelReconnectionWithoutActiveTimerIsNoOp() { + // Calling cancel without an active timer should be safe + XCTAssertFalse(interface.testHasActiveReconnectionTimer) + XCTAssertEqual(interface.testReconnectionAttempt, 0) + + interface.testCancelReconnection() + + XCTAssertFalse(interface.testHasActiveReconnectionTimer) + XCTAssertEqual(interface.testReconnectionAttempt, 0) + } + + func testScheduleReconnectionCancelsExistingTimer() { + // Schedule first reconnection + interface.testScheduleReconnection() + XCTAssertTrue(interface.testHasActiveReconnectionTimer) + + // Attempt reconnection to increment counter + interface.testAttemptReconnection() + XCTAssertEqual(interface.testReconnectionAttempt, 1) + + // Schedule again - should cancel existing timer and create new one + interface.testScheduleReconnection() + XCTAssertTrue(interface.testHasActiveReconnectionTimer, "New timer should be active") + // Attempt counter should remain (not reset by schedule, only by cancel) + XCTAssertEqual(interface.testReconnectionAttempt, 1) + } + + // MARK: - State Tracking Tests + + func testDisconnectedServersSetStartsEmpty() { + // Initially, no servers should be disconnected + XCTAssertTrue(interface.testDisconnectedServers.isEmpty, "Disconnected servers set should start empty") + } + + func testDisconnectedServersTracking() { + // This test documents that disconnected servers are tracked internally + // The actual tracking happens in the status(for:) method based on sync state + // Since we can't easily mock NEAppPushManager, we verify the Set is accessible + + let initialCount = interface.testDisconnectedServers.count + XCTAssertEqual(initialCount, 0, "Should start with no disconnected servers") + + // The actual population of this set happens when: + // 1. A server's sync state becomes .unavailable + // 2. The server has an active manager + // 3. status(for:) is called + + // These conditions require NEAppPushManager which we can't easily mock in unit tests + } + + // MARK: - Integration Behavior Documentation + + func testReconnectionFlowDocumentation() { + // This test documents the expected reconnection flow: + // 1. Server becomes unavailable -> added to disconnectedServers set + // 2. scheduleReconnection() called -> timer created with appropriate delay + // 3. Timer fires -> attemptReconnection() called + // 4. attemptReconnection() increments counter and calls reloadManagersAfterSave() + // 5. If server reconnects -> removed from disconnectedServers set + // 6. If all servers reconnect -> cancelReconnection() called + // 7. cancelReconnection() clears timer and resets attempt counter + + // Verify initial state + XCTAssertEqual(interface.testReconnectionAttempt, 0) + XCTAssertFalse(interface.testHasActiveReconnectionTimer) + XCTAssertTrue(interface.testDisconnectedServers.isEmpty) + + // Simulate reconnection flow + interface.testScheduleReconnection() + XCTAssertTrue(interface.testHasActiveReconnectionTimer, "Step 2: Timer should be created") + + interface.testAttemptReconnection() + XCTAssertEqual(interface.testReconnectionAttempt, 1, "Step 3: Counter should increment") + + interface.testCancelReconnection() + XCTAssertFalse(interface.testHasActiveReconnectionTimer, "Step 7: Timer should be cleared") + XCTAssertEqual(interface.testReconnectionAttempt, 0, "Step 7: Counter should be reset") + } + + func testExponentialBackoffWithCapDocumentation() { + // Document the exponential backoff behavior + // Delays: [5, 10, 30] + // Formula: delays[min(attemptNumber, delays.count - 1)] + + let delays = interface.testReconnectionDelays + + // First attempt (0): 5 seconds + XCTAssertEqual(delays[min(0, delays.count - 1)], 5) + + // Second attempt (1): 10 seconds + XCTAssertEqual(delays[min(1, delays.count - 1)], 10) + + // Third attempt (2): 30 seconds + XCTAssertEqual(delays[min(2, delays.count - 1)], 30) + + // Fourth and subsequent attempts: capped at 30 seconds + XCTAssertEqual(delays[min(3, delays.count - 1)], 30) + XCTAssertEqual(delays[min(4, delays.count - 1)], 30) + XCTAssertEqual(delays[min(10, delays.count - 1)], 30) + } + + func testTimerBehaviorWithMultipleServers() { + // Document expected behavior with multiple servers: + // - When any server disconnects: schedule reconnection if not already scheduled + // - When a server reconnects: remove from disconnectedServers set + // - When all servers reconnect: cancel reconnection timer + // - When some servers remain disconnected: timer remains active + + // This behavior is implemented in status(for:) method and can be tested + // in integration tests with actual NEAppPushManager instances + + XCTAssertTrue(true, "Behavior documented - requires integration testing") + } + + func testRapidStateChangesHandling() { + // Document that rapid state changes are handled correctly: + // - Multiple disconnections don't create duplicate entries + // - Reconnection during active timer cancels and resets properly + // - State transitions are idempotent + + // Simulate multiple schedule/cancel cycles + for _ in 0 ..< 5 { + interface.testScheduleReconnection() + XCTAssertTrue(interface.testHasActiveReconnectionTimer) + + interface.testCancelReconnection() + XCTAssertFalse(interface.testHasActiveReconnectionTimer) + XCTAssertEqual(interface.testReconnectionAttempt, 0) + } + + // Final state should be clean + XCTAssertFalse(interface.testHasActiveReconnectionTimer) + XCTAssertEqual(interface.testReconnectionAttempt, 0) + } + + func testAttemptCounterContinuesIndefinitely() { + // Document that attempt counter continues beyond delay array length + // (delay caps at 30s but counter keeps going for tracking purposes) + + for expectedAttempt in 0 ..< 20 { + XCTAssertEqual(interface.testReconnectionAttempt, expectedAttempt) + interface.testAttemptReconnection() + } + + XCTAssertEqual(interface.testReconnectionAttempt, 20) + } +}