Files

271 lines
14 KiB
Swift

// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
@testable import AIUsage
import Foundation
import Testing
private actor StubSub2APIHTTPClient: QuotaHTTPClient {
private var replies: [(Int, Data)]
private var captured: [URLRequest] = []
init(_ replies: [(Int, String)]) {
self.replies = replies.map { ($0.0, Data($0.1.utf8)) }
}
func send(_ request: URLRequest) async throws -> (Data, Int) {
captured.append(request)
guard !replies.isEmpty else { throw AIQuotaFailure.network }
let (status, data) = replies.removeFirst()
return (data, status)
}
func requests() -> [URLRequest] { captured }
}
private actor ConcurrentSub2APIHTTPClient: QuotaHTTPClient {
private(set) var logins = 0
func send(_ request: URLRequest) async throws -> (Data, Int) {
if request.url?.path.hasSuffix("/auth/login") == true {
logins += 1
try await Task.sleep(for: .milliseconds(30))
return (Data(#"{"code":0,"data":{"access_token":"shared","expires_in":3600}}"#.utf8), 200)
}
if request.url?.path.hasSuffix("/usage") == true {
return (Data(#"{"code":0,"data":{"five_hour":{"utilization":20,"remaining_seconds":1800}}}"#.utf8), 200)
}
let platform = request.url?.path.hasSuffix("/43") == true ? "anthropic" : "openai"
return (Data("{\"code\":0,\"data\":{\"platform\":\"\(platform)\",\"extra\":{}}}".utf8), 200)
}
}
@MainActor
private final class InMemorySub2APISecretStore: Sub2APISecretStore {
var value: String?
var failWrites = false
func read() throws -> String? { value }
func write(_ password: String) throws {
if failWrites { throw Sub2APIConfigurationError.missingCredentials }
value = password
}
func delete() throws { value = nil }
}
@Suite struct Sub2APIQuotaTests {
private func configuration(provider: AIProviderID = .codex, accountID: String = "42") throws -> Sub2APIConfiguration {
try Sub2APIConfiguration(address: "https://sub.example.com/prefix/", email: "admin@example.com",
password: "secret", accountID: accountID, provider: provider)
}
@Test @MainActor func configurationRequiresHTTPSAndStoresPasswordSeparately() throws {
#expect(throws: Sub2APIConfigurationError.invalidAddress) {
try Sub2APIConfiguration(address: "http://sub.example.com", email: "admin@example.com",
password: "secret", accountID: "42")
}
#expect(throws: Sub2APIConfigurationError.invalidAccountID) {
try Sub2APIConfiguration(address: "https://sub.example.com", email: "admin@example.com",
password: "secret", accountID: "../42")
}
let defaults = UserDefaults(suiteName: UUID().uuidString)!
let secrets = InMemorySub2APISecretStore()
let store = Sub2APISettingsStore(defaults: defaults, secrets: secrets)
let codexConfiguration = try configuration()
try store.save(codexConfiguration)
let saved = try #require(try store.load())
#expect(saved.baseURL.absoluteString == "https://sub.example.com/prefix")
#expect(saved.accountID == 42)
#expect(defaults.dictionary(forKey: "aiUsage.sub2api")?["password"] == nil)
#expect(secrets.value == "secret")
try store.clear()
let cleared = try store.load()
#expect(cleared == nil)
secrets.failWrites = true
#expect(throws: Sub2APIConfigurationError.missingCredentials) {
try store.save(codexConfiguration)
}
#expect(defaults.dictionary(forKey: "aiUsage.sub2api") == nil)
secrets.failWrites = false
let claudeSecrets = InMemorySub2APISecretStore()
let claudeStore = Sub2APISettingsStore(provider: .claude, defaults: defaults, secrets: claudeSecrets)
let claude = try configuration(provider: .claude, accountID: "43")
try claudeStore.save(claude)
#expect(try claudeStore.load()?.provider == .claude)
#expect(defaults.dictionary(forKey: "aiUsage.sub2api") == nil)
#expect(defaults.dictionary(forKey: "aiUsage.sub2api.claude")?["accountID"] as? Int == 43)
#expect(claudeSecrets.value == "secret")
#expect(throws: Sub2APIConfigurationError.providerMismatch) {
try store.save(claude)
}
}
@Test func detailedUsageUsesCachedQueryAndMapsBothWindows() async throws {
let http = StubSub2APIHTTPClient([
(200, #"{"code":0,"data":{"access_token":"short-lived"}}"#),
(200, #"{"code":0,"data":{"platform":"openai","extra":{}}}"#),
(200, #"{"code":0,"data":{"five_hour":{"utilization":35,"remaining_seconds":3600},"seven_day":{"utilization":72,"resets_at":"2026-09-26T17:35:00Z"}}}"#),
])
let result = try await Sub2APIQuotaClient(http: http).fetch(configuration: configuration())
#expect(result.source == .sub2api)
#expect(result.window(.session)?.remainingPercent == 65)
#expect(result.window(.weekly)?.remainingPercent == 28)
#expect(result.window(.fableWeekly) == nil)
#expect(result.window(.session)?.resetsAt != nil)
let requests = await http.requests()
#expect(requests.count == 3)
#expect(requests[0].httpMethod == "POST")
#expect(requests[0].url?.path == "/prefix/api/v1/auth/login")
#expect(requests[1].url?.path == "/prefix/api/v1/admin/accounts/42")
#expect(requests[2].url?.path == "/prefix/api/v1/admin/accounts/42/usage")
#expect(requests[2].value(forHTTPHeaderField: "Authorization") == "Bearer short-lived")
let query = URLComponents(url: try #require(requests[2].url), resolvingAgainstBaseURL: false)?.queryItems
#expect(query?.first(where: { $0.name == "force" })?.value == "false")
#expect(query?.first(where: { $0.name == "source" })?.value == "active")
}
@Test func olderServerFallsBackToAccountExtra() async throws {
let http = StubSub2APIHTTPClient([
(200, #"{"code":0,"data":{"access_token":"short-lived"}}"#),
(200, #"{"code":0,"data":{"platform":"openai","extra":{"session_window_utilization":0.25,"passive_usage_7d_utilization":0.6,"passive_usage_7d_reset":1800000000},"session_window_end":"2026-09-26T17:35:00Z"}}"#),
(404, "{}"),
])
let result = try await Sub2APIQuotaClient(http: http).fetch(configuration: configuration())
#expect(result.window(.session)?.usedPercent == 25)
#expect(result.window(.weekly)?.usedPercent == 60)
#expect(result.window(.fableWeekly) == nil)
#expect(await http.requests().count == 3)
}
@Test func transportFailureDoesNotRepeatAgainstAccountEndpoint() async throws {
let http = StubSub2APIHTTPClient([
(200, #"{"code":0,"data":{"access_token":"short-lived"}}"#),
(200, #"{"code":0,"data":{"platform":"openai","extra":{}}}"#),
])
let client = Sub2APIQuotaClient(http: http)
do {
_ = try await client.fetch(configuration: configuration())
Issue.record("Expected a transport failure")
} catch let failure as AIQuotaFailure {
#expect(failure == .sub2apiNetwork)
}
#expect(await http.requests().count == 3)
}
@Test func codexUsesManualSourceOnlyWhenAutomaticQuotaFails() async throws {
let configuration = try configuration()
let fallbackHTTP = StubSub2APIHTTPClient([
(200, #"{"code":0,"data":{"access_token":"short-lived"}}"#),
(200, #"{"code":0,"data":{"platform":"openai","extra":{}}}"#),
(200, #"{"code":0,"data":{"five_hour":{"utilization":20,"remaining_seconds":1800}}}"#),
])
let fallback = Sub2APIQuotaClient(http: fallbackHTTP)
let missingCLI = CodexQuotaProvider(credentials: { throw AIQuotaFailure.notConfigured },
http: StubSub2APIHTTPClient([]), sub2api: fallback,
sub2apiConfiguration: { configuration })
let manual = try await missingCLI.fetch()
#expect(manual.source == .sub2api)
#expect(manual.window(.session)?.remainingPercent == 80)
let directHTTP = StubSub2APIHTTPClient([
(200, #"{"rate_limit":{"primary_window":{"used_percent":74,"limit_window_seconds":604800}}}"#),
])
let direct = CodexQuotaProvider(credentials: { CodexQuotaCredentials(token: "cli-token", accountID: nil) },
http: directHTTP, sub2api: fallback, sub2apiConfiguration: { configuration })
let automatic = try await direct.fetch()
#expect(automatic.source == .direct)
#expect(automatic.window(.weekly)?.remainingPercent == 26)
#expect(await fallbackHTTP.requests().count == 3)
}
@Test func claudeUsesItsOwnManualAccountWhenLocalCredentialsAreMissing() async throws {
let configuration = try configuration(provider: .claude, accountID: "43")
let fallbackHTTP = StubSub2APIHTTPClient([
(200, #"{"code":0,"data":{"access_token":"shared"}}"#),
(200, #"{"code":0,"data":{"platform":"anthropic","extra":{"passive_usage_7d_oi_utilization":0.3,"passive_usage_7d_oi_reset":1800000000}}}"#),
(200, #"{"code":0,"data":{"five_hour":{"utilization":15,"remaining_seconds":1800},"seven_day":{"utilization":40},"seven_day_sonnet":{"utilization":55}}}"#),
])
let fallback = Sub2APIQuotaClient(http: fallbackHTTP)
let missingCLI = ClaudeQuotaProvider(credentials: { throw AIQuotaFailure.notConfigured },
http: StubSub2APIHTTPClient([]), sub2api: fallback,
sub2apiConfiguration: { configuration })
let manual = try await missingCLI.fetch()
#expect(manual.provider == .claude)
#expect(manual.source == .sub2api)
#expect(manual.window(.session)?.remainingPercent == 85)
#expect(manual.window(.weekly)?.remainingPercent == 60)
#expect(manual.window(.sonnetWeekly)?.remainingPercent == 45)
#expect(manual.window(.fableWeekly)?.remainingPercent == 70)
#expect(await fallbackHTTP.requests().count == 3)
let directHTTP = StubSub2APIHTTPClient([
(200, #"{"five_hour":{"utilization":20}}"#),
])
let direct = ClaudeQuotaProvider(credentials: { "claude-token" }, http: directHTTP,
sub2api: fallback, sub2apiConfiguration: { configuration })
let automatic = try await direct.fetch()
#expect(automatic.provider == .claude)
#expect(automatic.source == .direct)
#expect(await fallbackHTTP.requests().count == 3)
}
@Test func detailedClaudeFableWindowIsUsedOnlyWhenServerReturnsAValue() throws {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let present = Data(#"{"code":0,"data":{"five_hour":{"utilization":12},"seven_day_fable":{"utilization":37,"remaining_seconds":86400}}}"#.utf8)
let missing = Data(#"{"code":0,"data":{"five_hour":{"utilization":12},"seven_day_fable":null}}"#.utf8)
let fable = try Sub2APIQuotaClient.parseUsage(present, now: now, provider: .claude)
#expect(fable.window(.fableWeekly)?.remainingPercent == 63)
#expect(fable.window(.fableWeekly)?.resetsAt == now.addingTimeInterval(86400))
let unavailable = try Sub2APIQuotaClient.parseUsage(missing, now: now, provider: .claude)
#expect(unavailable.window(.fableWeekly) == nil)
}
@Test func mismatchedAccountPlatformIsRejectedBeforeUsageQuery() async throws {
let http = StubSub2APIHTTPClient([
(200, #"{"code":0,"data":{"access_token":"short-lived"}}"#),
(200, #"{"code":0,"data":{"platform":"openai","extra":{}}}"#),
])
do {
_ = try await Sub2APIQuotaClient(http: http)
.fetch(configuration: configuration(provider: .claude, accountID: "43"))
Issue.record("Expected a platform mismatch")
} catch let failure as AIQuotaFailure {
#expect(failure == .sub2apiAccountMismatch)
}
#expect(await http.requests().count == 2)
}
@Test(arguments: [(401, AIQuotaFailure.sub2apiUnauthorized), (403, .sub2apiUnauthorized),
(404, .sub2apiInvalidResponse), (502, .sub2apiNetwork)])
func loginFailureIsClassifiedByStatus(status: Int, expected: AIQuotaFailure) async throws {
let http = StubSub2APIHTTPClient([(status, #"{"code":1,"message":"error"}"#)])
do {
_ = try await Sub2APIQuotaClient(http: http).fetch(configuration: configuration())
Issue.record("Expected login status \(status) to fail")
} catch let failure as AIQuotaFailure {
#expect(failure == expected, "status \(status) mapped to \(failure)")
// 5xx 属于临时故障,必须保留上次额度;凭据错误则不能保留。
#expect(failure.preservesLastGood == (status == 502 || status == 404))
}
#expect(await http.requests().count == 1)
}
@Test func concurrentRefreshesShareOneAdminLogin() async throws {
let http = ConcurrentSub2APIHTTPClient()
let client = Sub2APIQuotaClient(http: http)
let codex = try configuration()
let claude = try configuration(provider: .claude, accountID: "43")
async let first = client.fetch(configuration: codex)
async let second = client.fetch(configuration: claude)
let (one, two) = try await (first, second)
#expect(one.window(.session)?.remainingPercent == 80)
#expect(two.window(.session)?.remainingPercent == 80)
#expect(one.provider == .codex)
#expect(two.provider == .claude)
#expect(await http.logins == 1)
}
}