624 lines
23 KiB
Swift
624 lines
23 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
struct ClaudeOAuthTests {
|
|
@Test
|
|
func `parses O auth credentials`() throws {
|
|
let json = """
|
|
{
|
|
"claudeAiOauth": {
|
|
"accessToken": "test-token",
|
|
"refreshToken": "test-refresh",
|
|
"expiresAt": 4102444800000,
|
|
"scopes": ["usage:read"],
|
|
"rateLimitTier": "default_claude_max_20x",
|
|
"subscriptionType": "pro"
|
|
}
|
|
}
|
|
"""
|
|
let creds = try ClaudeOAuthCredentials.parse(data: Data(json.utf8))
|
|
#expect(creds.accessToken == "test-token")
|
|
#expect(creds.refreshToken == "test-refresh")
|
|
#expect(creds.scopes == ["usage:read"])
|
|
#expect(creds.rateLimitTier == "default_claude_max_20x")
|
|
#expect(creds.subscriptionType == "pro")
|
|
#expect(creds.isExpired == false)
|
|
}
|
|
|
|
@Test
|
|
func `missing access token throws`() {
|
|
let json = """
|
|
{
|
|
"claudeAiOauth": {
|
|
"accessToken": "",
|
|
"refreshToken": "test-refresh",
|
|
"expiresAt": 1735689600000
|
|
}
|
|
}
|
|
"""
|
|
#expect(throws: ClaudeOAuthCredentialsError.self) {
|
|
_ = try ClaudeOAuthCredentials.parse(data: Data(json.utf8))
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `missing O auth block throws`() {
|
|
let json = """
|
|
{ "other": { "accessToken": "nope" } }
|
|
"""
|
|
#expect(throws: ClaudeOAuthCredentialsError.self) {
|
|
_ = try ClaudeOAuthCredentials.parse(data: Data(json.utf8))
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `mcp O auth only keychain payload throws`() {
|
|
let json = """
|
|
{
|
|
"mcpOAuth": {
|
|
"plugin:slack:slack": {
|
|
"accessToken": ""
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
#expect(throws: ClaudeOAuthCredentialsError.self) {
|
|
_ = try ClaudeOAuthCredentials.parse(data: Data(json.utf8))
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `detects mcp O auth only keychain payload shape`() {
|
|
let json = """
|
|
{
|
|
"mcpOAuth": {
|
|
"craft": { "accessToken": "" }
|
|
}
|
|
}
|
|
"""
|
|
let data = Data(json.utf8)
|
|
#expect(ClaudeOAuthCredentials.isMcpOAuthOnlyPayload(data: data))
|
|
}
|
|
|
|
@Test
|
|
func `treats missing expiry as expired`() {
|
|
let creds = ClaudeOAuthCredentials(
|
|
accessToken: "token",
|
|
refreshToken: nil,
|
|
expiresAt: nil,
|
|
scopes: [],
|
|
rateLimitTier: nil)
|
|
#expect(creds.isExpired == true)
|
|
}
|
|
|
|
@Test
|
|
func `maps O auth usage to snapshot`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" },
|
|
"seven_day": { "utilization": 30, "resets_at": "2025-12-31T00:00:00.000Z" },
|
|
"seven_day_sonnet": { "utilization": 5 }
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(
|
|
Data(json.utf8),
|
|
rateLimitTier: "claude_pro")
|
|
#expect(snap.primary.usedPercent == 12.5)
|
|
#expect(snap.primary.windowMinutes == 300)
|
|
#expect(snap.secondary?.usedPercent == 30)
|
|
#expect(snap.opus?.usedPercent == 5)
|
|
#expect(snap.primary.resetsAt != nil)
|
|
#expect(snap.loginMethod == "Claude Pro")
|
|
#expect(snap.oauthHistoryOwnerIdentifier?.count == 64)
|
|
}
|
|
|
|
@Test
|
|
func `maps O auth subscription type when rate limit tier is generic`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" }
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(
|
|
Data(json.utf8),
|
|
rateLimitTier: "default_claude_ai",
|
|
subscriptionType: "pro")
|
|
#expect(snap.loginMethod == "Claude Pro")
|
|
}
|
|
|
|
@Test
|
|
func `ignores merged O auth design usage window`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" },
|
|
"seven_day_design": { "utilization": 44, "resets_at": "2025-12-31T00:00:00.000Z" },
|
|
"seven_day_routines": { "utilization": 18, "resets_at": "2026-01-01T00:00:00.000Z" }
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
#expect(snap.extraRateWindows.count == 1)
|
|
#expect(snap.extraRateWindows.contains { $0.id == "claude-design" } == false)
|
|
#expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.title == "Daily Routines")
|
|
#expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 18)
|
|
}
|
|
|
|
@Test
|
|
func `surfaces Fable scoped weekly limit from limits array`() throws {
|
|
// Real shape observed 2026-07-03 during Anthropic's Fable 5 promotional access
|
|
// window (up to 50% of the weekly limit on Fable 5): weekly caps have moved from
|
|
// flat seven_day_* fields (now null) to a `limits` array with `scope.model.display_name`.
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 11.0, "resets_at": "2026-07-03T00:30:00.282668+00:00" },
|
|
"seven_day": { "utilization": 9.0, "resets_at": "2026-07-08T09:00:00.282694+00:00" },
|
|
"seven_day_opus": null,
|
|
"seven_day_sonnet": null,
|
|
"limits": [
|
|
{
|
|
"kind": "session", "group": "session", "percent": 11,
|
|
"resets_at": "2026-07-03T00:30:00.282668+00:00", "scope": null, "is_active": true
|
|
},
|
|
{
|
|
"kind": "weekly_all", "group": "weekly", "percent": 9,
|
|
"resets_at": "2026-07-08T09:00:00.282694+00:00", "scope": null, "is_active": false
|
|
},
|
|
{
|
|
"kind": "weekly_scoped", "group": "weekly", "percent": 5,
|
|
"resets_at": "2026-07-08T09:00:00.283070+00:00",
|
|
"scope": { "model": { "id": null, "display_name": "Fable" }, "surface": null },
|
|
"is_active": false
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
let fable = snap.extraRateWindows.first(where: { $0.id == "claude-weekly-scoped-fable" })
|
|
#expect(fable?.title == "Fable only")
|
|
#expect(fable?.window.usedPercent == 5)
|
|
#expect(fable?.window.resetsAt != nil)
|
|
}
|
|
|
|
@Test
|
|
func `ignores weekly scoped limit without a model display name`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 11.0, "resets_at": "2026-07-03T00:30:00.282668+00:00" },
|
|
"limits": [
|
|
{
|
|
"kind": "weekly_scoped", "group": "weekly", "percent": 5,
|
|
"resets_at": "2026-07-08T09:00:00.283070+00:00",
|
|
"scope": { "model": null, "surface": null }, "is_active": false
|
|
}
|
|
]
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
#expect(snap.extraRateWindows.contains { $0.id.hasPrefix("claude-weekly-scoped-") } == false)
|
|
}
|
|
|
|
@Test
|
|
func `ignores merged O auth omelette usage window`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" },
|
|
"seven_day_omelette": { "utilization": 29, "resets_at": "2025-12-31T00:00:00.000Z" },
|
|
"seven_day_cowork": { "utilization": 9, "resets_at": "2026-01-01T00:00:00.000Z" }
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
#expect(snap.extraRateWindows.count == 1)
|
|
#expect(snap.extraRateWindows.contains { $0.id == "claude-design" } == false)
|
|
#expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 9)
|
|
}
|
|
|
|
@Test
|
|
func `maps O auth null cowork as zero routines window`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" },
|
|
"seven_day_omelette": { "utilization": 29, "resets_at": "2025-12-31T00:00:00.000Z" },
|
|
"seven_day_cowork": null
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
#expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 0)
|
|
#expect(snap.extraRateWindows.contains { $0.id == "claude-design" } == false)
|
|
}
|
|
|
|
@Test
|
|
func `prefers populated routines alias over null alias in mixed payload`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 12.5, "resets_at": "2025-12-25T12:00:00.000Z" },
|
|
"seven_day_design": null,
|
|
"seven_day_omelette": { "utilization": 37, "resets_at": "2025-12-31T00:00:00.000Z" },
|
|
"seven_day_routines": null,
|
|
"seven_day_cowork": { "utilization": 14, "resets_at": "2026-01-01T00:00:00.000Z" }
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
#expect(snap.extraRateWindows.contains { $0.id == "claude-design" } == false)
|
|
#expect(snap.extraRateWindows.first(where: { $0.id == "claude-routines" })?.window.usedPercent == 14)
|
|
}
|
|
|
|
@Test
|
|
func `maps O auth extra usage`() throws {
|
|
// OAuth API returns values in cents (minor units), same as Web API.
|
|
// The normalization always converts to dollars (major units).
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 1, "resets_at": "2025-12-25T12:00:00.000Z" },
|
|
"extra_usage": {
|
|
"is_enabled": true,
|
|
"monthly_limit": 2050,
|
|
"used_credits": 325
|
|
}
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
#expect(snap.providerCost?.currencyCode == "USD")
|
|
#expect(snap.providerCost?.limit == 20.5)
|
|
#expect(snap.providerCost?.used == 3.25)
|
|
#expect(snap.providerCost?.period == "Monthly cap")
|
|
}
|
|
|
|
@Test
|
|
func `maps O auth extra usage minor units as major units`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 1, "resets_at": "2025-12-25T12:00:00.000Z" },
|
|
"extra_usage": {
|
|
"is_enabled": true,
|
|
"monthly_limit": 2000,
|
|
"used_credits": 520,
|
|
"currency": "USD"
|
|
}
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
#expect(snap.providerCost?.currencyCode == "USD")
|
|
#expect(snap.providerCost?.limit == 20)
|
|
#expect(snap.providerCost?.used == 5.2)
|
|
#expect(snap.providerCost?.period == "Monthly cap")
|
|
}
|
|
|
|
@Test
|
|
func `does not display spend limit 100x too high for enterprise O auth`() throws {
|
|
let json = """
|
|
{
|
|
"extra_usage": {
|
|
"is_enabled": true,
|
|
"monthly_limit": 2000,
|
|
"used_credits": 763,
|
|
"utilization": 38.15,
|
|
"currency": "EUR"
|
|
}
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(
|
|
Data(json.utf8),
|
|
subscriptionType: "enterprise")
|
|
#expect(snap.loginMethod == "Claude Enterprise")
|
|
#expect(snap.primary.usedPercent == 38.15)
|
|
#expect(snap.primaryWindowKind == .spendLimit)
|
|
#expect(snap.primary.windowMinutes == nil)
|
|
#expect(snap.primary.resetDescription == "Spend limit: €7.63 / €20.00")
|
|
#expect(snap.secondary == nil)
|
|
#expect(snap.providerCost?.period == "Spend limit")
|
|
#expect(snap.providerCost?.currencyCode == "EUR")
|
|
#expect(snap.providerCost?.limit == 20)
|
|
#expect(snap.providerCost?.used == 7.63)
|
|
|
|
let usage = ClaudeOAuthFetchStrategy._snapshotForTesting(from: snap)
|
|
#expect(usage.primary == nil)
|
|
#expect(usage.providerCost?.period == "Spend limit")
|
|
#expect(usage.providerCost?.limit == 20)
|
|
#expect(usage.providerCost?.used == 7.63)
|
|
}
|
|
|
|
@Test
|
|
func `maps O auth spend limit without plan metadata from minor units`() throws {
|
|
let json = """
|
|
{
|
|
"extra_usage": {
|
|
"is_enabled": true,
|
|
"monthly_limit": 2000,
|
|
"used_credits": 763,
|
|
"utilization": 38.15,
|
|
"currency": "EUR"
|
|
}
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
#expect(snap.loginMethod == nil)
|
|
#expect(snap.primaryWindowKind == .spendLimit)
|
|
#expect(snap.primary.usedPercent == 38.15)
|
|
#expect(snap.primary.resetDescription == "Spend limit: €7.63 / €20.00")
|
|
#expect(snap.providerCost?.period == "Spend limit")
|
|
#expect(snap.providerCost?.currencyCode == "EUR")
|
|
#expect(snap.providerCost?.limit == 20)
|
|
#expect(snap.providerCost?.used == 7.63)
|
|
}
|
|
|
|
@Test
|
|
func `maps large enterprise O auth spend limit from minor units`() throws {
|
|
let json = """
|
|
{
|
|
"extra_usage": {
|
|
"is_enabled": true,
|
|
"monthly_limit": 1000000,
|
|
"used_credits": 123456,
|
|
"utilization": 12.3456,
|
|
"currency": "USD"
|
|
}
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(
|
|
Data(json.utf8),
|
|
subscriptionType: "enterprise")
|
|
#expect(snap.primaryWindowKind == .spendLimit)
|
|
#expect(snap.primary.usedPercent == 12.3456)
|
|
#expect(snap.primary.resetDescription == "Spend limit: $1,234.56 / $10,000.00")
|
|
#expect(snap.providerCost?.period == "Spend limit")
|
|
#expect(snap.providerCost?.limit == 10000)
|
|
#expect(snap.providerCost?.used == 1234.56)
|
|
}
|
|
|
|
@Test
|
|
func `normalizes high limit O auth extra usage`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 1, "resets_at": "2025-12-25T12:00:00.000Z" },
|
|
"extra_usage": {
|
|
"is_enabled": true,
|
|
"monthly_limit": 200000,
|
|
"used_credits": 22200,
|
|
"currency": "USD"
|
|
}
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(
|
|
Data(json.utf8),
|
|
rateLimitTier: "claude_pro")
|
|
#expect(snap.providerCost?.currencyCode == "USD")
|
|
#expect(snap.providerCost?.limit == 2000)
|
|
#expect(snap.providerCost?.used == 222)
|
|
}
|
|
|
|
@Test
|
|
func `normalizes O auth extra usage cents to major units`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 1, "resets_at": "2025-12-25T12:00:00.000Z" },
|
|
"extra_usage": {
|
|
"is_enabled": true,
|
|
"monthly_limit": 200000,
|
|
"used_credits": 22200,
|
|
"currency": "USD"
|
|
}
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
#expect(snap.providerCost?.currencyCode == "USD")
|
|
#expect(snap.providerCost?.limit == 2000)
|
|
#expect(snap.providerCost?.used == 222)
|
|
}
|
|
|
|
@Test
|
|
func `prefers opus when sonnet missing`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 10, "resets_at": "2025-12-25T12:00:00.000Z" },
|
|
"seven_day_opus": { "utilization": 42 }
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
#expect(snap.opus?.usedPercent == 42)
|
|
}
|
|
|
|
@Test
|
|
func `includes body in O auth403 error`() {
|
|
let err = ClaudeOAuthFetchError.serverError(
|
|
403,
|
|
"HTTP 403: OAuth token does not meet scope requirement user:profile")
|
|
#expect(err.localizedDescription.contains("user:profile"))
|
|
#expect(err.localizedDescription.contains("HTTP 403"))
|
|
}
|
|
|
|
@Test
|
|
func `O auth429 error gives actionable guidance without raw body`() {
|
|
let err = ClaudeOAuthFetchError.rateLimited(retryAfter: nil)
|
|
#expect(err.localizedDescription.contains("rate limited"))
|
|
#expect(err.localizedDescription.contains("claude logout && claude login"))
|
|
#expect(!err.localizedDescription.contains("rate_limit_error"))
|
|
}
|
|
|
|
@Test
|
|
func `O auth429 usage fetch surfaces guidance without raw JSON`() async throws {
|
|
let fetcher = ClaudeUsageFetcher(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
environment: [:],
|
|
dataSource: .oauth,
|
|
oauthKeychainPromptCooldownEnabled: true)
|
|
|
|
let loadCredsOverride: (@Sendable (
|
|
[String: String],
|
|
Bool,
|
|
Bool) async throws -> ClaudeOAuthCredentials)? = { _, _, _ in
|
|
ClaudeOAuthCredentials(
|
|
accessToken: "rate-limited-token",
|
|
refreshToken: "refresh-token",
|
|
expiresAt: Date(timeIntervalSinceNow: 3600),
|
|
scopes: ["user:profile"],
|
|
rateLimitTier: nil)
|
|
}
|
|
let fetchOverride: (@Sendable (String, Bool) async throws -> OAuthUsageResponse)? = { _, _ in
|
|
throw ClaudeOAuthFetchError.rateLimited(retryAfter: nil)
|
|
}
|
|
|
|
do {
|
|
_ = try await ClaudeUsageFetcher.$fetchOAuthUsageOverride.withValue(fetchOverride) {
|
|
try await ClaudeUsageFetcher.$loadOAuthCredentialsOverride.withValue(
|
|
loadCredsOverride,
|
|
operation: {
|
|
try await fetcher.loadLatestUsage(model: "sonnet")
|
|
})
|
|
}
|
|
Issue.record("Expected OAuth rate limit to fail with guidance")
|
|
} catch let error as ClaudeUsageError {
|
|
guard case let .oauthFailed(message) = error else {
|
|
Issue.record("Expected ClaudeUsageError.oauthFailed, got \(error)")
|
|
return
|
|
}
|
|
#expect(message.contains("rate limited"))
|
|
#expect(message.contains("claude logout && claude login"))
|
|
#expect(!message.contains("rate_limit_error"))
|
|
} catch {
|
|
Issue.record("Expected ClaudeUsageError, got \(error)")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `O auth usage rate limit gate blocks background retries until cooldown`() {
|
|
ClaudeOAuthUsageRateLimitGate.resetForTesting()
|
|
defer { ClaudeOAuthUsageRateLimitGate.resetForTesting() }
|
|
|
|
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let retryAfter = now.addingTimeInterval(120)
|
|
|
|
#expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now) == nil)
|
|
ClaudeOAuthUsageRateLimitGate.recordRateLimit(retryAfter: retryAfter, now: now)
|
|
|
|
#expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now) == retryAfter)
|
|
#expect(ClaudeOAuthUsageRateLimitGate.blockedUntil(interaction: .background, now: now) == retryAfter)
|
|
#expect(ClaudeOAuthUsageRateLimitGate.blockedUntil(interaction: .userInitiated, now: now) == nil)
|
|
#expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now.addingTimeInterval(119)) != nil)
|
|
#expect(ClaudeOAuthUsageRateLimitGate.currentBlockedUntil(now: now.addingTimeInterval(121)) == nil)
|
|
}
|
|
|
|
@Test
|
|
func `O auth retry after parses seconds`() throws {
|
|
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let url = try #require(URL(string: "https://api.anthropic.com/api/oauth/usage"))
|
|
let response = try #require(HTTPURLResponse(
|
|
url: url,
|
|
statusCode: 429,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: ["Retry-After": "42"]))
|
|
|
|
#expect(
|
|
ClaudeOAuthUsageFetcher._retryAfterDateForTesting(from: response, now: now)
|
|
== now.addingTimeInterval(42))
|
|
}
|
|
|
|
@Test
|
|
func `O auth retry after parses HTTP date`() throws {
|
|
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let url = try #require(URL(string: "https://api.anthropic.com/api/oauth/usage"))
|
|
let response = try #require(HTTPURLResponse(
|
|
url: url,
|
|
statusCode: 429,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: ["Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"]))
|
|
|
|
#expect(
|
|
ClaudeOAuthUsageFetcher._retryAfterDateForTesting(from: response, now: now)
|
|
== Date(timeIntervalSince1970: 1_445_412_480))
|
|
}
|
|
|
|
@Test
|
|
func `oauth usage user agent uses claude code version`() {
|
|
#expect(
|
|
ClaudeOAuthUsageFetcher._userAgentForTesting(versionString: "2.1.70 (Claude Code)")
|
|
== "claude-code/2.1.70")
|
|
#expect(ClaudeOAuthUsageFetcher._userAgentForTesting(versionString: nil) == "claude-code/2.1.0")
|
|
}
|
|
|
|
@Test
|
|
func `oauth usage fallback user agent skips version detector`() {
|
|
var detectionCount = 0
|
|
let fallback = ClaudeOAuthUsageFetcher._userAgentForTesting(
|
|
detectClaudeVersion: false,
|
|
versionDetector: {
|
|
detectionCount += 1
|
|
return "2.1.70 (Claude Code)"
|
|
})
|
|
|
|
#expect(fallback == "claude-code/2.1.0")
|
|
#expect(detectionCount == 0)
|
|
|
|
let detected = ClaudeOAuthUsageFetcher._userAgentForTesting(
|
|
detectClaudeVersion: true,
|
|
versionDetector: {
|
|
detectionCount += 1
|
|
return "2.1.70 (Claude Code)"
|
|
})
|
|
|
|
#expect(detected == "claude-code/2.1.70")
|
|
#expect(detectionCount == 1)
|
|
}
|
|
|
|
@Test
|
|
func `skips extra usage when disabled`() throws {
|
|
let json = """
|
|
{
|
|
"five_hour": { "utilization": 1, "resets_at": "2025-12-25T12:00:00.000Z" },
|
|
"extra_usage": {
|
|
"is_enabled": false,
|
|
"monthly_limit": 100,
|
|
"used_credits": 10
|
|
}
|
|
}
|
|
"""
|
|
let snap = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8))
|
|
#expect(snap.providerCost == nil)
|
|
}
|
|
|
|
// MARK: - Scope-based strategy resolution
|
|
|
|
@Test
|
|
func `prefers O auth when available`() {
|
|
let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
|
|
selectedDataSource: .auto,
|
|
webExtrasEnabled: false,
|
|
hasWebSession: true,
|
|
hasCLI: true,
|
|
hasOAuthCredentials: true)
|
|
#expect(strategy.dataSource == .oauth)
|
|
}
|
|
|
|
@Test
|
|
func `falls back to CLI when O auth missing and CLI available`() {
|
|
let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
|
|
selectedDataSource: .auto,
|
|
webExtrasEnabled: false,
|
|
hasWebSession: true,
|
|
hasCLI: true,
|
|
hasOAuthCredentials: false)
|
|
#expect(strategy.dataSource == .cli)
|
|
}
|
|
|
|
@Test
|
|
func `falls back to web when O auth missing and CLI missing`() {
|
|
let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
|
|
selectedDataSource: .auto,
|
|
webExtrasEnabled: false,
|
|
hasWebSession: true,
|
|
hasCLI: false,
|
|
hasOAuthCredentials: false)
|
|
#expect(strategy.dataSource == .web)
|
|
}
|
|
|
|
@Test
|
|
func `falls back to CLI when O auth missing and web missing`() {
|
|
let strategy = ClaudeProviderDescriptor.resolveUsageStrategy(
|
|
selectedDataSource: .auto,
|
|
webExtrasEnabled: false,
|
|
hasWebSession: false,
|
|
hasCLI: true,
|
|
hasOAuthCredentials: false)
|
|
#expect(strategy.dataSource == .cli)
|
|
}
|
|
}
|