|
1 | 1 | import XCTest |
2 | 2 | @testable import ntfy_macos |
3 | 3 |
|
| 4 | +// MARK: - Mock URLProtocol for watchdog tests |
| 5 | + |
| 6 | +/// Intercepts URLSession requests and returns HTTP 200 without sending any data, |
| 7 | +/// simulating a stale connection where the server stops sending keepalives. |
| 8 | +final class HoldingURLProtocol: URLProtocol { |
| 9 | + static let requestCountLock = NSLock() |
| 10 | + nonisolated(unsafe) private static var _requestCount = 0 |
| 11 | + static var requestCount: Int { |
| 12 | + get { requestCountLock.lock(); defer { requestCountLock.unlock() }; return _requestCount } |
| 13 | + set { requestCountLock.lock(); defer { requestCountLock.unlock() }; _requestCount = newValue } |
| 14 | + } |
| 15 | + nonisolated(unsafe) static var onRequest: ((Int) -> Void)? |
| 16 | + |
| 17 | + static func reset() { |
| 18 | + requestCount = 0 |
| 19 | + onRequest = nil |
| 20 | + } |
| 21 | + |
| 22 | + override class func canInit(with request: URLRequest) -> Bool { true } |
| 23 | + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } |
| 24 | + |
| 25 | + override func startLoading() { |
| 26 | + let count = HoldingURLProtocol.requestCount + 1 |
| 27 | + HoldingURLProtocol.requestCount = count |
| 28 | + HoldingURLProtocol.onRequest?(count) |
| 29 | + |
| 30 | + // Return HTTP 200 but never send data or finish — simulates stale connection |
| 31 | + let response = HTTPURLResponse( |
| 32 | + url: request.url!, |
| 33 | + statusCode: 200, |
| 34 | + httpVersion: nil, |
| 35 | + headerFields: nil |
| 36 | + )! |
| 37 | + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) |
| 38 | + // Intentionally no didLoad or didFinishLoading — connection stays "open" |
| 39 | + } |
| 40 | + |
| 41 | + override func stopLoading() {} |
| 42 | +} |
| 43 | + |
| 44 | +// MARK: - Mock delegate |
| 45 | + |
| 46 | +final class MockNtfyDelegate: NtfyClientDelegate { |
| 47 | + var onConnect: (() -> Void)? |
| 48 | + var onDisconnect: (() -> Void)? |
| 49 | + var onError: ((Error) -> Void)? |
| 50 | + var onMessage: ((NtfyMessage) -> Void)? |
| 51 | + |
| 52 | + func ntfyClientDidConnect(_ client: NtfyClient) { onConnect?() } |
| 53 | + func ntfyClientDidDisconnect(_ client: NtfyClient) { onDisconnect?() } |
| 54 | + func ntfyClient(_ client: NtfyClient, didEncounterError error: Error) { onError?(error) } |
| 55 | + func ntfyClient(_ client: NtfyClient, didReceiveMessage message: NtfyMessage) { onMessage?(message) } |
| 56 | +} |
| 57 | + |
| 58 | +// MARK: - Helper |
| 59 | + |
| 60 | +private func makeSessionConfig() -> URLSessionConfiguration { |
| 61 | + let config = URLSessionConfiguration.ephemeral |
| 62 | + config.protocolClasses = [HoldingURLProtocol.self] |
| 63 | + return config |
| 64 | +} |
| 65 | + |
| 66 | +// MARK: - Tests |
| 67 | + |
4 | 68 | final class NtfyClientTests: XCTestCase { |
5 | 69 | // MARK: - URL Construction |
6 | 70 |
|
@@ -90,4 +154,63 @@ final class NtfyClientTests: XCTestCase { |
90 | 154 | client.disconnect() |
91 | 155 | client.disconnect() |
92 | 156 | } |
| 157 | + |
| 158 | + // MARK: - Watchdog |
| 159 | + |
| 160 | + func testWatchdogIntervalIsConfigurable() { |
| 161 | + let client = NtfyClient(serverURL: "https://ntfy.sh", topics: ["test"], watchdogInterval: 30.0) |
| 162 | + XCTAssertNotNil(client) |
| 163 | + } |
| 164 | + |
| 165 | + /// After disconnect(), the watchdog must not fire and trigger a reconnect. |
| 166 | + @MainActor func testWatchdogDoesNotFireAfterDisconnect() { |
| 167 | + HoldingURLProtocol.reset() |
| 168 | + let connectExp = expectation(description: "No reconnect after disconnect") |
| 169 | + connectExp.isInverted = true |
| 170 | + |
| 171 | + let delegate = MockNtfyDelegate() |
| 172 | + delegate.onConnect = { connectExp.fulfill() } |
| 173 | + |
| 174 | + let client = NtfyClient( |
| 175 | + serverURL: "https://ntfy.sh", topics: ["test"], |
| 176 | + watchdogInterval: 0.05, baseReconnectDelay: 0.0, |
| 177 | + urlSessionConfiguration: makeSessionConfig() |
| 178 | + ) |
| 179 | + client.delegate = delegate |
| 180 | + client.connect() |
| 181 | + client.disconnect() // Immediately cancel — watchdog must not trigger |
| 182 | + |
| 183 | + waitForExpectations(timeout: 0.3) |
| 184 | + } |
| 185 | + |
| 186 | +/// Cancelled task errors must not trigger reconnect (avoids double-reconnect when watchdog fires). |
| 187 | + @MainActor func testCancelledTaskDoesNotTriggerReconnect() { |
| 188 | + HoldingURLProtocol.reset() |
| 189 | + |
| 190 | + // Connect, then immediately disconnect — only 1 request should ever be made |
| 191 | + // (no spurious reconnect from the cancelled task's error) |
| 192 | + let initialExp = expectation(description: "Initial request received") |
| 193 | + HoldingURLProtocol.onRequest = { count in |
| 194 | + if count >= 1 { initialExp.fulfill() } |
| 195 | + } |
| 196 | + |
| 197 | + let client = NtfyClient( |
| 198 | + serverURL: "https://ntfy.sh", topics: ["test"], |
| 199 | + watchdogInterval: 60.0, baseReconnectDelay: 0.0, |
| 200 | + urlSessionConfiguration: makeSessionConfig() |
| 201 | + ) |
| 202 | + client.connect() |
| 203 | + waitForExpectations(timeout: 1.0) |
| 204 | + |
| 205 | + // Disconnect and wait — request count must stay at 1 |
| 206 | + client.disconnect() |
| 207 | + let noExtraExp = expectation(description: "No extra reconnect request") |
| 208 | + noExtraExp.isInverted = true |
| 209 | + HoldingURLProtocol.onRequest = { count in |
| 210 | + if count >= 2 { noExtraExp.fulfill() } |
| 211 | + } |
| 212 | + waitForExpectations(timeout: 0.3) |
| 213 | + |
| 214 | + XCTAssertEqual(HoldingURLProtocol.requestCount, 1) |
| 215 | + } |
93 | 216 | } |
0 commit comments