gitbayTests/StubProtocol.swift
163 lines · 5552 bytes
1import Foundation
2
3/// URLProtocol stub: tests enqueue responses, the client sees a real
4/// URLSession. Recorded envelopes go through here.
5///
6/// Swift Testing runs suites in parallel, so state is keyed per box: each
7/// `box()` gets its own queue and request log, matched up by a header the
8/// box's session adds to every request.
9nonisolated final class StubProtocol: URLProtocol, @unchecked Sendable {
10
11 struct Stub: Sendable {
12 let status: Int
13 let headers: [String: String]
14 let body: Data
15 /// When set, this stub only answers requests whose URL contains
16 /// it — needed when the code under test issues requests
17 /// concurrently and arrival order is not deterministic.
18 let match: String?
19
20 init(status: Int, headers: [String: String] = [:], json: String, match: String? = nil) {
21 self.status = status
22 var headers = headers
23 headers["Content-Type"] = headers["Content-Type"] ?? "application/json"
24 self.headers = headers
25 self.body = Data(json.utf8)
26 self.match = match
27 }
28
29 init(status: Int, headers: [String: String] = [:], body: Data = Data(), match: String? = nil) {
30 self.status = status
31 self.headers = headers
32 self.body = body
33 self.match = match
34 }
35 }
36
37 /// A request as the stub saw it, for asserting on method, URL, headers
38 /// and body.
39 struct Seen: Sendable {
40 let url: URL
41 let method: String
42 let headers: [String: String]
43 let body: Data
44 }
45
46 /// One test's private stub queue and request log.
47 final class Box: Sendable {
48 fileprivate let id = UUID().uuidString
49 private let queue = Mutex<[Stub]>([])
50 private let log = Mutex<[Seen]>([])
51
52 func enqueue(_ stub: Stub) {
53 queue.withLock { $0.append(stub) }
54 }
55
56 var seen: [Seen] {
57 log.withLock { $0 }
58 }
59
60 /// The stubbed session for this box. The marker header is how the
61 /// shared protocol class finds its way back here.
62 func session() -> URLSession {
63 let configuration = URLSessionConfiguration.ephemeral
64 configuration.protocolClasses = [StubProtocol.self]
65 configuration.httpAdditionalHeaders = [StubProtocol.marker: id]
66 return URLSession(configuration: configuration)
67 }
68
69 fileprivate func next(for url: URL) -> Stub? {
70 queue.withLock { stubs in
71 let index = stubs.firstIndex {
72 $0.match.map { url.absoluteString.contains($0) } ?? true
73 }
74 guard let index else { return nil }
75 return stubs.remove(at: index)
76 }
77 }
78
79 fileprivate func record(_ request: Seen) {
80 log.withLock { $0.append(request) }
81 }
82 }
83
84 private static let marker = "X-Stub-Box"
85 private static let boxes = Mutex<[String: Box]>([:])
86
87 static func box() -> Box {
88 let box = Box()
89 boxes.withLock { $0[box.id] = box }
90 return box
91 }
92
93 // MARK: - URLProtocol
94
95 override static func canInit(with _: URLRequest) -> Bool { true }
96
97 override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
98
99 override func startLoading() {
100 guard let id = request.value(forHTTPHeaderField: Self.marker),
101 let box = Self.boxes.withLock({ $0[id] }) else {
102 client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
103 return
104 }
105
106 // httpBody is transformed into a stream by URLSession; read it back.
107 var body = Data()
108 if let stream = request.httpBodyStream {
109 stream.open()
110 defer { stream.close() }
111 let size = 4096
112 let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: size)
113 defer { buffer.deallocate() }
114 while stream.hasBytesAvailable {
115 let read = stream.read(buffer, maxLength: size)
116 guard read > 0 else { break }
117 body.append(buffer, count: read)
118 }
119 }
120 var headers = request.allHTTPHeaderFields ?? [:]
121 headers.removeValue(forKey: Self.marker)
122 box.record(Seen(
123 url: request.url!,
124 method: request.httpMethod ?? "GET",
125 headers: headers,
126 body: body
127 ))
128
129 guard let stub = box.next(for: request.url!) else {
130 client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
131 return
132 }
133 let response = HTTPURLResponse(
134 url: request.url!,
135 statusCode: stub.status,
136 httpVersion: "HTTP/1.1",
137 headerFields: stub.headers
138 )!
139 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
140 if !stub.body.isEmpty {
141 client?.urlProtocol(self, didLoad: stub.body)
142 }
143 client?.urlProtocolDidFinishLoading(self)
144 }
145
146 override func stopLoading() {}
147}
148
149/// Minimal lock wrapper; os.OSAllocatedUnfairLock spelled test-side.
150nonisolated final class Mutex<Value>: @unchecked Sendable {
151 private var value: Value
152 private let lock = NSLock()
153
154 init(_ value: Value) {
155 self.value = value
156 }
157
158 func withLock<R>(_ body: (inout Value) -> R) -> R {
159 lock.lock()
160 defer { lock.unlock() }
161 return body(&value)
162 }
163}