oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import RastrilloApple
3import RastrilloTestSupport
4import XCTest
5@testable import DocsKit
6
7/// One writable, empty document whose stream answers with `stream`.
8private 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
21private 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
28final 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.
70private 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
77final 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.
119private 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