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) } }