import Foundation import RastrilloApple import RastrilloTestSupport import XCTest @testable import DocsKit /// One writable, empty document whose stream answers with `stream`. private func documentServer(stream: ScriptedTransport.Step) -> ScriptedTransport { ScriptedTransport { call in switch (call.kind, call.method, call.path) { case (.request, "GET", "/d/doc"): return .respond(status: 200, body: Data(#"{"sqid":"doc","title":"Doc","guid":"doc-guid","can_write":true,"can_share":false}"#.utf8)) case (.request, "GET", "/d/doc/updates"): return .respond(status: 200, headers: ["Docs-Seq": "0"]) case (.stream, _, "/d/doc/stream"): return stream case (.request, "POST", "/d/doc/updates"): return .respond(status: 204) default: return nil } } } private func streams(_ transport: ScriptedTransport) -> Int { transport.calls.filter { $0.kind == .stream }.count } @MainActor private func until(_ seconds: Double, _ condition: () -> Bool) async { let deadline = Date().addingTimeInterval(seconds) while !condition() && Date() < deadline { try? await Task.sleep(for: .milliseconds(10)) } } final class StreamConnectivityTests: XCTestCase { /// Offline, the stream loop makes no timed attempts; when the path comes /// back it reconnects at once rather than after a fixed sleep. @MainActor func testStreamWaitsForThePathAndReconnectsWhenItReturns() async throws { let path = ScriptedPathSource(.satisfied) let connectivity = NativeConnectivity(source: path) let server = documentServer(stream: .offline(.notConnectedToInternet)) let model = DocsModel(client: server, connectivity: connectivity) await model.open("doc") await until(2) { streams(server) >= 1 } path.send(.unsatisfied) await until(2) { connectivity.isPathSatisfied == false } try await Task.sleep(for: .milliseconds(600)) let offline = streams(server) try await Task.sleep(for: .milliseconds(2600)) XCTAssertEqual(streams(server), offline, "The stream retried on a timer while the path was down") let restored = Date() path.send(.satisfied) await until(2) { streams(server) > offline } XCTAssertGreaterThan(streams(server), offline, "The restored path never reconnected the stream") XCTAssertLessThan(Date().timeIntervalSince(restored), 1, "The reconnect waited out a sleep") await model.discardAll() } /// A stream that keeps closing at once is retried at 0.5 s, then 1 s, then /// 2 s (with jitter), not at a fixed interval. @MainActor func testStreamBacksOffBetweenQuickFailures() async throws { let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) let server = documentServer(stream: .respond(status: 200)) let model = DocsModel(client: server, connectivity: connectivity) await model.open("doc") await until(2) { streams(server) >= 1 } try await Task.sleep(for: .milliseconds(1500)) let early = streams(server) // Attempts at 0, then +[0.25, 0.5], +[0.5, 1], +[1, 2]: two or three by 1.5 s. XCTAssertGreaterThanOrEqual(early, 2, "The first retry waited longer than the first backoff step") XCTAssertLessThanOrEqual(early, 3, "Retries did not back off") await model.discardAll() } } /// Monotonic time the test moves by hand; the scripted server advances it. private final class FakeClock: @unchecked Sendable { private let lock = NSLock() private var value: TimeInterval = 1_000 var now: TimeInterval { lock.withLock { value } } func advance(_ seconds: TimeInterval) { lock.withLock { value += seconds } } } final class StreamPacingTests: XCTestCase { /// A 429 with Retry-After: 1 holds the stream off for a second even when /// the app is foregrounded during it. @MainActor func testRetryAfterSurvivesAWake() async throws { let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) let server = documentServer(stream: .respond(status: 429, headers: ["Retry-After": "1"])) let model = DocsModel(client: server, connectivity: connectivity) await model.open("doc") await until(2) { streams(server) >= 1 } let refused = Date() try await Task.sleep(for: .milliseconds(200)) connectivity.foregrounded() try await Task.sleep(for: .milliseconds(500)) XCTAssertEqual(streams(server), 1, "A foreground wake crossed the server's Retry-After") await until(2) { streams(server) >= 2 } XCTAssertGreaterThanOrEqual(Date().timeIntervalSince(refused), 0.95) await model.discardAll() } /// Every stream attempt fails after 10 s (by the model's clock) without /// ever opening. That is not a stable stream, so the backoff keeps /// growing: at most three attempts in 1.6 s, where resetting after each /// slow failure would retry every 0.25 to 0.5 s. @MainActor func testSlowFailuresThatNeverOpenedKeepBackingOff() async throws { let clock = FakeClock() let connectivity = NativeConnectivity(source: ScriptedPathSource(.satisfied)) let slow = ScriptedTransport { call in if call.kind == .stream { clock.advance(10); return .respond(status: 500) } return nil } let server = documentServer(stream: .hang) let model = DocsModel(transport: { SplitTransport(requests: server, streams: slow) }, connectivity: connectivity) model.reconnectClock = { clock.now } await model.open("doc") await until(2) { streams(slow) >= 1 } try await Task.sleep(for: .milliseconds(1600)) XCTAssertLessThanOrEqual(streams(slow), 3, "A slow failure that never opened reset the backoff") await model.discardAll() } } /// Requests go to one transport and streams to another. private struct SplitTransport: NativeTransport { let requests: ScriptedTransport let streams: ScriptedTransport var origin: URL { requests.origin } func responseDetails(_ path: String, method: String, body: Data?, contentType: String, maxBytes: Int) async throws -> NativeResponse { try await requests.responseDetails(path, method: method, body: body, contentType: contentType, maxBytes: maxBytes) } func stream(_ path: String, onOpen: @Sendable () async throws -> Void, onLine: @Sendable (String) async throws -> Void) async throws { try await streams.stream(path, onOpen: onOpen, onLine: onLine) } func download(_ path: String, to destination: URL) async throws { try await requests.download(path, to: destination) } }