534 lines
21 KiB
Swift
534 lines
21 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
@Suite(.serialized)
|
|
struct SakanaUsageFetcherTests {
|
|
@Test
|
|
func `billing html maps five hour and weekly windows`() throws {
|
|
let now = Date(timeIntervalSince1970: 1_782_222_000)
|
|
let usage = try SakanaUsageFetcher.parseBillingHTML(
|
|
Self.billingHTML,
|
|
now: now).toUsageSnapshot()
|
|
|
|
#expect(usage.primary?.usedPercent == 92)
|
|
#expect(usage.primary?.windowMinutes == 300)
|
|
#expect(usage.primary?.resetsAt == Self.date(year: 2026, month: 6, day: 23, hour: 14, minute: 53))
|
|
#expect(usage.primary?.resetDescription == nil)
|
|
#expect(usage.secondary?.usedPercent == 32)
|
|
#expect(usage.secondary?.windowMinutes == 10080)
|
|
#expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 0, minute: 0))
|
|
#expect(usage.secondary?.resetDescription == nil)
|
|
#expect(usage.identity?.providerID == .sakana)
|
|
#expect(usage.identity?.loginMethod == "Standard $20/mo")
|
|
#expect(usage.updatedAt == now)
|
|
}
|
|
|
|
@Test
|
|
func `fetch sends normalized cookie header to billing endpoint`() async throws {
|
|
let transport = SakanaScriptedTransport(statusCode: 200, body: Self.billingHTML)
|
|
|
|
let snapshot = try await SakanaUsageFetcher.fetchUsage(
|
|
cookieHeader: "Cookie: session=abc; theme=dark",
|
|
session: transport,
|
|
now: Date(timeIntervalSince1970: 0))
|
|
let requests = await transport.capturedRequestsSnapshot()
|
|
let request = requests.first { $0.url == "https://console.sakana.ai/billing" }
|
|
|
|
#expect(snapshot.fiveHour?.usedPercent == 92)
|
|
#expect(request?.url == "https://console.sakana.ai/billing")
|
|
#expect(request?.method == "GET")
|
|
#expect(request?.cookie == "session=abc; theme=dark")
|
|
#expect(request?.acceptLanguage == "en-US,en;q=0.9")
|
|
}
|
|
|
|
@Test
|
|
func `fetches pay as you go concurrently and merges the credit balance`() async throws {
|
|
let transport = SakanaScriptedTransport(
|
|
statusCode: 200,
|
|
body: Self.billingHTML,
|
|
overridesByURL: [
|
|
"https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML),
|
|
],
|
|
billingWaitsForPayAsYouGo: true)
|
|
|
|
let snapshot = try await SakanaUsageFetcher.fetchUsage(
|
|
cookieHeader: "session=abc",
|
|
session: transport,
|
|
now: Date(timeIntervalSince1970: 0))
|
|
let requests = await transport.capturedRequestsSnapshot()
|
|
|
|
#expect(snapshot.fiveHour?.usedPercent == 92)
|
|
#expect(snapshot.payAsYouGo?.creditBalance == 12.34)
|
|
#expect(snapshot.payAsYouGo?.periodUsageTotal == 5.67)
|
|
#expect(snapshot.payAsYouGo?.periodLabel == "Jun 02, 2026 - Jul 01, 2026")
|
|
#expect(requests.count == 2)
|
|
let payAsYouGoRequest = requests.first { $0.url == "https://console.sakana.ai/billing?tab=payAsYouGo" }
|
|
#expect(payAsYouGoRequest?.cookie == "session=abc")
|
|
|
|
let usage = snapshot.toUsageSnapshot()
|
|
#expect(usage.sakanaPayAsYouGo?.balanceDetail == "$12.34")
|
|
}
|
|
|
|
@Test
|
|
func `quick pay as you go response can finish after primary within the shared budget`() async throws {
|
|
let transport = SakanaScriptedTransport(
|
|
statusCode: 200,
|
|
body: Self.billingHTML,
|
|
overridesByURL: [
|
|
"https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML),
|
|
],
|
|
payAsYouGoDelay: .milliseconds(20))
|
|
|
|
let snapshot = try await SakanaUsageFetcher.fetchUsage(
|
|
cookieHeader: "session=abc",
|
|
session: transport,
|
|
now: Date(timeIntervalSince1970: 0))
|
|
|
|
#expect(snapshot.fiveHour?.usedPercent == 92)
|
|
#expect(snapshot.payAsYouGo?.creditBalance == 12.34)
|
|
}
|
|
|
|
@Test
|
|
func `fetch skips the pay as you go request entirely when optional usage is disabled`() async throws {
|
|
let transport = SakanaScriptedTransport(
|
|
statusCode: 200,
|
|
body: Self.billingHTML,
|
|
overridesByURL: [
|
|
"https://console.sakana.ai/billing?tab=payAsYouGo": (200, Self.payAsYouGoHTML),
|
|
])
|
|
|
|
let snapshot = try await SakanaUsageFetcher.fetchUsage(
|
|
cookieHeader: "session=abc",
|
|
session: transport,
|
|
now: Date(timeIntervalSince1970: 0),
|
|
includeOptionalUsage: false)
|
|
let requests = await transport.capturedRequestsSnapshot()
|
|
|
|
#expect(snapshot.fiveHour?.usedPercent == 92)
|
|
#expect(snapshot.payAsYouGo == nil)
|
|
// Only the required subscription-quota request is made; disabling optional usage must not
|
|
// just discard the PAYG result, it must skip the network request entirely.
|
|
#expect(requests.count == 1)
|
|
#expect(requests.first?.url == "https://console.sakana.ai/billing")
|
|
}
|
|
|
|
@Test
|
|
func `pay as you go bounded fetch does not wait for an operation that ignores cancellation`() async throws {
|
|
let startedAt = ContinuousClock.now
|
|
|
|
let fetched = await SakanaUsageFetcher._boundedFetchPayAsYouGoForTesting(timeout: .milliseconds(20)) {
|
|
await withCheckedContinuation { continuation in
|
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
|
|
continuation.resume(returning: SakanaPayAsYouGoSnapshot(creditBalance: 9))
|
|
}
|
|
}
|
|
}
|
|
|
|
let elapsed = startedAt.duration(to: .now)
|
|
#expect(fetched == nil)
|
|
#expect(elapsed < .milliseconds(300))
|
|
|
|
try await Task.sleep(for: .milliseconds(550))
|
|
}
|
|
|
|
@Test
|
|
func `fetch tolerates a failing pay as you go request without failing the primary fetch`() async throws {
|
|
// Default response is a 500; only the primary billing URL is overridden to succeed, so the
|
|
// pay-as-you-go request (not present in the override map) falls through to that failure.
|
|
let transport = SakanaScriptedTransport(
|
|
statusCode: 500,
|
|
body: "boom",
|
|
overridesByURL: [
|
|
"https://console.sakana.ai/billing": (200, Self.billingHTML),
|
|
])
|
|
|
|
let snapshot = try await SakanaUsageFetcher.fetchUsage(
|
|
cookieHeader: "session=abc",
|
|
session: transport,
|
|
now: Date(timeIntervalSince1970: 0))
|
|
|
|
#expect(snapshot.fiveHour?.usedPercent == 92)
|
|
#expect(snapshot.payAsYouGo == nil)
|
|
}
|
|
|
|
@Test
|
|
func `slow pay as you go request never delays the primary quota result`() async throws {
|
|
let transport = SakanaScriptedTransport(
|
|
statusCode: 200,
|
|
body: Self.billingHTML,
|
|
billingWaitsForPayAsYouGo: true,
|
|
payAsYouGoBlocksUntilCancelled: true)
|
|
let startedAt = ContinuousClock.now
|
|
|
|
let snapshot = try await SakanaUsageFetcher.fetchUsage(
|
|
cookieHeader: "session=abc",
|
|
session: transport,
|
|
now: Date(timeIntervalSince1970: 0))
|
|
|
|
#expect(snapshot.fiveHour?.usedPercent == 92)
|
|
#expect(snapshot.payAsYouGo == nil)
|
|
#expect(startedAt.duration(to: .now) < .milliseconds(500))
|
|
for _ in 0..<1000 where await !(transport.didCancelPayAsYouGo()) {
|
|
await Task.yield()
|
|
}
|
|
#expect(await transport.didCancelPayAsYouGo())
|
|
}
|
|
|
|
@Test
|
|
func `required fetch failure cancels the concurrent pay as you go request`() async throws {
|
|
let transport = SakanaScriptedTransport(
|
|
statusCode: 401,
|
|
body: "expired",
|
|
billingWaitsForPayAsYouGo: true,
|
|
payAsYouGoBlocksUntilCancelled: true)
|
|
|
|
await #expect(throws: SakanaUsageError.loginRequired) {
|
|
_ = try await SakanaUsageFetcher.fetchUsage(
|
|
cookieHeader: "session=expired",
|
|
session: transport)
|
|
}
|
|
|
|
for _ in 0..<1000 where await !(transport.didCancelPayAsYouGo()) {
|
|
await Task.yield()
|
|
}
|
|
#expect(await transport.didCancelPayAsYouGo())
|
|
}
|
|
|
|
@Test
|
|
func `fetch rejects cross origin login redirect`() async throws {
|
|
let transport = try SakanaScriptedTransport(
|
|
statusCode: 200,
|
|
body: Self.billingHTML,
|
|
responseURL: #require(URL(string: "https://auth.sakana.ai")?.appending(path: "login")))
|
|
|
|
await #expect(throws: SakanaUsageError.loginRequired) {
|
|
_ = try await SakanaUsageFetcher.fetchUsage(
|
|
cookieHeader: "session=expired",
|
|
session: transport)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `fetch classifies blocked login redirect as login required`() async throws {
|
|
let transport = try SakanaScriptedTransport(
|
|
statusCode: 302,
|
|
body: "",
|
|
headers: ["Location": #require(URL(string: "https://auth.sakana.ai/login")).absoluteString])
|
|
|
|
await #expect(throws: SakanaUsageError.loginRequired) {
|
|
_ = try await SakanaUsageFetcher.fetchUsage(
|
|
cookieHeader: "session=expired",
|
|
session: transport)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `fetch does not expose error response body`() async {
|
|
let transport = SakanaScriptedTransport(statusCode: 500, body: "private account response")
|
|
|
|
await #expect(throws: SakanaUsageError.apiError(500)) {
|
|
_ = try await SakanaUsageFetcher.fetchUsage(
|
|
cookieHeader: "session=abc",
|
|
session: transport)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `missing usage windows throws parse error`() {
|
|
#expect(throws: SakanaUsageError.parseFailed("Usage limit windows were not found.")) {
|
|
_ = try SakanaUsageFetcher.parseBillingHTML("<main>Billing</main>")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `out of range percentages are rejected`() {
|
|
let html = Self.billingHTML
|
|
.replacing("92% used", with: "101% used")
|
|
.replacing("32% used", with: "999% used")
|
|
|
|
#expect(throws: SakanaUsageError.parseFailed("Invalid 5-hour usage percentage.")) {
|
|
_ = try SakanaUsageFetcher.parseBillingHTML(html)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `invalid primary percentage rejects otherwise valid weekly response`() {
|
|
let html = Self.billingHTML.replacing("92% used", with: "101% used")
|
|
|
|
#expect(throws: SakanaUsageError.parseFailed("Invalid 5-hour usage percentage.")) {
|
|
_ = try SakanaUsageFetcher.parseBillingHTML(html)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `unparsed reset date does not become reset description`() throws {
|
|
let usage = try SakanaUsageFetcher.parseBillingHTML(
|
|
Self.billingHTML.replacing("June 23, 2026 at 2:53 PM", with: "soon-ish")).toUsageSnapshot()
|
|
|
|
#expect(usage.primary?.usedPercent == 92)
|
|
#expect(usage.primary?.resetsAt == nil)
|
|
#expect(usage.primary?.resetDescription == nil)
|
|
}
|
|
|
|
@Test
|
|
func `window without reset line still maps percent`() throws {
|
|
let html = Self.billingHTML.replacing(
|
|
"<p class=\"text-muted-foreground text-xs tabular-nums\">Resets on June 23, 2026 at 2:53 PM</p>",
|
|
with: "")
|
|
let usage = try SakanaUsageFetcher.parseBillingHTML(html).toUsageSnapshot()
|
|
|
|
#expect(usage.primary?.usedPercent == 92)
|
|
#expect(usage.primary?.windowMinutes == 300)
|
|
#expect(usage.primary?.resetsAt == nil)
|
|
#expect(usage.primary?.resetDescription == nil)
|
|
#expect(usage.secondary?.usedPercent == 32)
|
|
#expect(usage.secondary?.resetsAt == Self.date(year: 2026, month: 6, day: 29, hour: 0, minute: 0))
|
|
}
|
|
|
|
@Test
|
|
func `missing window percent rejects response without reading next quota window`() {
|
|
let html = Self.billingHTML.replacing(
|
|
"<p class=\"text-muted-foreground text-sm\">92% used</p>",
|
|
with: "")
|
|
|
|
#expect(throws: SakanaUsageError.parseFailed("Invalid 5-hour usage percentage.")) {
|
|
_ = try SakanaUsageFetcher.parseBillingHTML(html)
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `reset date is parsed as UTC regardless of the device's local timezone`() throws {
|
|
// The console always server-renders "Resets on <date>" in UTC (the client corrects it to
|
|
// the viewer's local time only after JS hydration, which this HTML-only fetcher never
|
|
// runs). Regression coverage for steipete/CodexBar#1826: force the process default far
|
|
// from UTC (UTC+14) so this fails if TimeZone.current ever leaks back into the parser --
|
|
// on a UTC CI runner the pre-fix TimeZone.current code would coincidentally still produce
|
|
// the right answer, so this test would not have caught the original bug without the
|
|
// override.
|
|
let originalTimeZone = NSTimeZone.default
|
|
NSTimeZone.default = TimeZone(secondsFromGMT: 14 * 60 * 60)!
|
|
defer { NSTimeZone.default = originalTimeZone }
|
|
|
|
let usage = try SakanaUsageFetcher.parseBillingHTML(Self.billingHTML).toUsageSnapshot()
|
|
|
|
#expect(usage.primary?.resetsAt == Self.date(year: 2026, month: 6, day: 23, hour: 14, minute: 53))
|
|
#expect(usage.primary?.resetsAt?.timeIntervalSince1970 == 1_782_226_380)
|
|
}
|
|
|
|
@Test
|
|
func `pay as you go html maps credit balance usage total and date range label`() {
|
|
let usage = SakanaUsageFetcher.parsePayAsYouGoHTML(Self.payAsYouGoHTML)
|
|
|
|
#expect(usage?.creditBalance == 12.34)
|
|
#expect(usage?.periodUsageTotal == 5.67)
|
|
#expect(usage?.periodLabel == "Jun 02, 2026 - Jul 01, 2026")
|
|
#expect(usage?.balanceDetail == "$12.34")
|
|
}
|
|
|
|
@Test
|
|
func `pay as you go html without usage total still maps credit balance`() {
|
|
let html = Self.payAsYouGoHTML.replacing(
|
|
"<span class=\"text-muted-foreground text-sm\">Total<!-- -->: <!-- -->$5.67</span>",
|
|
with: "")
|
|
|
|
let usage = SakanaUsageFetcher.parsePayAsYouGoHTML(html)
|
|
|
|
#expect(usage?.creditBalance == 12.34)
|
|
#expect(usage?.periodUsageTotal == nil)
|
|
}
|
|
|
|
@Test
|
|
func `billing html without a pay as you go tab returns nil`() {
|
|
#expect(SakanaUsageFetcher.parsePayAsYouGoHTML(Self.billingHTML) == nil)
|
|
}
|
|
|
|
@Test
|
|
func `sakana usage snapshot carries pay as you go through to the usage snapshot mapping`() {
|
|
let payAsYouGo = SakanaPayAsYouGoSnapshot(creditBalance: 9, periodUsageTotal: 1.5, periodLabel: "Last 30 days")
|
|
let snapshot = SakanaUsageSnapshot(
|
|
planName: "Standard",
|
|
priceLabel: "$20/mo",
|
|
fiveHour: .init(usedPercent: 10, resetsAt: nil),
|
|
weekly: .init(usedPercent: 20, resetsAt: nil),
|
|
payAsYouGo: payAsYouGo)
|
|
|
|
let usage = snapshot.toUsageSnapshot()
|
|
|
|
#expect(usage.sakanaPayAsYouGo?.creditBalance == 9)
|
|
#expect(usage.sakanaPayAsYouGo?.balanceDetail == "$9.00")
|
|
}
|
|
|
|
private static func date(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date? {
|
|
var calendar = Calendar(identifier: .gregorian)
|
|
calendar.timeZone = TimeZone(identifier: "UTC")!
|
|
return calendar.date(from: DateComponents(
|
|
year: year,
|
|
month: month,
|
|
day: day,
|
|
hour: hour,
|
|
minute: minute))
|
|
}
|
|
|
|
/// Raw server response values are UTC; browser hydration localizes them afterward.
|
|
private static let billingHTML = """
|
|
<main>
|
|
<div data-slot="card-title"><span>Standard</span><span>$20/mo</span></div>
|
|
<div data-slot="card-title">Usage limit</div>
|
|
<p class="font-medium text-sm">5-hour</p>
|
|
<p class="text-muted-foreground text-xs tabular-nums">Resets on June 23, 2026 at 2:53 PM</p>
|
|
<button aria-label="The 5-hour window starts with your first request."></button>
|
|
<p class="text-muted-foreground text-sm">92% used</p>
|
|
<p class="font-medium text-sm">Weekly</p>
|
|
<p class="text-muted-foreground text-xs tabular-nums">Resets on June 29, 2026 at 12:00 AM</p>
|
|
<button aria-label="Weekly usage resets every Monday at 00:00 UTC."></button>
|
|
<p class="text-muted-foreground text-sm">32% used</p>
|
|
</main>
|
|
"""
|
|
|
|
/// Minimal reproduction of the "Pay as you go" tab, which the live console only server-renders
|
|
/// when the request includes `?tab=payAsYouGo`. The `<!-- -->` markers reproduce React's
|
|
/// hydration-boundary comments between separately interpolated JSX text nodes.
|
|
private static let payAsYouGoHTML = """
|
|
<main>
|
|
<h2 class="font-semibold text-base">Credit balance</h2>
|
|
<button aria-label="Credit updates may be delayed."></button>
|
|
<p class="font-semibold text-3xl tabular-nums">$12.34</p>
|
|
<button aria-label="Usage date range">Jun 02, 2026<!-- --> -<!-- --> <!-- -->Jul 01, 2026</button>
|
|
<h2 class="font-semibold">Usage</h2>
|
|
<span class="text-muted-foreground text-sm">Total<!-- -->: <!-- -->$5.67</span>
|
|
</main>
|
|
"""
|
|
}
|
|
|
|
private actor SakanaScriptedTransport: ProviderHTTPTransport {
|
|
struct CapturedRequest {
|
|
let url: String?
|
|
let method: String?
|
|
let cookie: String?
|
|
let acceptLanguage: String?
|
|
}
|
|
|
|
private let statusCode: Int
|
|
private let body: String
|
|
private let responseURL: URL?
|
|
private let headers: [String: String]
|
|
/// Per-URL response overrides (keyed by the full request URL string), used to stub the
|
|
/// subscription-tab and pay-as-you-go-tab requests independently. Falls back to
|
|
/// `(statusCode, body)` for any URL not present here.
|
|
private let overridesByURL: [String: (statusCode: Int, body: String)]
|
|
private let billingWaitsForPayAsYouGo: Bool
|
|
private let payAsYouGoBlocksUntilCancelled: Bool
|
|
private let payAsYouGoDelay: Duration?
|
|
private var capturedRequests: [CapturedRequest] = []
|
|
private var payAsYouGoStarted = false
|
|
private var payAsYouGoCompleted = false
|
|
private var payAsYouGoWasCancelled = false
|
|
private var payAsYouGoStartWaiters: [CheckedContinuation<Void, Never>] = []
|
|
private var payAsYouGoCompletionWaiters: [CheckedContinuation<Void, Never>] = []
|
|
|
|
init(
|
|
statusCode: Int,
|
|
body: String,
|
|
responseURL: URL? = nil,
|
|
headers: [String: String] = [:],
|
|
overridesByURL: [String: (statusCode: Int, body: String)] = [:],
|
|
billingWaitsForPayAsYouGo: Bool = false,
|
|
payAsYouGoBlocksUntilCancelled: Bool = false,
|
|
payAsYouGoDelay: Duration? = nil)
|
|
{
|
|
self.statusCode = statusCode
|
|
self.body = body
|
|
self.responseURL = responseURL
|
|
self.headers = headers
|
|
self.overridesByURL = overridesByURL
|
|
self.billingWaitsForPayAsYouGo = billingWaitsForPayAsYouGo
|
|
self.payAsYouGoBlocksUntilCancelled = payAsYouGoBlocksUntilCancelled
|
|
self.payAsYouGoDelay = payAsYouGoDelay
|
|
}
|
|
|
|
func lastCapturedRequest() -> CapturedRequest? {
|
|
self.capturedRequests.last
|
|
}
|
|
|
|
func capturedRequestsSnapshot() -> [CapturedRequest] {
|
|
self.capturedRequests
|
|
}
|
|
|
|
func didCancelPayAsYouGo() -> Bool {
|
|
self.payAsYouGoWasCancelled
|
|
}
|
|
|
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
|
let isPayAsYouGo = request.url?.query == "tab=payAsYouGo"
|
|
if isPayAsYouGo {
|
|
self.markPayAsYouGoStarted()
|
|
if let payAsYouGoDelay {
|
|
try await Task.sleep(for: payAsYouGoDelay)
|
|
}
|
|
if self.payAsYouGoBlocksUntilCancelled {
|
|
do {
|
|
try await Task.sleep(for: .seconds(30))
|
|
} catch {
|
|
self.payAsYouGoWasCancelled = true
|
|
throw error
|
|
}
|
|
}
|
|
} else if self.billingWaitsForPayAsYouGo {
|
|
await self.waitForPayAsYouGoStart()
|
|
if !self.payAsYouGoBlocksUntilCancelled {
|
|
await self.waitForPayAsYouGoCompletion()
|
|
}
|
|
}
|
|
|
|
self.capturedRequests.append(CapturedRequest(
|
|
url: request.url?.absoluteString,
|
|
method: request.httpMethod,
|
|
cookie: request.value(forHTTPHeaderField: "Cookie"),
|
|
acceptLanguage: request.value(forHTTPHeaderField: "Accept-Language")))
|
|
|
|
let override = request.url.flatMap { self.overridesByURL[$0.absoluteString] }
|
|
let (responseStatusCode, responseBody) = override ?? (self.statusCode, self.body)
|
|
let response = HTTPURLResponse(
|
|
url: self.responseURL ?? request.url!,
|
|
statusCode: responseStatusCode,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: self.headers)!
|
|
if isPayAsYouGo {
|
|
self.markPayAsYouGoCompleted()
|
|
}
|
|
return (Data(responseBody.utf8), response)
|
|
}
|
|
|
|
private func waitForPayAsYouGoStart() async {
|
|
guard !self.payAsYouGoStarted else { return }
|
|
await withCheckedContinuation { continuation in
|
|
self.payAsYouGoStartWaiters.append(continuation)
|
|
}
|
|
}
|
|
|
|
private func waitForPayAsYouGoCompletion() async {
|
|
guard !self.payAsYouGoCompleted else { return }
|
|
await withCheckedContinuation { continuation in
|
|
self.payAsYouGoCompletionWaiters.append(continuation)
|
|
}
|
|
}
|
|
|
|
private func markPayAsYouGoStarted() {
|
|
self.payAsYouGoStarted = true
|
|
let waiters = self.payAsYouGoStartWaiters
|
|
self.payAsYouGoStartWaiters.removeAll()
|
|
waiters.forEach { $0.resume() }
|
|
}
|
|
|
|
private func markPayAsYouGoCompleted() {
|
|
self.payAsYouGoCompleted = true
|
|
let waiters = self.payAsYouGoCompletionWaiters
|
|
self.payAsYouGoCompletionWaiters.removeAll()
|
|
waiters.forEach { $0.resume() }
|
|
}
|
|
}
|