Files
steipete--codexbar/Tests/CodexBarTests/CodexOAuthCreditLimitTests.swift
2026-07-13 12:22:33 +08:00

358 lines
13 KiB
Swift

import Foundation
import Testing
@testable import CodexBarCore
struct CodexOAuthCreditLimitTests {
private struct StubFetchStrategy: ProviderFetchStrategy {
let id = "stub.cli"
let kind: ProviderFetchKind = .cli
let available: Bool
let result: ProviderFetchResult?
func isAvailable(_: ProviderFetchContext) async -> Bool {
self.available
}
func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult {
guard let result else { throw UsageError.noRateLimitsFound }
return result
}
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}
}
private func makeContext(
sourceMode: ProviderSourceMode = .auto,
includeCredits: Bool = true) -> ProviderFetchContext
{
let browserDetection = BrowserDetection(cacheTTL: 0)
return ProviderFetchContext(
runtime: .app,
sourceMode: sourceMode,
includeCredits: includeCredits,
webTimeout: 60,
webDebugDumpHTML: false,
verbose: false,
env: [:],
settings: nil,
fetcher: UsageFetcher(),
claudeFetcher: ClaudeUsageFetcher(browserDetection: browserDetection),
browserDetection: browserDetection)
}
private func makeCredentials() -> CodexOAuthCredentials {
CodexOAuthCredentials(
accessToken: "access",
refreshToken: "refresh",
idToken: nil,
accountId: nil,
lastRefresh: Date())
}
private func makeCLIResult(
credits: CreditsSnapshot?,
email: String? = nil) -> ProviderFetchResult
{
ProviderFetchResult(
usage: UsageSnapshot(
primary: nil,
secondary: nil,
updatedAt: Date(),
identity: email.map {
ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: $0,
accountOrganization: nil,
loginMethod: "enterprise")
}),
credits: credits,
dashboard: nil,
sourceLabel: "codex-cli",
strategyID: "stub.cli",
strategyKind: .cli)
}
private func replacingIdentity(
_ result: ProviderFetchResult,
email: String) -> ProviderFetchResult
{
ProviderFetchResult(
usage: result.usage.withIdentity(ProviderIdentitySnapshot(
providerID: .codex,
accountEmail: email,
accountOrganization: nil,
loginMethod: "enterprise")),
credits: result.credits,
dashboard: result.dashboard,
sourceLabel: result.sourceLabel,
strategyID: result.strategyID,
strategyKind: result.strategyKind)
}
private func makeMonthlyLimitCredits() -> CreditsSnapshot {
let now = Date()
let limit = CodexCreditLimitSnapshot(
used: 250,
limit: 1000,
remainingPercent: 75,
resetsAt: nil,
updatedAt: now)
return CreditsSnapshot(
remaining: limit.remaining,
events: [],
updatedAt: now,
codexCreditLimit: limit)
}
private func oauthZeroCreditRateWindowJSON() -> String {
"""
{
"rate_limit": {
"primary_window": {
"used_percent": 12,
"reset_at": 1766948068,
"limit_window_seconds": 18000
},
"secondary_window": null
},
"credits": {
"has_credits": true,
"unlimited": false,
"balance": "0"
}
}
"""
}
@Test
func `decodes monthly credit limit from rate limit payload`() throws {
let json = """
{
"plan_type": "enterprise",
"rate_limit": {
"primary_window": null,
"secondary_window": null,
"individual_limit": {
"limit": 100000,
"used": "7761",
"remaining_percent": 92.239,
"resets_at": 1782864000
}
},
"credits": {
"has_credits": true,
"unlimited": false,
"balance": "0"
}
}
"""
let response = try CodexOAuthUsageFetcher._decodeUsageResponseForTesting(Data(json.utf8))
#expect(response.rateLimit?.individualLimit?.limit == 100_000)
#expect(response.rateLimit?.individualLimit?.used == 7761)
#expect(response.rateLimit?.individualLimit?.remainingPercent == 92.239)
#expect(response.rateLimit?.individualLimit?.resetsAt == 1_782_864_000)
}
@Test
func `monthly credit limit O auth payload displays limit when balance is zero`() throws {
let json = """
{
"rate_limit": {
"primary_window": null,
"secondary_window": null,
"individual_limit": {
"limit": 100000,
"used": 7761,
"remaining_percent": 92.239,
"resets_at": 1782864000
}
},
"credits": {
"has_credits": true,
"unlimited": false,
"balance": "0"
}
}
"""
let creds = self.makeCredentials()
let result = try CodexOAuthFetchStrategy._mapResultForTesting(Data(json.utf8), credentials: creds)
#expect(result.credits?.remaining == 0)
#expect(result.credits?.codexCreditLimit?.remaining == 92239)
#expect(result.credits?.codexCreditLimit?.remainingPercent == 92.239)
#expect(result.sourceLabel == "oauth")
}
@Test
func `explicit O auth zero credits without monthly limit keeps partial result`() throws {
let json = """
{
"rate_limit": {
"primary_window": null,
"secondary_window": null
},
"credits": {
"has_credits": true,
"unlimited": false,
"balance": "0"
}
}
"""
let result = try CodexOAuthFetchStrategy._mapResultForTesting(
Data(json.utf8),
credentials: self.makeCredentials(),
sourceMode: .oauth)
#expect(result.credits?.remaining == 0)
#expect(result.credits?.codexCreditLimit == nil)
#expect(result.sourceLabel == "oauth")
}
@Test
func `auto O auth zero credits preserves O auth usage while adding CLI monthly limit`() async throws {
let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting(
Data(self.oauthZeroCreditRateWindowJSON().utf8),
credentials: self.makeCredentials(),
sourceMode: .auto)
let oauthResult = self.replacingIdentity(mappedOAuth, email: "owner@example.com")
let cliResult = self.makeCLIResult(
credits: self.makeMonthlyLimitCredits(),
email: "owner@example.com")
let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting(
oauthResult: oauthResult,
context: self.makeContext(sourceMode: .auto),
cliStrategy: StubFetchStrategy(available: true, result: cliResult))
#expect(oauthResult.usage.primary != nil)
#expect(CodexOAuthFetchStrategy._shouldTryCLIForMonthlyLimitForTesting(oauthResult))
#expect(result.sourceLabel == "oauth")
#expect(result.strategyKind == .oauth)
#expect(result.usage.primary == oauthResult.usage.primary)
#expect(result.credits?.remaining == oauthResult.credits?.remaining)
#expect(result.credits?.codexCreditLimit?.remaining == 750)
}
@Test
func `usage-only O auth refresh does not launch CLI monthly limit enrichment`() async throws {
let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting(
Data(self.oauthZeroCreditRateWindowJSON().utf8),
credentials: self.makeCredentials(),
sourceMode: .auto)
let oauthResult = self.replacingIdentity(mappedOAuth, email: "owner@example.com")
let cliResult = self.makeCLIResult(
credits: self.makeMonthlyLimitCredits(),
email: "owner@example.com")
let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting(
oauthResult: oauthResult,
context: self.makeContext(sourceMode: .auto, includeCredits: false),
cliStrategy: StubFetchStrategy(available: true, result: cliResult))
#expect(result.sourceLabel == "oauth")
#expect(result.usage.primary == oauthResult.usage.primary)
#expect(result.credits?.codexCreditLimit == nil)
}
@Test
func `auto O auth zero credits rejects CLI monthly limit without verified identity`() async throws {
let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting(
Data(self.oauthZeroCreditRateWindowJSON().utf8),
credentials: self.makeCredentials(),
sourceMode: .auto)
let oauthResult = self.replacingIdentity(mappedOAuth, email: "owner@example.com")
let cliResult = self.makeCLIResult(credits: self.makeMonthlyLimitCredits())
let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting(
oauthResult: oauthResult,
context: self.makeContext(sourceMode: .auto),
cliStrategy: StubFetchStrategy(available: true, result: cliResult))
#expect(result.sourceLabel == "oauth")
#expect(result.usage.identity?.accountEmail == "owner@example.com")
#expect(result.credits?.codexCreditLimit == nil)
}
@Test
func `auto O auth zero credits rejects CLI monthly limit from another account`() async throws {
let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting(
Data(self.oauthZeroCreditRateWindowJSON().utf8),
credentials: self.makeCredentials(),
sourceMode: .auto)
let oauthResult = self.replacingIdentity(mappedOAuth, email: "owner@example.com")
let cliResult = self.makeCLIResult(
credits: self.makeMonthlyLimitCredits(),
email: "other@example.com")
let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting(
oauthResult: oauthResult,
context: self.makeContext(sourceMode: .auto),
cliStrategy: StubFetchStrategy(available: true, result: cliResult))
#expect(result.sourceLabel == "oauth")
#expect(result.usage.identity?.accountEmail == "owner@example.com")
#expect(result.credits?.codexCreditLimit == nil)
}
@Test
func `auto O auth zero credits accepts matching CLI account case insensitively`() async throws {
let mappedOAuth = try CodexOAuthFetchStrategy._mapResultForTesting(
Data(self.oauthZeroCreditRateWindowJSON().utf8),
credentials: self.makeCredentials(),
sourceMode: .auto)
let oauthResult = self.replacingIdentity(mappedOAuth, email: "Owner@Example.com")
let cliResult = self.makeCLIResult(
credits: self.makeMonthlyLimitCredits(),
email: " owner@example.COM ")
let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting(
oauthResult: oauthResult,
context: self.makeContext(sourceMode: .auto),
cliStrategy: StubFetchStrategy(available: true, result: cliResult))
#expect(result.sourceLabel == "oauth")
#expect(result.usage.identity?.accountEmail == "Owner@Example.com")
#expect(result.credits?.codexCreditLimit?.remaining == 750)
}
@Test
func `auto O auth zero credits keeps partial result when CLI is unavailable`() async throws {
let oauthResult = try CodexOAuthFetchStrategy._mapResultForTesting(
Data(self.oauthZeroCreditRateWindowJSON().utf8),
credentials: self.makeCredentials(),
sourceMode: .auto)
let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting(
oauthResult: oauthResult,
context: self.makeContext(sourceMode: .auto),
cliStrategy: StubFetchStrategy(available: false, result: nil))
#expect(result.sourceLabel == "oauth")
#expect(result.credits?.remaining == 0)
#expect(result.usage.primary != nil)
}
@Test
func `auto O auth zero credits keeps partial result when CLI lacks monthly limit`() async throws {
let oauthResult = try CodexOAuthFetchStrategy._mapResultForTesting(
Data(self.oauthZeroCreditRateWindowJSON().utf8),
credentials: self.makeCredentials(),
sourceMode: .auto)
let cliResult = self.makeCLIResult(credits: CreditsSnapshot(
remaining: 0,
events: [],
updatedAt: Date()))
let result = try await CodexOAuthFetchStrategy._replaceWithCLIMonthlyLimitForTesting(
oauthResult: oauthResult,
context: self.makeContext(sourceMode: .auto),
cliStrategy: StubFetchStrategy(available: true, result: cliResult))
#expect(result.sourceLabel == "oauth")
#expect(result.credits?.codexCreditLimit == nil)
}
}