T-iOS-1: ios/ XcodeGen project (iOS 17, Swift 6 strict concurrency, ATS per PLAN §5.2), 5 SPM package shells, CI skeleton T-iOS-2: Origin spike vs real server — URLSessionWebSocketTask custom Origin CONFIRMED (no Starscream); 16MiB replay + EMSGSIZE(40) errno correction written back to plan T-iOS-3: WireProtocol frozen contract, 59 tests, 100% line coverage, cross-impl vectors vs src/protocol.ts via tsx T-iOS-4: FakeTransport/FakeClock/FakeHTTPTransport doubles Verify: independent agent re-ran all acceptance — 6/6 PASS
119 lines
4.4 KiB
Swift
119 lines
4.4 KiB
Swift
import Foundation
|
|
import WireProtocol
|
|
|
|
/// Errors `FakeHTTPTransport` produces on its own (Equatable for exact
|
|
/// `#expect(throws:)` assertions).
|
|
public enum FakeHTTPTransportError: Error, Equatable, Sendable {
|
|
/// `send` was called for a URL/method with no queued response — the test
|
|
/// forgot to script it. Loud and identifying, never a silent hang.
|
|
case noQueuedResponse(method: String, url: URL)
|
|
/// The request had no URL at all (malformed test input).
|
|
case requestMissingURL
|
|
/// `HTTPURLResponse` refused the scripted status/headers (should not
|
|
/// happen with sane inputs; surfaced explicitly rather than force-unwrapped).
|
|
case responseConstructionFailed(url: URL)
|
|
}
|
|
|
|
/// In-memory `HTTPTransport` double (T-iOS-4). `APIClient` cannot tell it
|
|
/// apart from the `URLSession`-backed implementation (plan §3.4).
|
|
///
|
|
/// - **Queue responses per URL/method** (FIFO per route): `queueSuccess` /
|
|
/// `queueFailure`. An unqueued route throws `.noQueuedResponse` immediately.
|
|
/// - **Record requests verbatim, headers included** — so Origin-iff-G tests
|
|
/// (plan §3.4 铁律) can assert exactly which requests carried `Origin`.
|
|
public actor FakeHTTPTransport: HTTPTransport {
|
|
/// Route identity: HTTP method (uppercased) + exact URL.
|
|
public struct RouteKey: Hashable, Sendable {
|
|
public let method: String
|
|
public let url: URL
|
|
|
|
public init(method: String, url: URL) {
|
|
self.method = method.uppercased()
|
|
self.url = url
|
|
}
|
|
}
|
|
|
|
private enum QueuedResult {
|
|
case success(status: Int, headers: [String: String], body: Data)
|
|
case failure(any Error)
|
|
}
|
|
|
|
/// Default HTTP method for queueing/matching when a request omits one.
|
|
public static let defaultMethod = "GET"
|
|
/// Default scripted success status.
|
|
public static let defaultOKStatus = 200
|
|
private static let httpVersion = "HTTP/1.1"
|
|
|
|
private var queues: [RouteKey: [QueuedResult]] = [:]
|
|
|
|
/// Every request passed to `send`, in order, verbatim (URL, method,
|
|
/// headers, body) — including ones that found no queued response.
|
|
public private(set) var recordedRequests: [URLRequest] = []
|
|
|
|
public init() {}
|
|
|
|
// MARK: - Scripting
|
|
|
|
/// Queue one successful response for `method url` (FIFO per route).
|
|
public func queueSuccess(
|
|
method: String = FakeHTTPTransport.defaultMethod,
|
|
url: URL,
|
|
status: Int = FakeHTTPTransport.defaultOKStatus,
|
|
headers: [String: String] = [:],
|
|
body: Data = Data()
|
|
) {
|
|
enqueue(.success(status: status, headers: headers, body: body),
|
|
for: RouteKey(method: method, url: url))
|
|
}
|
|
|
|
/// Queue one transport-level failure for `method url` (e.g. connection
|
|
/// refused → `PairingError.hostUnreachable` classification tests).
|
|
public func queueFailure(
|
|
method: String = FakeHTTPTransport.defaultMethod,
|
|
url: URL,
|
|
error: any Error
|
|
) {
|
|
enqueue(.failure(error), for: RouteKey(method: method, url: url))
|
|
}
|
|
|
|
// MARK: - HTTPTransport
|
|
|
|
public func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
|
recordedRequests.append(request)
|
|
guard let url = request.url else {
|
|
throw FakeHTTPTransportError.requestMissingURL
|
|
}
|
|
let key = RouteKey(method: request.httpMethod ?? Self.defaultMethod, url: url)
|
|
guard var queue = queues[key], !queue.isEmpty else {
|
|
throw FakeHTTPTransportError.noQueuedResponse(method: key.method, url: url)
|
|
}
|
|
let next = queue.removeFirst()
|
|
queues[key] = queue
|
|
switch next {
|
|
case .failure(let error):
|
|
throw error
|
|
case .success(let status, let headers, let body):
|
|
return (body, try Self.makeResponse(url: url, status: status, headers: headers))
|
|
}
|
|
}
|
|
|
|
// MARK: - Internals
|
|
|
|
private func enqueue(_ result: QueuedResult, for key: RouteKey) {
|
|
queues[key, default: []].append(result)
|
|
}
|
|
|
|
private static func makeResponse(
|
|
url: URL,
|
|
status: Int,
|
|
headers: [String: String]
|
|
) throws -> HTTPURLResponse {
|
|
guard let response = HTTPURLResponse(
|
|
url: url, statusCode: status, httpVersion: httpVersion, headerFields: headers
|
|
) else {
|
|
throw FakeHTTPTransportError.responseConstructionFailed(url: url)
|
|
}
|
|
return response
|
|
}
|
|
}
|