rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import RastrilloNative
3import RastrilloTestSupport
4import XCTest
5@testable import RastrilloApple
6
7@MainActor private func settle(until condition: () async -> Bool, seconds: Double = 3) async {
8 let deadline = Date().addingTimeInterval(seconds)
9 while !(await condition()) && Date() < deadline { try? await Task.sleep(nanoseconds: 5_000_000) }
10}
11
12/// A send that POSTs each entry's payload, in order, as one request per batch.
13@MainActor private func poster(_ calls: @escaping @MainActor (String, [OutboxEntry]) -> Void = { _, _ in }) -> OutboxDrainer.Send {
14 { lane, entries, transport in
15 calls(lane, entries)
16 let body = entries.map { String(decoding: $0.payload, as: UTF8.self) }.joined(separator: ",")
17 let reply = try await transport.responseDetails("/\(lane)", method: "POST", body: Data(body.utf8))
18 try reply.requireSuccess()
19 return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, .sent) })
20 }
21}
22
23@MainActor final class OutboxDrainerTests: XCTestCase {
24 private func connectivity(_ status: NativePath.Status = .satisfied) async -> (NativeConnectivity, ScriptedPathSource) {
25 let source = ScriptedPathSource(status)
26 let connectivity = NativeConnectivity(source: source)
27 await settle { connectivity.path?.status == status }
28 return (connectivity, source)
29 }
30
31 private func outbox(_ lanes: [(String, String)]) async throws -> Outbox {
32 let outbox = Outbox(store: MemoryBlobStore(), account: "a")
33 for (lane, text) in lanes { try await outbox.enqueue(lane: lane, kind: "u", payload: Data(text.utf8)) }
34 return outbox
35 }
36
37 private func bodies(_ transport: ScriptedTransport) -> [String] {
38 transport.committed.map { String(decoding: $0.body ?? Data(), as: UTF8.self) }
39 }
40
41 func testThrowingLaneDoesNotStarveOtherLanesAndEventuallyHolds() async throws {
42 struct Broken: Error {}
43 let (connectivity, _) = await connectivity()
44 let outbox = try await outbox([("a", "broken"), ("b", "good"), ("c", "good")])
45 let transport = ScriptedTransport([])
46 var calls: [String] = []
47 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { lane, entries, _ in
48 calls.append(lane)
49 if lane == "a" { throw Broken() }
50 return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, .sent) })
51 }
52 await drainer.drain()
53 XCTAssertEqual(calls, ["a", "b", "c"])
54 let remaining = await outbox.summary
55 XCTAssertEqual(remaining.lanes.count, 1)
56 await drainer.drain(); await drainer.drain()
57 let held = await outbox.entries(lane: "a")
58 XCTAssertEqual(held.first?.hold, Hold(.rejected("send")))
59 await drainer.drain()
60 XCTAssertEqual(calls.filter { $0 == "a" }.count, 3, "Three consecutive throws hold the lane instead of spinning")
61 }
62
63 func testConcurrentDrainWaitsForTheReservedBatch() async throws {
64 let (connectivity, _) = await connectivity()
65 let outbox = try await outbox([("doc:a", "1")])
66 let transport = ScriptedTransport([])
67 var release: CheckedContinuation<Void, Never>?
68 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { _, entries, _ in
69 await withCheckedContinuation { release = $0 }
70 return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, .sent) })
71 }
72 let first = Task { await drainer.drain() }
73 await settle { release != nil }
74 var finished = false
75 let second = Task { let outcome = await drainer.drain(); finished = true; return outcome }
76 try await Task.sleep(for: .milliseconds(30))
77 XCTAssertFalse(finished, "A concurrent close-save cannot report idle before acknowledgement")
78 release?.resume()
79 let outcomes = await (first.value, second.value)
80 XCTAssertEqual(outcomes.0, .idle); XCTAssertEqual(outcomes.1, .idle)
81 let summary = await outbox.summary
82 XCTAssertTrue(summary.isEmpty)
83 }
84
85 func testUnavailableStoreKeepsTheLastKnownStatusUntilUnlock() async throws {
86 let (connectivity, _) = await connectivity(.unsatisfied)
87 let disk = FaultingBlobStore()
88 let outbox = Outbox(store: disk, account: "a")
89 try await outbox.enqueue(lane: "doc:a", kind: "update", payload: Data([1]))
90 let drainer = OutboxDrainer(outbox: outbox, transport: { ScriptedTransport([]) }, connectivity: connectivity, send: poster())
91 await drainer.drain()
92 XCTAssertEqual(drainer.status(), .offline(pending: 1))
93 disk.unavailable = true
94 // Force an I/O operation even after a successful initial load.
95 do { try await outbox.enqueue(lane: "doc:a", kind: "update", payload: Data([2])); XCTFail("Locked store must refuse enqueue") }
96 catch BlobStoreError.unavailable { }
97 await drainer.drain()
98 XCTAssertEqual(drainer.status(), .offline(pending: 1), "Unavailable keeps the known status; it is not lost")
99 XCTAssertEqual(drainer.status(localFailure: .cannotSaveOnDevice), .failed(pending: 1, reason: .cannotSaveOnDevice), "The app still reports undurable input explicitly")
100 disk.unavailable = false
101 await drainer.drain()
102 XCTAssertEqual(drainer.status(), .offline(pending: 1))
103 XCTAssertEqual(disk.committed.contents.count, 1)
104 }
105
106 func testDrainsEachLaneInOrderAndAcknowledges() async throws {
107 let (connectivity, _) = await connectivity()
108 let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)])
109 let outbox = try await outbox([("doc:a", "1"), ("doc:b", "x"), ("doc:a", "2")])
110 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
111 let outcome = await drainer.drain()
112 XCTAssertEqual(outcome, .idle)
113 XCTAssertEqual(transport.committed.map(\.path), ["/doc:a", "/doc:b"])
114 XCTAssertEqual(bodies(transport), ["1,2", "x"], "One batch per lane, FIFO")
115 let summary = await outbox.summary
116 XCTAssertTrue(summary.isEmpty)
117 XCTAssertEqual(drainer.status(), .saved)
118 }
119
120 func testBatchesAreCutByBytes() async throws {
121 let (connectivity, _) = await connectivity()
122 let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)])
123 let outbox = try await outbox([("doc:a", "aaaa"), ("doc:a", "bbbb"), ("doc:a", "cccc")])
124 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, maxBatchBytes: 8, send: poster())
125 await drainer.drain()
126 XCTAssertEqual(bodies(transport), ["aaaa,bbbb", "cccc"])
127 }
128
129 func testAPathDownMakesNoAttemptAndWaits() async throws {
130 let (connectivity, _) = await connectivity(.unsatisfied)
131 let transport = ScriptedTransport([])
132 let outbox = try await outbox([("doc:a", "1")])
133 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
134 let outcome = await drainer.drain()
135 XCTAssertEqual(outcome, .waitingForNetwork)
136 XCTAssertEqual(transport.calls.count, 0, "An offline phone makes no attempts")
137 XCTAssertEqual(drainer.status(), .offline(pending: 1))
138 }
139
140 func testOfflineMidSendWithThePathDownWaitsForTheNetwork() async throws {
141 let (connectivity, source) = await connectivity()
142 let outbox = try await outbox([("doc:a", "1")])
143 let transport = ScriptedTransport(responder: { _ in
144 source.send(.unsatisfied)
145 return .offline(.notConnectedToInternet)
146 })
147 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
148 await settle { connectivity.isPathSatisfied }
149 let first = await drainer.drain()
150 await settle { !connectivity.isPathSatisfied }
151 // Whether the path update landed before or after the classification,
152 // the next drain must not try again while the path is down.
153 let second = await drainer.drain()
154 XCTAssertEqual(second, .waitingForNetwork, "first was \(first)")
155 XCTAssertEqual(transport.calls.count, 1)
156 }
157
158 /// M2: a lost response or timeout while the path stays up backs off and
159 /// retries the same batch; it does not wait for a path change.
160 func testALostResponseOnAWorkingPathBacksOffAndResendsTheSameBatch() async throws {
161 let (connectivity, _) = await connectivity()
162 let transport = ScriptedTransport([.commitThenLoseResponse(), .respond(status: 204)])
163 let outbox = try await outbox([("doc:a", "1"), ("doc:a", "2")])
164 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
165 let first = await drainer.drain()
166 guard case .backingOff(let delay) = first else { return XCTFail("Expected a backoff, got \(first)") }
167 XCTAssertGreaterThan(delay, 0)
168 XCTAssertEqual(drainer.phase, .backingOff)
169 XCTAssertEqual(drainer.status(), .retrying(pending: 1))
170 let kept = await outbox.entries(lane: "doc:a")
171 XCTAssertEqual(kept.count, 2, "Nothing is acknowledged that the drainer did not see acknowledged")
172 let outcome1 = await drainer.drain()
173 XCTAssertEqual(outcome1, .idle)
174 XCTAssertEqual(bodies(transport), ["1,2", "1,2"], "The same batch, sent again")
175 }
176
177 func testAnEndedSessionStopsEveryLaneAndHoldsNothing() async throws {
178 let (connectivity, _) = await connectivity()
179 let transport = ScriptedTransport([.respond(status: 401), .respond(status: 204), .respond(status: 204)])
180 let outbox = try await outbox([("doc:a", "1"), ("doc:b", "2")])
181 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
182 let outcome2 = await drainer.drain()
183 XCTAssertEqual(outcome2, .sessionEnded)
184 XCTAssertEqual(transport.calls.count, 1, "Every lane stops")
185 let summary = await outbox.summary
186 XCTAssertEqual(summary.held, 0, "Never hold or drop on 401")
187 XCTAssertEqual(summary.pending, 2)
188 XCTAssertEqual(drainer.status(), .needsSignIn(pending: 2))
189 let outcome3 = await drainer.drain()
190 XCTAssertEqual(outcome3, .sessionEnded, "It stays stopped until signed in again")
191 drainer.sessionRestored()
192 let outcome4 = await drainer.drain()
193 XCTAssertEqual(outcome4, .idle)
194 XCTAssertEqual(transport.calls.count, 3)
195 }
196
197 func testNoSessionIsAnEndedSession() async throws {
198 let (connectivity, _) = await connectivity()
199 let outbox = try await outbox([("doc:a", "1")])
200 let drainer = OutboxDrainer(outbox: outbox, transport: { nil }, connectivity: connectivity, send: poster())
201 let outcome5 = await drainer.drain()
202 XCTAssertEqual(outcome5, .sessionEnded)
203 }
204
205 func testRefusalsHoldTheirLaneAndOtherLanesKeepDraining() async throws {
206 let (connectivity, _) = await connectivity()
207 let transport = ScriptedTransport { call in
208 switch call.path {
209 case "/doc:gone": return .respond(status: 403)
210 case "/doc:big": return .respond(status: 413)
211 case "/doc:bad": return .respond(status: 400)
212 default: return .respond(status: 204)
213 }
214 }
215 let outbox = try await outbox([("doc:gone", "1"), ("doc:big", "2"), ("doc:bad", "3"), ("doc:ok", "4")])
216 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
217 let outcome6 = await drainer.drain()
218 XCTAssertEqual(outcome6, .idle)
219 let summary = await outbox.summary
220 XCTAssertEqual(summary.lanes["doc:gone"]?.firstHold, .accessLost)
221 XCTAssertEqual(summary.lanes["doc:big"]?.firstHold, .tooLarge)
222 XCTAssertEqual(summary.lanes["doc:bad"]?.firstHold, .rejected("400"))
223 XCTAssertNil(summary.lanes["doc:ok"], "Other lanes are not stopped by a hold")
224 XCTAssertEqual(transport.calls.count, 4, "A held lane is not retried")
225 XCTAssertEqual(drainer.status(lane: "doc:gone"), .failed(pending: 1, reason: .held(.accessLost)))
226 XCTAssertEqual(drainer.status(lane: "doc:ok"), .saved)
227 }
228
229 func testRateLimitingIsAFloorOnTheDelay() async throws {
230 let (connectivity, _) = await connectivity()
231 let transport = ScriptedTransport([.respond(status: 429, headers: ["retry-after": "30"])])
232 let outbox = try await outbox([("doc:a", "1")])
233 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
234 let outcome = await drainer.drain()
235 guard case .rateLimited(let delay) = outcome else { return XCTFail("Expected rate limiting, got \(outcome)") }
236 XCTAssertGreaterThanOrEqual(delay, 30)
237 }
238
239 func testServerErrorsBackOff() async throws {
240 let (connectivity, _) = await connectivity()
241 let transport = ScriptedTransport([.respond(status: 502)])
242 let outbox = try await outbox([("doc:a", "1")])
243 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
244 let backedOff = await drainer.drain()
245 guard case .backingOff = backedOff else { return XCTFail("Expected a backoff") }
246 let summary = await outbox.summary
247 XCTAssertEqual(summary.pending, 1)
248 XCTAssertEqual(summary.held, 0)
249 }
250
251 /// H4: the send went through but the store locked before the ack. The
252 /// batch stays queued and goes again; it is never lost.
253 func testALockedStoreDuringTheAckIsARetry() async throws {
254 let (connectivity, _) = await connectivity()
255 let disk = FaultingBlobStore()
256 let outbox = Outbox(store: disk, account: "a")
257 try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8))
258 let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)])
259 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity,
260 send: poster { _, _ in disk.unavailable = true })
261 let backedOff = await drainer.drain()
262 guard case .backingOff = backedOff else { return XCTFail("Expected a retry") }
263 disk.unavailable = false
264 let kept = await outbox.entries(lane: "doc:a")
265 XCTAssertEqual(kept.count, 1)
266 let again = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
267 let outcome7 = await again.drain()
268 XCTAssertEqual(outcome7, .idle)
269 XCTAssertEqual(transport.committed.count, 2)
270 }
271
272 func testAnUnavailableStoreWaitsAndDeletesNothing() async throws {
273 let (connectivity, _) = await connectivity()
274 let disk = FaultingBlobStore()
275 let setup = Outbox(store: disk, account: "a")
276 try await setup.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8))
277 disk.unavailable = true
278 let outbox = Outbox(store: disk, account: "a")
279 let transport = ScriptedTransport([])
280 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
281 let outcome = await drainer.drain()
282 XCTAssertTrue(outcome == .storeUnavailable || outcome == .idle, "\(outcome)")
283 XCTAssertEqual(transport.calls.count, 0)
284 disk.unavailable = false
285 XCTAssertEqual(disk.committed.contents.count, 1)
286 }
287
288 func testAppDispositionsRetryHoldAndSkip() async throws {
289 let (connectivity, _) = await connectivity()
290 let outbox = try await outbox([("doc:a", "1"), ("doc:a", "2"), ("doc:a", "3")])
291 let transport = ScriptedTransport([])
292 var round = 0
293 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { _, entries, _ in
294 round += 1
295 // Round 1: only the first could be sent (a merge fallback); the rest
296 // are left out and go back without penalty.
297 if round == 1 { return [entries[0].id: .sent] }
298 return [entries[0].id: .hold(Hold(.rejected("no")))]
299 }
300 let outcome8 = await drainer.drain()
301 XCTAssertEqual(outcome8, .idle)
302 XCTAssertEqual(round, 2)
303 let lane = await outbox.entries(lane: "doc:a")
304 XCTAssertEqual(lane.map { String(decoding: $0.payload, as: UTF8.self) }, ["2", "3"])
305 XCTAssertEqual(lane.first?.hold, Hold(.rejected("no")))
306 XCTAssertEqual(lane.last?.attempts, 0, "A left-out entry is not a failed attempt")
307 }
308
309 /// The background loop: an enqueue kicks it, and a restored path wakes it
310 /// with no timer running in between.
311 func testTheLoopSendsOnKickAndOnReconnect() async throws {
312 let (connectivity, source) = await connectivity()
313 let transport = ScriptedTransport { _ in .respond(status: 204) }
314 let outbox = Outbox(store: MemoryBlobStore(), account: "a")
315 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster())
316 drainer.start()
317 defer { drainer.stop() }
318 try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8))
319 drainer.kick()
320 await settle { transport.committed.count == 1 }
321 XCTAssertEqual(transport.committed.count, 1)
322
323 source.send(.unsatisfied)
324 await settle { !connectivity.isPathSatisfied }
325 try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("2".utf8))
326 drainer.kick()
327 try await Task.sleep(nanoseconds: 100_000_000)
328 XCTAssertEqual(transport.calls.count, 1, "Nothing is attempted while the path is down")
329 await settle { drainer.status() == .offline(pending: 1) }
330 XCTAssertEqual(drainer.status(), .offline(pending: 1))
331 source.send(.satisfied)
332 await settle { transport.committed.count == 2 }
333 XCTAssertEqual(bodies(transport), ["1", "2"])
334 await settle { drainer.summary.isEmpty }
335 XCTAssertEqual(drainer.status(), .saved)
336 }
337 func testAnAppRetryDoesNotStarveOtherLanes() async throws {
338 let (connectivity, _) = await connectivity()
339 let outbox = try await outbox([("doc:a-draft", "1"), ("doc:z-ready", "2")])
340 let transport = ScriptedTransport([])
341 var sent: [String] = []
342 let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { lane, entries, _ in
343 sent.append(lane)
344 return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, lane == "doc:a-draft" ? .retry : .sent) })
345 }
346 let result = await drainer.drain()
347 guard case .backingOff = result else { return XCTFail("The draft still needs a retry") }
348 XCTAssertEqual(sent, ["doc:a-draft", "doc:z-ready"])
349 let remaining = await outbox.entries(lane: "doc:z-ready")
350 XCTAssertTrue(remaining.isEmpty, "An unattached draft must not block another document")
351 }
352
353}
354