435 lines
16 KiB
Swift
435 lines
16 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
struct DoubaoUsageSnapshotTests {
|
|
@Test
|
|
func `normal usage with both headers present and non-empty reports correct percent`() {
|
|
let snapshot = DoubaoUsageSnapshot(
|
|
remainingRequests: 750,
|
|
limitRequests: 1000,
|
|
resetTime: nil,
|
|
updatedAt: Date(),
|
|
apiKeyValid: true)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
#expect(usage.primary?.usedPercent == 25)
|
|
#expect(usage.primary?.resetDescription == "250/1000 requests")
|
|
}
|
|
|
|
@Test
|
|
func `boundary normal usage at near-full reports correct percent`() {
|
|
let snapshot = DoubaoUsageSnapshot(
|
|
remainingRequests: 1,
|
|
limitRequests: 1000,
|
|
resetTime: nil,
|
|
updatedAt: Date(),
|
|
apiKeyValid: true)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
#expect(usage.primary?.usedPercent == 99.9)
|
|
#expect(usage.primary?.resetDescription == "999/1000 requests")
|
|
}
|
|
|
|
@Test
|
|
func `unreliable headers omit the request limit window`() {
|
|
let snapshot = DoubaoUsageSnapshot(
|
|
remainingRequests: 0,
|
|
limitRequests: 1000,
|
|
resetTime: nil,
|
|
updatedAt: Date(),
|
|
apiKeyValid: true,
|
|
requestLimitsReliable: false)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
#expect(usage.primary == nil)
|
|
#expect(usage.rateLimitsUnavailable(for: .doubao))
|
|
}
|
|
|
|
@Test
|
|
func `explicit rate limit with zero remaining reports exhausted quota`() {
|
|
let snapshot = DoubaoUsageSnapshot(
|
|
remainingRequests: 0,
|
|
limitRequests: 1000,
|
|
resetTime: nil,
|
|
updatedAt: Date(),
|
|
apiKeyValid: true)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
#expect(usage.primary?.usedPercent == 100)
|
|
#expect(usage.primary?.resetDescription == "1000/1000 requests")
|
|
}
|
|
|
|
@Test
|
|
func `both headers missing but key valid omit the request limit window`() {
|
|
let snapshot = DoubaoUsageSnapshot(
|
|
remainingRequests: 0,
|
|
limitRequests: 0,
|
|
resetTime: nil,
|
|
updatedAt: Date(),
|
|
apiKeyValid: true)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
#expect(usage.primary == nil)
|
|
#expect(usage.rateLimitsUnavailable(for: .doubao))
|
|
}
|
|
|
|
@Test
|
|
func `invalid key with no headers reports No usage data`() {
|
|
let snapshot = DoubaoUsageSnapshot(
|
|
remainingRequests: 0,
|
|
limitRequests: 0,
|
|
resetTime: nil,
|
|
updatedAt: Date(),
|
|
apiKeyValid: false)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
#expect(usage.primary?.usedPercent == 0)
|
|
#expect(usage.primary?.resetDescription == "No usage data")
|
|
}
|
|
|
|
@Test
|
|
func `provider identity is correctly tagged as doubao`() {
|
|
let snapshot = DoubaoUsageSnapshot(
|
|
remainingRequests: 500,
|
|
limitRequests: 1000,
|
|
resetTime: nil,
|
|
updatedAt: Date(),
|
|
apiKeyValid: true)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
#expect(usage.identity?.providerID == .doubao)
|
|
#expect(usage.identity?.accountEmail == nil)
|
|
}
|
|
}
|
|
|
|
struct DoubaoUsageFetcherTests {
|
|
@Test
|
|
func `coding plan response maps session weekly and monthly windows`() throws {
|
|
let data = Data(
|
|
"""
|
|
{
|
|
"ResponseMetadata": {
|
|
"Action": "GetCodingPlanUsage",
|
|
"Version": "2024-01-01",
|
|
"Service": "ark",
|
|
"Region": "cn-beijing"
|
|
},
|
|
"Result": {
|
|
"Status": "Running",
|
|
"UpdateTimestamp": 1782226444,
|
|
"QuotaUsage": [
|
|
{"Level":"session","Percent":0.116,"ResetTimestamp":1782226478},
|
|
{"Level":"weekly","Percent":3.182143,"ResetTimestamp":1782662400},
|
|
{"Level":"monthly","Percent":7.5730535,"ResetTimestamp":1782403199}
|
|
]
|
|
}
|
|
}
|
|
""".utf8)
|
|
|
|
let usage = try DoubaoUsageFetcher.decodeCodingPlanUsage(from: data).toUsageSnapshot(
|
|
updatedAt: Date(timeIntervalSince1970: 0))
|
|
|
|
#expect(usage.primary?.usedPercent == 0.116)
|
|
#expect(usage.primary?.windowMinutes == 300)
|
|
#expect(usage.primary?.resetsAt == Date(timeIntervalSince1970: 1_782_226_478))
|
|
#expect(usage.primary?.resetDescription == nil)
|
|
#expect(usage.secondary?.usedPercent == 3.182143)
|
|
#expect(usage.secondary?.windowMinutes == 10080)
|
|
#expect(usage.tertiary?.usedPercent == 7.5730535)
|
|
#expect(usage.tertiary?.windowMinutes == 43200)
|
|
#expect(usage.identity?.providerID == .doubao)
|
|
#expect(usage.identity?.loginMethod == "Running")
|
|
}
|
|
|
|
@Test
|
|
func `coding plan response ignores missing reset sentinels`() throws {
|
|
let fallbackUpdatedAt = Date(timeIntervalSince1970: 42)
|
|
let data = Data(
|
|
"""
|
|
{
|
|
"Result": {
|
|
"Status": "Running",
|
|
"UpdateTimestamp": 0,
|
|
"QuotaUsage": [
|
|
{"Level":"session","Percent":12.5,"ResetTimestamp":0},
|
|
{"Level":"weekly","Percent":24,"ResetTimestamp":-1}
|
|
]
|
|
}
|
|
}
|
|
""".utf8)
|
|
|
|
let usage = try DoubaoUsageFetcher.decodeCodingPlanUsage(from: data).toUsageSnapshot(
|
|
updatedAt: fallbackUpdatedAt)
|
|
|
|
#expect(usage.updatedAt == fallbackUpdatedAt)
|
|
#expect(usage.primary?.usedPercent == 12.5)
|
|
#expect(usage.primary?.resetsAt == nil)
|
|
#expect(usage.primary?.resetDescription == nil)
|
|
#expect(usage.secondary?.usedPercent == 24)
|
|
#expect(usage.secondary?.resetsAt == nil)
|
|
#expect(usage.secondary?.resetDescription == nil)
|
|
}
|
|
|
|
@Test
|
|
func `coding plan fetch signs volcengine request`() async throws {
|
|
let transport = DoubaoScriptedTransport(results: [
|
|
.rawResponse(
|
|
statusCode: 200,
|
|
body: """
|
|
{
|
|
"Result": {
|
|
"Status": "Running",
|
|
"UpdateTimestamp": 1782226444,
|
|
"QuotaUsage": [
|
|
{"Level":"session","Percent":12.5,"ResetTimestamp":1782226478}
|
|
]
|
|
}
|
|
}
|
|
"""),
|
|
])
|
|
let credentials = DoubaoCodingPlanCredentials(
|
|
accessKeyID: "AKLTTEST",
|
|
secretAccessKey: "secret",
|
|
region: "cn-beijing")
|
|
let date = Date(timeIntervalSince1970: 1_781_654_400)
|
|
|
|
let snapshot = try await DoubaoUsageFetcher.fetchCodingPlanUsage(
|
|
credentials: credentials,
|
|
session: transport,
|
|
date: date)
|
|
let request = await transport.lastCapturedRequest()
|
|
|
|
#expect(snapshot.toUsageSnapshot().primary?.usedPercent == 12.5)
|
|
#expect(request?.method == "POST")
|
|
#expect(request?.url == "https://open.volcengineapi.com/?Action=GetCodingPlanUsage&Version=2024-01-01")
|
|
#expect(request?.host == "open.volcengineapi.com")
|
|
#expect(request?.date == "20260617T000000Z")
|
|
#expect(request?.contentSHA256 ==
|
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
|
#expect(request?.authorization?.contains(
|
|
"HMAC-SHA256 Credential=AKLTTEST/20260617/cn-beijing/ark/request") == true)
|
|
#expect(request?.authorization?.contains(
|
|
"SignedHeaders=content-type;host;x-content-sha256;x-date") == true)
|
|
}
|
|
|
|
@Test
|
|
func `coding plan fetch surfaces volcengine access denied error`() async {
|
|
let transport = DoubaoScriptedTransport(results: [
|
|
.rawResponse(
|
|
statusCode: 403,
|
|
body: """
|
|
{
|
|
"ResponseMetadata": {
|
|
"Action": "GetCodingPlanUsage",
|
|
"Error": {
|
|
"CodeN": 100013,
|
|
"Code": "AccessDenied",
|
|
"Message": "User is not authorized to perform: ark:GetCodingPlanUsage"
|
|
}
|
|
}
|
|
}
|
|
"""),
|
|
])
|
|
let credentials = DoubaoCodingPlanCredentials(
|
|
accessKeyID: "AKLTTEST",
|
|
secretAccessKey: "secret",
|
|
region: "cn-beijing")
|
|
|
|
await #expect {
|
|
_ = try await DoubaoUsageFetcher.fetchCodingPlanUsage(
|
|
credentials: credentials,
|
|
session: transport,
|
|
date: Date(timeIntervalSince1970: 1_781_654_400))
|
|
} throws: { error in
|
|
guard case let DoubaoUsageError.apiError(code, message) = error else { return false }
|
|
return code == 403
|
|
&& message.contains("AccessDenied")
|
|
&& message.contains("ark:GetCodingPlanUsage")
|
|
&& !message.contains("bytes")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `repeated successful zero remaining responses omit unknown request limit`() async throws {
|
|
let transport = DoubaoScriptedTransport(results: [
|
|
.response(statusCode: 200, limit: 1000, remaining: 0),
|
|
.response(statusCode: 200, limit: 1000, remaining: 0),
|
|
])
|
|
|
|
let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
|
|
#expect(usage.primary == nil)
|
|
#expect(usage.rateLimitsUnavailable(for: .doubao))
|
|
#expect(await transport.requestCount() == 2)
|
|
}
|
|
|
|
@Test
|
|
func `successful final request followed by rate limit reports exhausted quota`() async throws {
|
|
let transport = DoubaoScriptedTransport(results: [
|
|
.response(statusCode: 200, limit: 1000, remaining: 0),
|
|
.response(statusCode: 429, limit: 1000, remaining: 0),
|
|
])
|
|
|
|
let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
|
|
#expect(usage.primary?.usedPercent == 100)
|
|
#expect(usage.primary?.resetDescription == "1000/1000 requests")
|
|
#expect(await transport.requestCount() == 2)
|
|
}
|
|
|
|
@Test
|
|
func `headerless rate limit confirmation preserves exhausted quota`() async throws {
|
|
let transport = DoubaoScriptedTransport(results: [
|
|
.response(statusCode: 200, limit: 1000, remaining: 0),
|
|
.response(statusCode: 429, limit: nil, remaining: nil),
|
|
])
|
|
|
|
let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
|
|
#expect(usage.primary?.usedPercent == 100)
|
|
#expect(usage.primary?.resetDescription == "1000/1000 requests")
|
|
#expect(await transport.requestCount() == 2)
|
|
}
|
|
|
|
@Test
|
|
func `rate limit with request limit header reports exhausted quota`() async throws {
|
|
let transport = DoubaoScriptedTransport(results: [
|
|
.response(statusCode: 429, limit: 1000, remaining: nil),
|
|
])
|
|
|
|
let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
|
|
#expect(usage.primary?.usedPercent == 100)
|
|
#expect(usage.primary?.resetDescription == "1000/1000 requests")
|
|
#expect(await transport.requestCount() == 1)
|
|
}
|
|
|
|
@Test
|
|
func `bare rate limit omits unknown request limit`() async throws {
|
|
let transport = DoubaoScriptedTransport(results: [
|
|
.response(statusCode: 429, limit: nil, remaining: nil),
|
|
])
|
|
|
|
let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
|
|
#expect(usage.primary == nil)
|
|
#expect(usage.rateLimitsUnavailable(for: .doubao))
|
|
#expect(await transport.requestCount() == 1)
|
|
}
|
|
|
|
@Test
|
|
func `failed zero remaining confirmation preserves exhausted quota`() async throws {
|
|
let transport = DoubaoScriptedTransport(results: [
|
|
.response(statusCode: 200, limit: 1000, remaining: 0),
|
|
.failure(URLError(.timedOut)),
|
|
])
|
|
|
|
let snapshot = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport)
|
|
let usage = snapshot.toUsageSnapshot()
|
|
|
|
#expect(usage.primary?.usedPercent == 100)
|
|
#expect(usage.primary?.resetDescription == "1000/1000 requests")
|
|
#expect(await transport.requestCount() == 2)
|
|
}
|
|
|
|
@Test
|
|
func `task cancellation during confirmation propagates`() async {
|
|
let transport = DoubaoScriptedTransport(results: [
|
|
.response(statusCode: 200, limit: 1000, remaining: 0),
|
|
.cancellation,
|
|
])
|
|
|
|
await #expect(throws: CancellationError.self) {
|
|
_ = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport)
|
|
}
|
|
#expect(await transport.requestCount() == 2)
|
|
}
|
|
|
|
@Test
|
|
func `url cancellation during confirmation propagates`() async {
|
|
let transport = DoubaoScriptedTransport(results: [
|
|
.response(statusCode: 200, limit: 1000, remaining: 0),
|
|
.failure(URLError(.cancelled)),
|
|
])
|
|
|
|
await #expect {
|
|
_ = try await DoubaoUsageFetcher.fetchUsage(apiKey: "test-key", session: transport)
|
|
} throws: { error in
|
|
(error as? URLError)?.code == .cancelled
|
|
}
|
|
#expect(await transport.requestCount() == 2)
|
|
}
|
|
}
|
|
|
|
private actor DoubaoScriptedTransport: ProviderHTTPTransport {
|
|
enum Result {
|
|
case response(statusCode: Int, limit: Int?, remaining: Int?)
|
|
case rawResponse(statusCode: Int, body: String)
|
|
case failure(URLError)
|
|
case cancellation
|
|
}
|
|
|
|
struct CapturedRequest {
|
|
let url: String?
|
|
let method: String?
|
|
let host: String?
|
|
let date: String?
|
|
let contentSHA256: String?
|
|
let authorization: String?
|
|
}
|
|
|
|
private var results: [Result]
|
|
private var requests = 0
|
|
private var capturedRequest: CapturedRequest?
|
|
|
|
init(results: [Result]) {
|
|
self.results = results
|
|
}
|
|
|
|
func requestCount() -> Int {
|
|
self.requests
|
|
}
|
|
|
|
func lastCapturedRequest() -> CapturedRequest? {
|
|
self.capturedRequest
|
|
}
|
|
|
|
func data(for request: URLRequest) throws -> (Data, URLResponse) {
|
|
self.requests += 1
|
|
self.capturedRequest = CapturedRequest(
|
|
url: request.url?.absoluteString,
|
|
method: request.httpMethod,
|
|
host: request.value(forHTTPHeaderField: "Host"),
|
|
date: request.value(forHTTPHeaderField: "X-Date"),
|
|
contentSHA256: request.value(forHTTPHeaderField: "X-Content-Sha256"),
|
|
authorization: request.value(forHTTPHeaderField: "Authorization"))
|
|
let result = self.results.removeFirst()
|
|
switch result {
|
|
case let .response(statusCode, limit, remaining):
|
|
var headers: [String: String] = [:]
|
|
if let limit {
|
|
headers["x-ratelimit-limit-requests"] = String(limit)
|
|
}
|
|
if let remaining {
|
|
headers["x-ratelimit-remaining-requests"] = String(remaining)
|
|
}
|
|
let response = HTTPURLResponse(
|
|
url: request.url!,
|
|
statusCode: statusCode,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: headers)!
|
|
return (Data(#"{"usage":{"total_tokens":1}}"#.utf8), response)
|
|
case let .rawResponse(statusCode, body):
|
|
let response = HTTPURLResponse(
|
|
url: request.url!,
|
|
statusCode: statusCode,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: [:])!
|
|
return (Data(body.utf8), response)
|
|
case let .failure(error):
|
|
throw error
|
|
case .cancellation:
|
|
throw CancellationError()
|
|
}
|
|
}
|
|
}
|