| 1 | import Foundation |
| 2 | import XCTest |
| 3 | import RastrilloNative |
| 4 | @testable import RastrilloApple |
| 5 | |
| 6 | @MainActor final class ReconnectTests: XCTestCase { |
| 7 | private func connectivity(_ status: NativePath.Status = .satisfied) async -> (NativeConnectivity, FakePathSource) { |
| 8 | let source = FakePathSource() |
| 9 | let connectivity = NativeConnectivity(source: source) |
| 10 | source.send(status) |
| 11 | for _ in 0..<200 where connectivity.path == nil { try? await Task.sleep(nanoseconds: 5_000_000) } |
| 12 | return (connectivity, source) |
| 13 | } |
| 14 | |
| 15 | /// A Retry-After of 1 s holds even when the app is foregrounded and the |
| 16 | /// path changes during it: waking must not cross the server's deadline. |
| 17 | func testRetryAfterSurvivesConnectivityWakes() async { |
| 18 | let (connectivity, source) = await connectivity() |
| 19 | let reconnect = NativeReconnect(connectivity: connectivity, backoff: Backoff(random: { $0.lowerBound })) |
| 20 | reconnect.failed(NativeClientError.rateLimited(retryAfter: 1)) |
| 21 | let start = Date() |
| 22 | var finished: Date? |
| 23 | let pausing = Task { @MainActor in await reconnect.pause(); finished = Date() } |
| 24 | try? await Task.sleep(nanoseconds: 150_000_000) |
| 25 | connectivity.foregrounded() |
| 26 | source.send(.requiresConnection) |
| 27 | try? await Task.sleep(nanoseconds: 100_000_000) |
| 28 | source.send(.satisfied) |
| 29 | try? await Task.sleep(nanoseconds: 300_000_000) |
| 30 | XCTAssertNil(finished, "A wake crossed the server's Retry-After") |
| 31 | await pausing.value |
| 32 | XCTAssertGreaterThanOrEqual(finished?.timeIntervalSince(start) ?? 0, 0.95) |
| 33 | } |
| 34 | |
| 35 | /// After the deadline, the pause still waits for the path if it is down. |
| 36 | func testRetryAfterThenWaitsForThePath() async { |
| 37 | let (connectivity, source) = await connectivity() |
| 38 | let reconnect = NativeReconnect(connectivity: connectivity) |
| 39 | reconnect.failed(NativeClientError.rateLimited(retryAfter: 0.2)) |
| 40 | source.send(.unsatisfied) |
| 41 | var finished = false |
| 42 | let pausing = Task { @MainActor in await reconnect.pause(); finished = true } |
| 43 | try? await Task.sleep(nanoseconds: 1_000_000_000) |
| 44 | XCTAssertFalse(finished, "It retried with the path down") |
| 45 | source.send(.satisfied) |
| 46 | await pausing.value |
| 47 | XCTAssertTrue(finished) |
| 48 | } |
| 49 | |
| 50 | /// The stable-stream clock starts at `opened()`: an attempt that spent |
| 51 | /// longer than 5 s failing, and never opened, does not reset the backoff. |
| 52 | func testStableLifetimeIsTimedFromOpen() async { |
| 53 | let (connectivity, _) = await connectivity() |
| 54 | var clock: TimeInterval = 0 |
| 55 | let reconnect = NativeReconnect(connectivity: connectivity, backoff: Backoff(random: { $0.lowerBound }), now: { clock }) |
| 56 | let first = await reconnect.pause() // 0.25 |
| 57 | clock += 10 // a 10 s refusal, never opened |
| 58 | let second = await reconnect.pause() |
| 59 | XCTAssertEqual(second, first * 2, "A slow failure counted as a stable stream") |
| 60 | reconnect.opened() |
| 61 | clock += 6 // open for 6 s, then closed |
| 62 | let third = await reconnect.pause() |
| 63 | XCTAssertEqual(third, first, "A stream open longer than 5 s must reset the backoff") |
| 64 | } |
| 65 | |
| 66 | /// Without a Retry-After, a wake still cuts the backoff short. |
| 67 | func testWakeCutsAnOrdinaryBackoffShort() async { |
| 68 | let (connectivity, _) = await connectivity() |
| 69 | let reconnect = NativeReconnect(connectivity: connectivity, backoff: Backoff(random: { _ in 30 })) |
| 70 | let start = Date() |
| 71 | let pausing = Task { await reconnect.pause() } |
| 72 | try? await Task.sleep(nanoseconds: 100_000_000) |
| 73 | connectivity.foregrounded() |
| 74 | _ = await pausing.value |
| 75 | XCTAssertLessThan(Date().timeIntervalSince(start), 2) |
| 76 | } |
| 77 | } |
| 78 | |