api-swift / Tests / IrdAPITests / Helpers / Fakes.swift
679 words
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
import Foundation
import Testing
@testable import IrdAPI

/// Scripted HTTP: a handler answers each request; every request is recorded.
final class FakeHTTP: HTTPTransport, @unchecked Sendable {
    private let lock = NSLock()
    private(set) var requests: [URLRequest] = []
    private var queue: [HTTPResponse] = []
    private var handler: (@Sendable (URLRequest) async throws -> HTTPResponse)?

    init(_ handler: @escaping @Sendable (URLRequest) async throws -> HTTPResponse = { _ in json(200, "{}") }) {
        self.handler = handler
    }

    /// Replies consumed in order; the last one repeats.
    init(script: [HTTPResponse]) { queue = script }

    func send(_ request: URLRequest) async throws -> HTTPResponse {
        let scripted: HTTPResponse? = lock.withLock {
            requests.append(request)
            guard !queue.isEmpty else { return nil }
            return queue.count == 1 ? queue[0] : queue.removeFirst()
        }
        if let scripted { return scripted }
        guard let handler else { return json(200, "{}") }
        return try await handler(request)
    }

    var count: Int { lock.withLock { requests.count } }
    var last: URLRequest? { lock.withLock { requests.last } }
    /// `["GET /a", "POST /b?x=1"]` — method plus path and query of every request.
    var trail: [String] { lock.withLock { requests.map { "\($0.httpMethod ?? "?") \(pathAndQuery($0))" } } }
}

func pathAndQuery(_ r: URLRequest) -> String {
    guard let url = r.url else { return "?" }
    var s = url.path
    if let q = url.query { s += "?" + q }
    return s
}

func json(_ status: Int, _ body: String) -> HTTPResponse { HTTPResponse(status: status, body: Data(body.utf8)) }

/// The JSON body a request carries, if any.
func bodyJSON(_ r: URLRequest?) -> JSONValue? {
    guard let data = r?.httpBody else { return nil }
    return try? JSONValue.parse(data)
}

/// A client on a fake transport with an instant clock.
func captureClient(apiKey: String? = "ird_k", script: [HTTPResponse] = [json(200, "{}")],
                   clock: any IrdClock = InstantClock(), headers: [String: String] = [:]) -> (IrdClient, FakeHTTP) {
    let http = FakeHTTP(script: script)
    var c = Configuration()
    if let apiKey { c.credential = StaticCredential(apiKey) }
    c.transport = http
    c.clock = clock
    c.headers = headers
    return (IrdClient(configuration: c), http)
}

/// Never sleeps; time stands still.
struct InstantClock: IrdClock {
    var now: Duration { .zero }
    func sleep(for duration: Duration) async throws {}
}

/// A manually advanced clock. Sleepers wake when `advance` moves time past their
/// deadline, and a cancelled sleeper is removed (like `Task.sleep`, it throws).
final class TestClock: IrdClock, @unchecked Sendable {
    private let lock = NSLock()
    private var time: Duration = .zero
    private var waiters: [(id: UUID, deadline: Duration, c: CheckedContinuation<Void, any Error>)] = []
    private(set) var sleeps: [Duration] = []

    var now: Duration { lock.withLock { time } }

    func sleep(for duration: Duration) async throws {
        let id = UUID()
        try await withTaskCancellationHandler {
            try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, any Error>) in
                let immediate: Bool = lock.withLock {
                    sleeps.append(duration)
                    if duration <= .zero { return true }
                    waiters.append((id, time + duration, c))
                    return false
                }
                if immediate { c.resume() }
            }
        } onCancel: {
            let cancelled: CheckedContinuation<Void, any Error>? = lock.withLock {
                guard let i = waiters.firstIndex(where: { $0.id == id }) else { return nil }
                return waiters.remove(at: i).c
            }
            cancelled?.resume(throwing: CancellationError())
        }
    }

    func advance(by d: Duration) {
        let due: [CheckedContinuation<Void, any Error>] = lock.withLock {
            time += d
            let fired = waiters.filter { $0.deadline <= time }.map(\.c)
            waiters.removeAll { $0.deadline <= time }
            return fired
        }
        for c in due { c.resume() }
    }

    var sleeperCount: Int { lock.withLock { waiters.count } }
    var recordedSleeps: [Duration] { lock.withLock { sleeps } }
}

/// Runs `body` and returns the `IrdError` it threw, or nil.
func caught(_ body: () async throws -> Void) async -> IrdError? {
    do { try await body(); return nil } catch let e as IrdError { return e } catch { return nil }
}

/// Polls until `condition` holds or `timeout` (real time) elapses.
func eventually(timeout: Duration = .seconds(3), _ condition: @Sendable () async -> Bool) async -> Bool {
    let deadline = ContinuousClock.now + timeout
    while ContinuousClock.now < deadline {
        if await condition() { return true }
        try? await Task.sleep(for: .milliseconds(5))
    }
    return await condition()
}

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/api-swift.git