| 1 | import Foundation |
| 2 | import RastrilloApple |
| 3 | import RastrilloTestSupport |
| 4 | import XCTest |
| 5 | @testable import DocsKit |
| 6 | |
| 7 | /// One writable, empty document whose stream answers with `stream`. |
| 8 | private func documentServer(stream: ScriptedTransport.Step) -> ScriptedTransport { |
| 9 | ScriptedTransport { call in |
| 10 | switch (call.kind, call.method, call.path) { |
| 11 | case (.request, "GET", "/d/doc"): |
| 12 | return .respond(status: 200, body: Data(#"{"sqid":"doc","title":"Doc","guid":"doc-guid","can_write":true,"can_share":false}"#.utf8)) |
| 13 | case (.request, "GET", "/d/doc/updates"): return .respond(status: 200, headers: ["Docs-Seq": "0"]) |
| 14 | case (.stream, _, "/d/doc/stream"): return stream |
| 15 | case (.request, "POST", "/d/doc/updates"): return .respond(status: 204) |
| 16 | default: return nil |
| 17 | } |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | private func streams(_ transport: ScriptedTransport) -> Int { transport.calls.filter { $0.kind == .stream }.count } |
| 22 | |
| 23 | @MainActor private func until(_ seconds: Double, _ condition: () -> Bool) async { |
| 24 | let deadline = Date().addingTimeInterval(seconds) |
| 25 | while !condition() && Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } |
| 26 | } |
| 27 | |
| 28 | final class StreamConnectivityTests: XCTestCase { |
| 29 | /// Offline, the stream loop makes no timed attempts; when the path comes |
| 30 | /// back it reconnects at once rather than after a fixed sleep. |
| 31 | @MainActor func testStreamWaitsForThePathAndReconnectsWhenItReturns() async throws { |
| 32 | let path = ScriptedPathSource(.satisfied) |
| 33 | let connectivity = NativeConnectivity(source: path) |
| 34 | let server = documentServer(stream: .offline(.notConnectedToInternet)) |
| 35 | let model = DocsModel(client: server, connectivity: connectivity) |
| 36 | await model.open("doc") |
| 37 | await until(2) { streams(server) >= 1 } |
| 38 | path.send(.unsatisfied) |
| 39 | await until(2) { connectivity.isPathSatisfied == false } |
| 40 | try await Task.sleep(for: .milliseconds(600)) |
| 41 | let offline = streams(server) |
| 42 | try await Task.sleep(for: .milliseconds(2600)) |
| 43 | XCTAssertEqual(streams(server), offline, "The stream retried on a timer while the path was down") |
| 44 | let restored = Date() |
| 45 | path.send(.satisfied) |
| 46 | await until(2) { streams(server) > offline } |
| 47 | XCTAssertGreaterThan(streams(server), offline, "The restored path never reconnected the stream") |
| 48 | XCTAssertLessThan(Date().timeIntervalSince(restored), 1, "The reconnect waited out a sleep") |
| 49 | await model.discardAll() |
| 50 | } |
| 51 | |
| 52 | /// A stream that keeps closing at once is retried at 0.5 s, then 1 s, then |
| 53 | /// 2 s (with jitter), not at a fixed interval. |
| 54 | @MainActor func testStreamBacksOffBetweenQuickFailures() async throws { |
| 55 | let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) |
| 56 | let server = documentServer(stream: .respond(status: 200)) |
| 57 | let model = DocsModel(client: server, connectivity: connectivity) |
| 58 | await model.open("doc") |
| 59 | await until(2) { streams(server) >= 1 } |
| 60 | try await Task.sleep(for: .milliseconds(1500)) |
| 61 | let early = streams(server) |
| 62 | // Attempts at 0, then +[0.25, 0.5], +[0.5, 1], +[1, 2]: two or three by 1.5 s. |
| 63 | XCTAssertGreaterThanOrEqual(early, 2, "The first retry waited longer than the first backoff step") |
| 64 | XCTAssertLessThanOrEqual(early, 3, "Retries did not back off") |
| 65 | await model.discardAll() |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /// Monotonic time the test moves by hand; the scripted server advances it. |
| 70 | private final class FakeClock: @unchecked Sendable { |
| 71 | private let lock = NSLock() |
| 72 | private var value: TimeInterval = 1_000 |
| 73 | var now: TimeInterval { lock.withLock { value } } |
| 74 | func advance(_ seconds: TimeInterval) { lock.withLock { value += seconds } } |
| 75 | } |
| 76 | |
| 77 | final class StreamPacingTests: XCTestCase { |
| 78 | /// A 429 with Retry-After: 1 holds the stream off for a second even when |
| 79 | /// the app is foregrounded during it. |
| 80 | @MainActor func testRetryAfterSurvivesAWake() async throws { |
| 81 | let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) |
| 82 | let server = documentServer(stream: .respond(status: 429, headers: ["Retry-After": "1"])) |
| 83 | let model = DocsModel(client: server, connectivity: connectivity) |
| 84 | await model.open("doc") |
| 85 | await until(2) { streams(server) >= 1 } |
| 86 | let refused = Date() |
| 87 | try await Task.sleep(for: .milliseconds(200)) |
| 88 | connectivity.foregrounded() |
| 89 | try await Task.sleep(for: .milliseconds(500)) |
| 90 | XCTAssertEqual(streams(server), 1, "A foreground wake crossed the server's Retry-After") |
| 91 | await until(2) { streams(server) >= 2 } |
| 92 | XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(refused), 0.95) |
| 93 | await model.discardAll() |
| 94 | } |
| 95 | |
| 96 | /// Every stream attempt fails after 10 s (by the model's clock) without |
| 97 | /// ever opening. That is not a stable stream, so the backoff keeps |
| 98 | /// growing: at most three attempts in 1.6 s, where resetting after each |
| 99 | /// slow failure would retry every 0.25 to 0.5 s. |
| 100 | @MainActor func testSlowFailuresThatNeverOpenedKeepBackingOff() async throws { |
| 101 | let clock = FakeClock() |
| 102 | let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) |
| 103 | let slow = ScriptedTransport { call in |
| 104 | if call.kind == .stream { clock.advance(10); return .respond(status: 500) } |
| 105 | return nil |
| 106 | } |
| 107 | let server = documentServer(stream: .hang) |
| 108 | let model = DocsModel(transport: { SplitTransport(requests: server, streams: slow) }, connectivity: connectivity) |
| 109 | model.reconnectClock = { clock.now } |
| 110 | await model.open("doc") |
| 111 | await until(2) { streams(slow) >= 1 } |
| 112 | try await Task.sleep(for: .milliseconds(1600)) |
| 113 | XCTAssertLessThanOrEqual(streams(slow), 3, "A slow failure that never opened reset the backoff") |
| 114 | await model.discardAll() |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | /// Requests go to one transport and streams to another. |
| 119 | private struct SplitTransport: NativeTransport { |
| 120 | let requests: ScriptedTransport |
| 121 | let streams: ScriptedTransport |
| 122 | var origin: URL { requests.origin } |
| 123 | func responseDetails(_ path: String, method: String, body: Data?, contentType: String, maxBytes: Int) async throws -> NativeResponse { |
| 124 | try await requests.responseDetails(path, method: method, body: body, contentType: contentType, maxBytes: maxBytes) |
| 125 | } |
| 126 | func stream(_ path: String, onOpen: @Sendable () async throws -> Void, onLine: @Sendable (String) async throws -> Void) async throws { |
| 127 | try await streams.stream(path, onOpen: onOpen, onLine: onLine) |
| 128 | } |
| 129 | func download(_ path: String, to destination: URL) async throws { try await requests.download(path, to: destination) } |
| 130 | } |
| 131 | |