Files
hapi/ios/Packages/HapiKit/Tests/HapiClientTests/TestSupport.swift
T
weishu 1f0ec6184d feat(ios): HapiClient API transport + auth (A-M1b)
APIClient (final class, Sendable) with typed endpoints for the M2/M3 REST
surface behind an HTTPPerforming seam; actor AuthManager with single-flight
JWT refresh (POST /api/auth), proactive refresh 10 min before exp, terminal
authFailed state, and 401 -> refresh -> retry-once wiring per
docs/api/client-contract/auth.md; Keychain credential store (per-hub records
under run.hapi.companion) with CredentialStoring seam + in-memory double;
HubRegistry (ordered hubs + active hub in injectable UserDefaults, origin
normalization); payload-only JWT decoding; APIError {status, code, body}
parsing per errors.md; minimal multipart builder for M4c dictation; 256 MB
URLCache session for generated images.

Request/response wire models (SendMessageRequest, PermissionApproveRequest,
SpawnRequest/SpawnResponse, MessagesQuery, envelopes, RPC-wrapped shapes)
join HapiProtocol/Models mirroring shared/src/apiTypes.ts.

swift-testing coverage: JWT decode (padding/garbage/hostile exp), auth
single-flight (8 concurrent callers -> one exchange), 401 retry-once and
terminal paths, APIError body parsing, endpoint request construction
byte-checks (cursor queries, deliveryMode body, answers formats, explicit
null model reset) via a recording performer, hub URL normalization,
multipart bytes.
2026-08-17 15:20:03 +08:00

128 lines
4.1 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Testing
// MARK: - JWT construction
func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
/// A well-formed (unsigned) JWT with the hub's payload shape.
func makeJWT(uid: Int = 1, ns: String = "default", exp: Int? = nil) -> String {
var fields = ["\"uid\":\(uid)", "\"ns\":\"\(ns)\""]
if let exp {
fields.append("\"exp\":\(exp)")
}
let payload = "{\(fields.joined(separator: ","))}"
return "eyJhbGciOiJIUzI1NiJ9.\(base64URLEncode(Data(payload.utf8))).c2ln"
}
// MARK: - Error capture
/// Runs `body` and returns the error it threw, or `nil` when it succeeded.
func capturedError<T>(_ body: () async throws -> T) async -> (any Error)? {
do {
_ = try await body()
return nil
} catch {
return error
}
}
// MARK: - Scripted HTTP performer
/// Records every request and answers from a FIFO of stubbed responses
/// (falling back to a configurable default when the queue is empty).
actor RecordingPerformer: HTTPPerforming {
struct Stub: Sendable {
var status: Int = 200
var body: Data = Data("{}".utf8)
var headers: [String: String] = [:]
}
private(set) var requests: [URLRequest] = []
private var queue: [Stub] = []
private var fallback = Stub()
private var delayNanoseconds: UInt64 = 0
func enqueue(status: Int = 200, json: String = "{}", headers: [String: String] = [:]) {
queue.append(Stub(status: status, body: Data(json.utf8), headers: headers))
}
func setFallback(status: Int, json: String) {
fallback = Stub(status: status, body: Data(json.utf8))
}
/// Delay before answering — lets concurrent callers pile up on one
/// in-flight exchange.
func setDelay(nanoseconds: UInt64) {
delayNanoseconds = nanoseconds
}
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
requests.append(request)
if delayNanoseconds > 0 {
try await Task.sleep(nanoseconds: delayNanoseconds)
}
let stub = queue.isEmpty ? fallback : queue.removeFirst()
guard let url = request.url,
let response = HTTPURLResponse(
url: url,
statusCode: stub.status,
httpVersion: "HTTP/1.1",
headerFields: stub.headers
) else {
throw URLError(.badURL)
}
return (stub.body, response)
}
}
// MARK: - Wiring
let testHubURLString = "https://hub.test"
let testEpochSeconds = 1_700_000_000
let testNow = Date(timeIntervalSince1970: TimeInterval(testEpochSeconds))
struct Harness {
let baseURL: URL
let performer: RecordingPerformer
let store: InMemoryCredentialStore
let auth: AuthManager
let client: APIClient
}
/// Builds the full client stack against a `RecordingPerformer`.
/// `jwt` (when given) is persisted in the credential record, which is how
/// `AuthManager` picks up an initial token.
func makeHarness(
paired: Bool = true,
accessToken: String = "access-token",
jwt: String? = nil,
now: Date = testNow
) throws -> Harness {
let baseURL = try #require(URL(string: testHubURLString))
let performer = RecordingPerformer()
let store = InMemoryCredentialStore()
if paired {
try store.store(HubCredentials(hubUrl: testHubURLString, accessToken: accessToken, jwt: jwt))
}
let auth = AuthManager(baseURL: baseURL, credentialStore: store, performer: performer, now: { now })
let client = APIClient(baseURL: baseURL, authManager: auth, performer: performer)
return Harness(baseURL: baseURL, performer: performer, store: store, auth: auth, client: client)
}
/// A JWT that will not expire during a test.
func freshJWT(ns: String = "default") -> String {
makeJWT(ns: ns, exp: testEpochSeconds + 4 * 3600)
}
func authResponseJSON(token: String) -> String {
"{\"token\":\"\(token)\",\"user\":{\"id\":1}}"
}