10bc457c72
Unify PiP recovery across startup and foreground transitions, surface actionable status, and add privacy-safe analytics plus the refreshed onboarding and account experience. Update documentation assets and advance the release build to 84.
614 lines
23 KiB
Swift
614 lines
23 KiB
Swift
// ManagedGatewayTests.swift
|
|
// OSGKeyboard · Extension Tests
|
|
//
|
|
// Hermetic grant rotation and managed LLM tests using a private URLProtocol stub.
|
|
|
|
import Foundation
|
|
@testable import OSGKeyboardShared
|
|
import XCTest
|
|
|
|
final class ManagedGatewayTests: XCTestCase {
|
|
private let baseURL = URL(string: "https://gateway.test")!
|
|
|
|
override func setUp() {
|
|
super.setUp()
|
|
GatewayStub.shared.reset()
|
|
}
|
|
|
|
override func tearDown() {
|
|
GatewayStub.shared.reset()
|
|
super.tearDown()
|
|
}
|
|
|
|
func testFiveMinuteAccessBoundaryAndScope() {
|
|
let start = Date(timeIntervalSince1970: 1_000)
|
|
let value = credentials(accessExpiresAt: start.addingTimeInterval(3_600), receivedAt: start)
|
|
XCTAssertEqual(value.effectiveAccessExpiresAt, start.addingTimeInterval(300))
|
|
XCTAssertTrue(value.hasUsableAccessToken(for: .polish, at: start.addingTimeInterval(269)))
|
|
XCTAssertFalse(value.hasUsableAccessToken(for: .polish, at: start.addingTimeInterval(270)))
|
|
XCTAssertFalse(value.hasUsableAccessToken(for: .asr, at: start))
|
|
}
|
|
|
|
func testScopePolicyExcludesASRForLocalEngineAndIncludesItForCloud() {
|
|
let localScopes = ManagedGatewayScopePolicy.scopes(engineMode: "local")
|
|
XCTAssertEqual(localScopes, [.polish, .assistant, .agent])
|
|
XCTAssertFalse(localScopes.contains(.asr))
|
|
|
|
let cloudScopes = ManagedGatewayScopePolicy.scopes(engineMode: "cloud")
|
|
XCTAssertEqual(cloudScopes, [.polish, .assistant, .agent, .asr])
|
|
}
|
|
|
|
func testCreateGrantSendsAccountTokenButStoresOnlyGrant() async throws {
|
|
GatewayStub.shared.enqueue(
|
|
201,
|
|
grantJSON(
|
|
access: "grant-access",
|
|
refresh: "grant-refresh",
|
|
scopes: ["polish", "ai"]
|
|
)
|
|
)
|
|
let store = MemoryGrantStore()
|
|
let coordinator = makeCoordinator(store: store)
|
|
|
|
_ = try await coordinator.createGrant(
|
|
accountAccessToken: "account-secret",
|
|
scopes: [.polish, .assistant],
|
|
lifetimeSeconds: 3_600,
|
|
idempotencyKey: "create-idempotency"
|
|
)
|
|
|
|
let stored = await store.value()
|
|
XCTAssertEqual(stored?.refreshToken, "grant-refresh")
|
|
let request = try XCTUnwrap(GatewayStub.shared.requests().first)
|
|
XCTAssertEqual(request.url?.path, "/v1/gateway/grants")
|
|
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer account-secret")
|
|
XCTAssertEqual(request.value(forHTTPHeaderField: "Idempotency-Key"), "create-idempotency")
|
|
let body = try jsonBody(request)
|
|
XCTAssertEqual(Set(body["scopes"] as? [String] ?? []), Set(["polish", "ai"]))
|
|
XCTAssertEqual(body["lifetimeSeconds"] as? Int, 3_600)
|
|
let persisted = try JSONEncoder().encode(stored)
|
|
XCTAssertFalse(
|
|
(String(bytes: persisted, encoding: .utf8) ?? "").contains("account-secret")
|
|
)
|
|
}
|
|
|
|
func testCreateGrantRejectsBroaderReturnedScopes() async throws {
|
|
GatewayStub.shared.enqueue(
|
|
201,
|
|
grantJSON(
|
|
access: "grant-access",
|
|
refresh: "grant-refresh",
|
|
scopes: ["polish", "ai", "agent"]
|
|
)
|
|
)
|
|
let store = MemoryGrantStore()
|
|
let coordinator = makeCoordinator(store: store)
|
|
|
|
await XCTAssertThrowsManaged(.invalidGrant) {
|
|
_ = try await coordinator.createGrant(
|
|
accountAccessToken: "account-secret",
|
|
scopes: [.polish],
|
|
idempotencyKey: "create-scope-boundary"
|
|
)
|
|
}
|
|
let stored = await store.value()
|
|
XCTAssertNil(stored)
|
|
}
|
|
|
|
func testConcurrentRefreshIsMergedAndHashesOldTokenForIdempotency() async throws {
|
|
let now = Date(timeIntervalSince1970: 2_000)
|
|
let oldRefresh = "old-refresh-token-that-is-at-least-32-characters"
|
|
let store = MemoryGrantStore(
|
|
credentials(
|
|
accessToken: "expired",
|
|
refreshToken: oldRefresh,
|
|
accessExpiresAt: now.addingTimeInterval(-1),
|
|
receivedAt: now.addingTimeInterval(-600)
|
|
)
|
|
)
|
|
GatewayStub.shared.enqueue(
|
|
200,
|
|
grantJSON(access: "new-access", refresh: "new-refresh"),
|
|
delay: 0.05
|
|
)
|
|
let coordinator = makeCoordinator(store: store, now: { now })
|
|
|
|
async let first = coordinator.accessToken(for: .assistant)
|
|
async let second = coordinator.accessToken(for: .assistant)
|
|
let values = try await (first, second)
|
|
|
|
XCTAssertEqual(values.0, "new-access")
|
|
XCTAssertEqual(values.1, "new-access")
|
|
let requests = GatewayStub.shared.requests()
|
|
XCTAssertEqual(requests.count, 1)
|
|
let request = try XCTUnwrap(requests.first)
|
|
let key = try XCTUnwrap(request.value(forHTTPHeaderField: "Idempotency-Key"))
|
|
XCTAssertEqual(key, GatewayGrantCoordinator.refreshTokenIdempotencyKey(oldRefresh))
|
|
XCTAssertFalse(key.contains(oldRefresh))
|
|
XCTAssertEqual(try jsonBody(request)["refreshToken"] as? String, oldRefresh)
|
|
}
|
|
|
|
func testManagedClientMapsAllCapabilitiesHeadersBodyAndRequestIds() async throws {
|
|
let now = Date(timeIntervalSince1970: 3_000)
|
|
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
|
let coordinator = makeCoordinator(store: store, now: { now })
|
|
let ids = LockedValues(["request-0001", "request-0002", "request-0003"])
|
|
|
|
let cases: [(ManagedLLMClient.Capability, ManagedGatewayTaskKind)] = [
|
|
(.polish, .dictationPolish),
|
|
(.assistant, .aiQuestion),
|
|
(.agent, .agentPlanning)
|
|
]
|
|
for (capability, _) in cases {
|
|
GatewayStub.shared.enqueue(200, Data(#"{"choices":[{"message":{"content":"ok"}}]}"#.utf8))
|
|
let client = ManagedLLMClient(
|
|
capability: capability,
|
|
grants: coordinator,
|
|
baseURL: baseURL,
|
|
session: stubSession(),
|
|
requestId: { ids.next() }
|
|
)
|
|
let result = try await client.polish(
|
|
"input",
|
|
systemPrompt: "context",
|
|
timeout: 7,
|
|
options: LLMGenerationOptions(temperature: 0.4, maxTokens: 321)
|
|
)
|
|
XCTAssertEqual(result, "ok")
|
|
}
|
|
|
|
let requests = GatewayStub.shared.requests()
|
|
XCTAssertEqual(
|
|
requests.compactMap(\.url?.path),
|
|
[
|
|
"/v1/gateway/llm/polish",
|
|
"/v1/gateway/llm/ai",
|
|
"/v1/gateway/llm/agent"
|
|
]
|
|
)
|
|
XCTAssertEqual(Set(requests.compactMap { $0.value(forHTTPHeaderField: "X-Request-ID") }).count, 3)
|
|
for (request, expectedCase) in zip(requests, cases) {
|
|
XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer access")
|
|
let body = try jsonBody(request)
|
|
XCTAssertEqual(body["input"] as? String, "input")
|
|
XCTAssertEqual(body["context"] as? String, "context")
|
|
XCTAssertEqual(body["maxOutputTokens"] as? Int, 321)
|
|
XCTAssertEqual(body["temperature"] as? Double, 0.4)
|
|
XCTAssertEqual(body["stream"] as? Bool, false)
|
|
XCTAssertEqual(body["taskKind"] as? String, expectedCase.1.rawValue)
|
|
XCTAssertEqual(request.timeoutInterval, 7)
|
|
}
|
|
}
|
|
|
|
func testManagedClientSerializesFineGrainedTaskKinds() async throws {
|
|
let now = Date(timeIntervalSince1970: 3_500)
|
|
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
|
let coordinator = makeCoordinator(store: store, now: { now })
|
|
let cases: [(ManagedLLMClient.Capability, ManagedGatewayTaskKind)] = [
|
|
(.polish, .translation),
|
|
(.polish, .editLastInput),
|
|
(.assistant, .clipboardTransform),
|
|
(.assistant, .customSkill)
|
|
]
|
|
|
|
for (capability, taskKind) in cases {
|
|
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"ok"}"#.utf8))
|
|
let client = ManagedLLMClient(
|
|
capability: capability,
|
|
taskKind: taskKind,
|
|
grants: coordinator,
|
|
baseURL: baseURL,
|
|
session: stubSession()
|
|
)
|
|
_ = try await client.polish("input", systemPrompt: "context")
|
|
}
|
|
|
|
let taskKinds = try GatewayStub.shared.requests().map {
|
|
try XCTUnwrap(jsonBody($0)["taskKind"] as? String)
|
|
}
|
|
XCTAssertEqual(taskKinds, cases.map { $0.1.rawValue })
|
|
}
|
|
|
|
func testManagedClientSerializesOOBERequestPurpose() async throws {
|
|
let now = Date(timeIntervalSince1970: 3_750)
|
|
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
|
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"ok"}"#.utf8))
|
|
let client = ManagedLLMClient(
|
|
capability: .polish,
|
|
taskKind: .dictationPolish,
|
|
requestPurpose: .oobe,
|
|
grants: makeCoordinator(store: store, now: { now }),
|
|
baseURL: baseURL,
|
|
session: stubSession()
|
|
)
|
|
|
|
_ = try await client.polish("input", systemPrompt: "context")
|
|
|
|
let request = try XCTUnwrap(GatewayStub.shared.requests().last)
|
|
let body = try jsonBody(request)
|
|
XCTAssertEqual(body["taskKind"] as? String, "dictation_polish")
|
|
XCTAssertEqual(body["requestPurpose"] as? String, "oobe")
|
|
}
|
|
|
|
func testManagedTaskKindWireValuesMatchServerContract() {
|
|
XCTAssertEqual(
|
|
ManagedGatewayTaskKind.allCases.map(\.rawValue),
|
|
[
|
|
"dictation_polish",
|
|
"translation",
|
|
"edit_last_input",
|
|
"ai_question",
|
|
"clipboard_transform",
|
|
"custom_skill",
|
|
"agent_planning"
|
|
]
|
|
)
|
|
}
|
|
|
|
func testConversationMappingAndRawTextFallback() async throws {
|
|
let now = Date(timeIntervalSince1970: 4_000)
|
|
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
|
GatewayStub.shared.enqueue(200, Data("raw answer".utf8), contentType: "text/plain")
|
|
let client = ManagedLLMClient(
|
|
capability: .assistant,
|
|
grants: makeCoordinator(store: store, now: { now }),
|
|
baseURL: baseURL,
|
|
session: stubSession()
|
|
)
|
|
|
|
let answer = try await client.complete(
|
|
messages: [.system("system"), .user("old"), .assistant("reply"), .user("latest")],
|
|
timeout: nil,
|
|
options: .polishDefault
|
|
)
|
|
|
|
XCTAssertEqual(answer, "raw answer")
|
|
let request = try XCTUnwrap(GatewayStub.shared.requests().first)
|
|
let body = try jsonBody(request)
|
|
XCTAssertEqual(body["input"] as? String, "latest")
|
|
XCTAssertEqual(body["context"] as? String, "system:\nsystem\n\nuser:\nold\n\nassistant:\nreply")
|
|
}
|
|
|
|
func testUnauthorizedRequestRefreshesOnceWithSameRequestId() async throws {
|
|
let now = Date(timeIntervalSince1970: 5_000)
|
|
let oldRefresh = "old-refresh-value-123456789012345678901"
|
|
let store = MemoryGrantStore(
|
|
credentials(accessToken: "old-access", refreshToken: oldRefresh, receivedAt: now)
|
|
)
|
|
GatewayStub.shared.enqueue(401, errorJSON("unauthorized"))
|
|
GatewayStub.shared.enqueue(200, grantJSON(access: "new-access", refresh: "new-refresh"))
|
|
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"done"}"#.utf8))
|
|
let client = ManagedLLMClient(
|
|
capability: .polish,
|
|
grants: makeCoordinator(store: store, now: { now }),
|
|
baseURL: baseURL,
|
|
session: stubSession(),
|
|
requestId: { "logical-request" }
|
|
)
|
|
|
|
let result = try await client.polish("input", systemPrompt: "prompt")
|
|
XCTAssertEqual(result, "done")
|
|
let requests = GatewayStub.shared.requests()
|
|
XCTAssertEqual(requests.count, 3)
|
|
XCTAssertEqual(requests[0].value(forHTTPHeaderField: "Authorization"), "Bearer old-access")
|
|
XCTAssertEqual(requests[1].url?.path, "/v1/gateway/grants/refresh")
|
|
XCTAssertEqual(requests[2].value(forHTTPHeaderField: "Authorization"), "Bearer new-access")
|
|
XCTAssertEqual(
|
|
requests[0].value(forHTTPHeaderField: "X-Request-ID"),
|
|
requests[2].value(forHTTPHeaderField: "X-Request-ID")
|
|
)
|
|
XCTAssertEqual(
|
|
requests[1].value(forHTTPHeaderField: "Idempotency-Key"),
|
|
GatewayGrantCoordinator.refreshTokenIdempotencyKey(oldRefresh)
|
|
)
|
|
}
|
|
|
|
func testCreditAndInvalidRefreshErrorsAreMapped() async throws {
|
|
let now = Date(timeIntervalSince1970: 6_000)
|
|
let creditStore = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
|
GatewayStub.shared.enqueue(402, errorJSON("insufficient_credits"))
|
|
let creditClient = ManagedLLMClient(
|
|
capability: .assistant,
|
|
grants: makeCoordinator(store: creditStore, now: { now }),
|
|
baseURL: baseURL,
|
|
session: stubSession()
|
|
)
|
|
await XCTAssertThrowsManaged(.insufficientCredits) {
|
|
_ = try await creditClient.complete(
|
|
messages: [.user("question")],
|
|
timeout: nil,
|
|
options: .polishDefault
|
|
)
|
|
}
|
|
|
|
let expiredStore = MemoryGrantStore(
|
|
credentials(
|
|
accessToken: "expired",
|
|
accessExpiresAt: now.addingTimeInterval(-1),
|
|
receivedAt: now.addingTimeInterval(-600)
|
|
)
|
|
)
|
|
GatewayStub.shared.enqueue(401, errorJSON("invalid_gateway_refresh"))
|
|
let coordinator = makeCoordinator(store: expiredStore, now: { now })
|
|
await XCTAssertThrowsManaged(.invalidGrant) {
|
|
_ = try await coordinator.accessToken(for: .assistant)
|
|
}
|
|
let remaining = await expiredStore.value()
|
|
XCTAssertNil(remaining)
|
|
}
|
|
|
|
func testTransportTimeoutMapsToManagedTimeout() async {
|
|
let now = Date(timeIntervalSince1970: 6_500)
|
|
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
|
GatewayStub.shared.enqueue(
|
|
0,
|
|
Data(),
|
|
transportError: .timedOut
|
|
)
|
|
let client = ManagedLLMClient(
|
|
capability: .polish,
|
|
grants: makeCoordinator(store: store, now: { now }),
|
|
baseURL: baseURL,
|
|
session: stubSession()
|
|
)
|
|
|
|
await XCTAssertThrowsManaged(.timeout) {
|
|
_ = try await client.polish("input", systemPrompt: "prompt", timeout: 0.1)
|
|
}
|
|
}
|
|
|
|
func testCancellationAndStreaming() async throws {
|
|
let now = Date(timeIntervalSince1970: 7_000)
|
|
let store = MemoryGrantStore(credentials(accessToken: "access", receivedAt: now))
|
|
let coordinator = makeCoordinator(store: store, now: { now })
|
|
GatewayStub.shared.enqueue(200, Data(#"{"output_text":"late"}"#.utf8), delay: 5)
|
|
let cancelledClient = ManagedLLMClient(
|
|
capability: .polish,
|
|
grants: coordinator,
|
|
baseURL: baseURL,
|
|
session: stubSession()
|
|
)
|
|
let task = Task { try await cancelledClient.polish("input", systemPrompt: "prompt") }
|
|
try await Task.sleep(for: .milliseconds(50))
|
|
task.cancel()
|
|
do {
|
|
_ = try await task.value
|
|
XCTFail("expected cancellation")
|
|
} catch let error as LLMError {
|
|
XCTAssertEqual(error, .cancelled)
|
|
} catch is CancellationError {
|
|
// Foundation may surface native cancellation before client mapping.
|
|
}
|
|
|
|
let sse = """
|
|
data: {"choices":[{"delta":{"reasoning_content":"hidden","content":"Hello"}}]}
|
|
|
|
data: {"type":"response.output_text.delta","delta":" world"}
|
|
|
|
data: [DONE]
|
|
|
|
"""
|
|
GatewayStub.shared.enqueue(200, Data(sse.utf8), contentType: "text/event-stream")
|
|
let streamClient = ManagedLLMClient(
|
|
capability: .assistant,
|
|
grants: coordinator,
|
|
baseURL: baseURL,
|
|
session: stubSession()
|
|
)
|
|
var answer = ""
|
|
for try await event in streamClient.completeStreaming(
|
|
messages: [.user("question")],
|
|
timeout: 2,
|
|
options: .polishDefault
|
|
) {
|
|
if case .delta(let value) = event { answer += value }
|
|
}
|
|
XCTAssertEqual(answer, "Hello world")
|
|
let streamRequest = try XCTUnwrap(GatewayStub.shared.requests().last)
|
|
XCTAssertEqual(try jsonBody(streamRequest)["stream"] as? Bool, true)
|
|
}
|
|
|
|
private func makeCoordinator(
|
|
store: MemoryGrantStore,
|
|
now: @escaping @Sendable () -> Date = Date.init
|
|
) -> GatewayGrantCoordinator {
|
|
GatewayGrantCoordinator(baseURL: baseURL, store: store, session: stubSession(), now: now)
|
|
}
|
|
|
|
private func stubSession() -> URLSession {
|
|
let config = URLSessionConfiguration.ephemeral
|
|
config.protocolClasses = [GatewayURLProtocol.self]
|
|
return URLSession(configuration: config)
|
|
}
|
|
|
|
private func credentials(
|
|
accessToken: String = "access-token",
|
|
refreshToken: String = "refresh-token-value-12345678901234567890",
|
|
accessExpiresAt: Date = Date(timeIntervalSince1970: 20_000),
|
|
refreshExpiresAt: Date = Date(timeIntervalSince1970: 40_000),
|
|
receivedAt: Date = Date(timeIntervalSince1970: 10_000)
|
|
) -> ManagedGatewayGrantCredentials {
|
|
ManagedGatewayGrantCredentials(
|
|
grantId: "11111111-1111-1111-1111-111111111111",
|
|
scopes: [.polish, .assistant, .agent],
|
|
accessToken: accessToken,
|
|
accessExpiresAt: accessExpiresAt,
|
|
refreshToken: refreshToken,
|
|
refreshExpiresAt: refreshExpiresAt,
|
|
receivedAt: receivedAt
|
|
)
|
|
}
|
|
|
|
private func grantJSON(
|
|
access: String,
|
|
refresh: String,
|
|
scopes: [String] = ["polish", "ai", "agent"]
|
|
) -> Data {
|
|
let encodedScopes = scopes.map { "\"\($0)\"" }.joined(separator: ",")
|
|
return Data(
|
|
"""
|
|
{"grantId":"11111111-1111-1111-1111-111111111111",
|
|
"scopes":[\(encodedScopes)],"accessToken":"\(access)",
|
|
"accessExpiresAt":"2030-01-01T00:05:00Z","refreshToken":"\(refresh)",
|
|
"refreshExpiresAt":"2030-01-02T00:00:00Z"}
|
|
""".utf8
|
|
)
|
|
}
|
|
|
|
private func errorJSON(_ code: String) -> Data {
|
|
Data(#"{"code":"\#(code)","message":"error","requestId":"request-id"}"#.utf8)
|
|
}
|
|
|
|
private func jsonBody(_ request: URLRequest) throws -> [String: Any] {
|
|
try XCTUnwrap(
|
|
JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any]
|
|
)
|
|
}
|
|
|
|
private func XCTAssertThrowsManaged(
|
|
_ expected: ManagedGatewayError,
|
|
operation: () async throws -> Void
|
|
) async {
|
|
do {
|
|
try await operation()
|
|
XCTFail("expected \(expected)")
|
|
} catch let error as ManagedGatewayError {
|
|
XCTAssertEqual(error, expected)
|
|
} catch {
|
|
XCTFail("unexpected error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private actor MemoryGrantStore: GatewayGrantCredentialStore {
|
|
private var credentials: ManagedGatewayGrantCredentials?
|
|
|
|
init(_ credentials: ManagedGatewayGrantCredentials? = nil) {
|
|
self.credentials = credentials
|
|
}
|
|
|
|
func load() async throws -> ManagedGatewayGrantCredentials? { credentials }
|
|
func save(_ credentials: ManagedGatewayGrantCredentials) async throws { self.credentials = credentials }
|
|
func delete() async throws { credentials = nil }
|
|
func value() -> ManagedGatewayGrantCredentials? { credentials }
|
|
}
|
|
|
|
private final class LockedValues: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var values: [String]
|
|
|
|
init(_ values: [String]) { self.values = values }
|
|
|
|
func next() -> String {
|
|
lock.withLock { values.isEmpty ? UUID().uuidString : values.removeFirst() }
|
|
}
|
|
}
|
|
|
|
private final class GatewayStub: @unchecked Sendable {
|
|
struct Response {
|
|
let status: Int
|
|
let body: Data
|
|
let contentType: String
|
|
let delay: TimeInterval
|
|
let transportError: URLError.Code?
|
|
}
|
|
|
|
static let shared = GatewayStub()
|
|
private let lock = NSLock()
|
|
private var responses: [Response] = []
|
|
private var captured: [URLRequest] = []
|
|
|
|
func enqueue(
|
|
_ status: Int,
|
|
_ body: Data,
|
|
contentType: String = "application/json",
|
|
delay: TimeInterval = 0,
|
|
transportError: URLError.Code? = nil
|
|
) {
|
|
lock.withLock {
|
|
responses.append(
|
|
Response(
|
|
status: status,
|
|
body: body,
|
|
contentType: contentType,
|
|
delay: delay,
|
|
transportError: transportError
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
func take(_ request: URLRequest) -> Response {
|
|
lock.withLock {
|
|
captured.append(Self.materialize(request))
|
|
return responses.isEmpty
|
|
? Response(
|
|
status: 500,
|
|
body: Data(),
|
|
contentType: "application/json",
|
|
delay: 0,
|
|
transportError: nil
|
|
)
|
|
: responses.removeFirst()
|
|
}
|
|
}
|
|
|
|
func requests() -> [URLRequest] { lock.withLock { captured } }
|
|
|
|
func reset() {
|
|
lock.withLock {
|
|
responses.removeAll()
|
|
captured.removeAll()
|
|
}
|
|
}
|
|
|
|
private static func materialize(_ source: URLRequest) -> URLRequest {
|
|
var request = source
|
|
guard request.httpBody == nil, let stream = request.httpBodyStream else { return request }
|
|
stream.open()
|
|
defer { stream.close() }
|
|
var data = Data()
|
|
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: 4_096)
|
|
defer { buffer.deallocate() }
|
|
while stream.hasBytesAvailable {
|
|
let count = stream.read(buffer, maxLength: 4_096)
|
|
if count <= 0 { break }
|
|
data.append(buffer, count: count)
|
|
}
|
|
request.httpBody = data
|
|
return request
|
|
}
|
|
}
|
|
|
|
private final class GatewayURLProtocol: URLProtocol, @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var stopped = false
|
|
|
|
override static func canInit(with request: URLRequest) -> Bool { true }
|
|
override static func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
|
|
override func startLoading() {
|
|
let response = GatewayStub.shared.take(request)
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + response.delay) { [weak self] in
|
|
guard let self, !self.lock.withLock({ self.stopped }) else { return }
|
|
if let errorCode = response.transportError {
|
|
self.client?.urlProtocol(self, didFailWithError: URLError(errorCode))
|
|
return
|
|
}
|
|
let http = HTTPURLResponse(
|
|
url: self.request.url!,
|
|
statusCode: response.status,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: ["Content-Type": response.contentType]
|
|
)!
|
|
self.client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed)
|
|
self.client?.urlProtocol(self, didLoad: response.body)
|
|
self.client?.urlProtocolDidFinishLoading(self)
|
|
}
|
|
}
|
|
|
|
override func stopLoading() {
|
|
lock.withLock { stopped = true }
|
|
}
|
|
}
|