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 XCTest
3import RastrilloApple
4import RastrilloTestSupport
5
6private struct Empty: Decodable {}
7
8private actor Lines {
9 var values: [String] = []
10 func append(_ value: String) { values.append(value) }
11}
12
13/// Each error class a consumer must tell apart, produced the way an app
14/// meets it: through `request`, `stream` and `download` on a transport.
15final class ScriptedTransportTests: XCTestCase {
16 private func requestFailure(_ step: ScriptedTransport.Step,
17 file: StaticString = #filePath, line: UInt = #line) async -> Error? {
18 let transport = ScriptedTransport([step])
19 do {
20 let _: Empty = try await transport.request("/x", method: "POST", body: Data("{}".utf8))
21 XCTFail("The scripted step succeeded", file: file, line: line)
22 return nil
23 } catch { return error }
24 }
25
26 private func assertRequest(_ step: ScriptedTransport.Step, fails expected: NativeClientError,
27 _ retry: NativeClientError.RetryClass,
28 file: StaticString = #filePath, line: UInt = #line) async {
29 let error = await requestFailure(step, file: file, line: line)
30 guard let typed = error as? NativeClientError else {
31 return XCTFail("Expected \(expected), got \(String(describing: error))", file: file, line: line)
32 }
33 XCTAssertEqual(typed, expected, file: file, line: line)
34 XCTAssertEqual(typed.retryClass, retry, file: file, line: line)
35 }
36
37 func testOffline() async {
38 await assertRequest(.offline(), fails: .offline(.notConnectedToInternet), .offline)
39 await assertRequest(.offline(.timedOut), fails: .offline(.timedOut), .offline)
40 }
41
42 func testEndedSession() async {
43 await assertRequest(.respond(status: 401), fails: .unauthorized, .auth)
44 // NoRedirects surfaces the redirect to /signin as a 303.
45 await assertRequest(.respond(status: 303, headers: ["Location": "/signin"]), fails: .unauthorized, .auth)
46 }
47
48 func testPermanentRefusals() async {
49 await assertRequest(.respond(status: 403), fails: .forbidden, .permanent)
50 await assertRequest(.respond(status: 404), fails: .notFound, .permanent)
51 await assertRequest(.respond(status: 413), fails: .tooLarge, .permanent)
52 await assertRequest(.respond(status: 400, body: Data("bad".utf8)),
53 fails: .rejected(status: 400, body: Data("bad".utf8)), .permanent)
54 await assertRequest(.respond(status: 422), fails: .rejected(status: 422, body: Data()), .permanent)
55 }
56
57 func testConflictKeepsTheServersBody() async {
58 let theirs = Data(#"{"value":"theirs"}"#.utf8)
59 await assertRequest(.respond(status: 409, body: theirs), fails: .conflict(theirs), .conflict)
60 }
61
62 func testRateLimitedCarriesRetryAfter() async {
63 await assertRequest(.respond(status: 429, headers: ["Retry-After": "7"]), fails: .rateLimited(retryAfter: 7), .transient)
64 await assertRequest(.respond(status: 429), fails: .rateLimited(retryAfter: nil), .transient)
65 // A 503 that says when to come back is a rate limit, not a fault.
66 await assertRequest(.respond(status: 503, headers: ["retry-after": "3"]), fails: .rateLimited(retryAfter: 3), .transient)
67 }
68
69 func testServerFaults() async {
70 await assertRequest(.respond(status: 500), fails: .server(status: 500), .transient)
71 await assertRequest(.respond(status: 503), fails: .server(status: 503), .transient)
72 }
73
74 func testSuccessDecodesAndIsCommitted() async throws {
75 let transport = ScriptedTransport([.respond(status: 201, body: Data("{}".utf8))])
76 let _: Empty = try await transport.request("/notes", method: "POST", body: Data("{}".utf8))
77 XCTAssertEqual(transport.committed.map(\.path), ["/notes"])
78 XCTAssertEqual(transport.remainingSteps, 0)
79 }
80
81 func testResponseDetailsReturnsAnyStatus() async throws {
82 let transport = ScriptedTransport([.respond(status: 401, headers: ["Docs-Seq": "4"])])
83 let response = try await transport.responseDetails("/d/1")
84 XCTAssertEqual(response.status, 401)
85 XCTAssertEqual(response.header("docs-seq"), "4")
86 XCTAssertThrowsError(try response.requireSuccess()) { XCTAssertEqual($0 as? NativeClientError, .unauthorized) }
87 XCTAssertTrue(transport.committed.isEmpty, "A refused request was recorded as applied")
88 }
89
90 func testCommitThenLoseResponse() async {
91 let transport = ScriptedTransport([.commitThenLoseResponse()])
92 do {
93 _ = try await transport.responseDetails("/d/1/updates", method: "POST", body: Data([1, 2]))
94 XCTFail("A lost response succeeded")
95 } catch {
96 XCTAssertEqual(error as? NativeClientError, .offline(.networkConnectionLost))
97 XCTAssertEqual((error as? NativeClientError)?.retryClass, .offline)
98 }
99 // The caller saw a failure, yet the server holds the write: the case a
100 // durable outbox must not send twice in effect.
101 XCTAssertEqual(transport.committed, [.init(kind: .request, path: "/d/1/updates", method: "POST", body: Data([1, 2]))])
102 }
103
104 func testHangUntilCancelled() async throws {
105 let transport = ScriptedTransport([.hang])
106 let task = Task { try await transport.responseDetails("/slow") }
107 while transport.calls.isEmpty { try await Task.sleep(nanoseconds: 1_000_000) }
108 task.cancel()
109 do {
110 _ = try await task.value
111 XCTFail("A hung request completed")
112 } catch { XCTAssertTrue(error is CancellationError, "Expected cancellation, got \(error)") }
113 }
114
115 func testRefusedStreamIsTyped() async {
116 let transport = ScriptedTransport([.respond(status: 401), .offline()])
117 do {
118 try await transport.stream("/d/1/stream") { _ in XCTFail("A refused stream delivered a line") }
119 XCTFail("A refused stream opened")
120 } catch { XCTAssertEqual(error as? NativeClientError, .unauthorized) }
121 do {
122 try await transport.stream("/d/1/stream", onOpen: { XCTFail("An offline stream opened") }) { _ in }
123 XCTFail("An offline stream opened")
124 } catch { XCTAssertEqual(error as? NativeClientError, .offline(.notConnectedToInternet)) }
125 }
126
127 func testStreamOpensBeforeLines() async throws {
128 let transport = ScriptedTransport([.respond(status: 200, body: Data("event: update\ndata: 42\n\n".utf8))])
129 let lines = Lines()
130 try await transport.stream("/d/1/stream", onOpen: { await lines.append("opened") }) { await lines.append($0) }
131 let received = await lines.values
132 XCTAssertEqual(received, ["opened", "event: update", "data: 42", ""])
133 }
134
135 func testDownloadWritesOrFailsTyped() async throws {
136 let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
137 try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
138 defer { try? FileManager.default.removeItem(at: directory) }
139 let transport = ScriptedTransport([.respond(status: 200, body: Data("12345".utf8)), .respond(status: 403)])
140 let file = directory.appendingPathComponent("export.xlsx")
141 try await transport.download("/export", to: file)
142 XCTAssertEqual(try Data(contentsOf: file), Data("12345".utf8))
143 do {
144 try await transport.download("/export", to: directory.appendingPathComponent("again.xlsx"))
145 XCTFail("A refused export succeeded")
146 } catch { XCTAssertEqual(error as? NativeClientError, .forbidden) }
147 }
148
149 func testUnscriptedCallFailsLoudly() async {
150 let transport = ScriptedTransport()
151 do {
152 _ = try await transport.responseDetails("/surprise")
153 XCTFail("An unscripted call succeeded")
154 } catch { XCTAssertTrue(error is ScriptedTransport.Unscripted, "Got \(error)") }
155 XCTAssertEqual(transport.calls.map(\.path), ["/surprise"])
156 }
157
158 func testResponderAnswersRacingCallsByWhatTheyAsk() async throws {
159 let transport = ScriptedTransport { call in
160 switch (call.kind, call.path) {
161 case (.stream, "/d/1/stream"): return .hang
162 case (.request, "/d/1/updates"): return .respond(status: 204)
163 default: return nil
164 }
165 }
166 let listening = Task { try await transport.stream("/d/1/stream") { _ in } }
167 let saved = try await transport.responseDetails("/d/1/updates", method: "POST", body: Data([7]))
168 XCTAssertEqual(saved.status, 204)
169 XCTAssertEqual(transport.committed.map(\.path), ["/d/1/updates"])
170 do {
171 _ = try await transport.responseDetails("/elsewhere")
172 XCTFail("A declined call succeeded")
173 } catch { XCTAssertTrue(error is ScriptedTransport.Unscripted, "Got \(error)") }
174 listening.cancel()
175 do { try await listening.value; XCTFail("A hung stream completed") } catch { XCTAssertTrue(error is CancellationError) }
176 }
177}
178