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

1065 lines
45 KiB
Swift

import AppKit
import CodexBarCore
import Foundation
import XCTest
@testable import CodexBar
@MainActor
final class StatusMenuTokenAccountSwitcherTests: XCTestCase {
private func disableMenuCardsForTesting() {
StatusItemController.menuCardRenderingEnabled = false
StatusItemController.setMenuRefreshEnabledForTesting(false)
}
private func makeSettings() -> SettingsStore {
let settings = testSettingsStore(
suiteName: "StatusMenuTokenAccountSwitcherTests",
tokenAccountStore: InMemoryTokenAccountStore())
settings.providerDetectionCompleted = true
return settings
}
private func enableOnlyClaude(_ settings: SettingsStore) {
self.enableOnly(.claude, settings)
}
private func enableOnly(_ enabledProvider: UsageProvider, _ settings: SettingsStore) {
let registry = ProviderRegistry.shared
for provider in UsageProvider.allCases {
guard let metadata = registry.metadata[provider] else { continue }
settings.setProviderEnabled(provider: provider, metadata: metadata, enabled: provider == enabledProvider)
}
}
private func representedIDs(in menu: NSMenu) -> [String] {
menu.items.compactMap { $0.representedObject as? String }
}
private func installBlockingClaudeProvider(on store: UsageStore, blocker: BlockingTokenAccountFetchStrategy) {
let baseSpec = store.providerSpecs[.claude]!
store.providerSpecs[.claude] = Self.makeClaudeProviderSpec(baseSpec: baseSpec) {
try await blocker.awaitResult()
}
}
private func installRotatingProvider(
on store: UsageStore,
provider: UsageProvider,
rotatedToken: String)
{
let baseSpec = store.providerSpecs[provider]!
let baseDescriptor = baseSpec.descriptor
let snapshot = self.snapshot(percent: 37)
let descriptor = ProviderDescriptor(
id: provider,
metadata: baseDescriptor.metadata,
branding: baseDescriptor.branding,
tokenCost: baseDescriptor.tokenCost,
fetchPlan: ProviderFetchPlan(
sourceModes: baseDescriptor.fetchPlan.sourceModes,
pipeline: ProviderFetchPipeline { _ in [
RotatingTokenAccountFetchStrategy(
provider: provider,
rotatedToken: rotatedToken,
snapshot: snapshot),
] }),
cli: baseDescriptor.cli)
store.providerSpecs[provider] = ProviderSpec(
style: baseSpec.style,
isEnabled: baseSpec.isEnabled,
descriptor: descriptor,
makeFetchContext: baseSpec.makeFetchContext)
}
private static func makeClaudeProviderSpec(
baseSpec: ProviderSpec,
loader: @escaping @Sendable () async throws -> UsageSnapshot) -> ProviderSpec
{
let baseDescriptor = baseSpec.descriptor
let strategy = StatusMenuTokenAccountFetchStrategy(loader: loader)
let descriptor = ProviderDescriptor(
id: .claude,
metadata: baseDescriptor.metadata,
branding: baseDescriptor.branding,
tokenCost: baseDescriptor.tokenCost,
fetchPlan: ProviderFetchPlan(
sourceModes: [.auto, .cli, .oauth],
pipeline: ProviderFetchPipeline { _ in [strategy] }),
cli: baseDescriptor.cli)
return ProviderSpec(
style: baseSpec.style,
isEnabled: baseSpec.isEnabled,
descriptor: descriptor,
makeFetchContext: baseSpec.makeFetchContext)
}
private func snapshot(percent: Double = 12) -> UsageSnapshot {
UsageSnapshot(
primary: RateWindow(
usedPercent: percent,
windowMinutes: 300,
resetsAt: Date().addingTimeInterval(300),
resetDescription: nil),
secondary: nil,
updatedAt: Date(),
identity: ProviderIdentitySnapshot(
providerID: .claude,
accountEmail: "claude@example.com",
accountOrganization: nil,
loginMethod: "OAuth"))
}
func test_tokenAccountMenuSelectionRefreshesProviderWhileGlobalRefreshIsActive() async throws {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
self.enableOnlyClaude(settings)
settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary")
settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary")
settings.setActiveTokenAccountIndex(0, for: .claude)
let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
let blocker = BlockingTokenAccountFetchStrategy()
self.installBlockingClaudeProvider(on: store, blocker: blocker)
let controller = StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: testStatusBar())
defer { controller.releaseStatusItemsForTesting() }
let refreshTask = Task { @MainActor in
await store.refresh()
}
await blocker.waitUntilStarted(count: 1)
XCTAssertTrue(store.isRefreshing)
let menu = controller.makeMenu()
defer { withExtendedLifetime(menu) {} }
controller.menuWillOpen(menu)
let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first)
let selectionTask = try XCTUnwrap(switcher._test_select(index: 1))
XCTAssertEqual(settings.tokenAccountsData(for: .claude)?.clampedActiveIndex(), 1)
for _ in 0..<40 {
await Task.yield()
}
let startedBeforeDrain = await blocker.startedCallCount()
XCTAssertEqual(startedBeforeDrain, 1)
await blocker.resumeAll(with: .success(self.snapshot(percent: 17)))
await blocker.waitUntilStarted(count: 2)
await selectionTask.value
await refreshTask.value
let startedCallCount = await blocker.startedCallCount()
XCTAssertGreaterThanOrEqual(startedCallCount, 2)
}
func test_multiAccountSegmentedLayoutShowsCopilotSwitcher() throws {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
settings.multiAccountMenuLayout = .segmented
self.enableOnly(.copilot, settings)
settings.addTokenAccount(provider: .copilot, label: "Primary", token: "gh_primary")
settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "gh_secondary")
let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
let controller = StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: testStatusBar())
defer { controller.releaseStatusItemsForTesting() }
let menu = controller.makeMenu(for: .copilot)
controller.menuWillOpen(menu)
_ = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first)
XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard"])
}
func test_multiAccountStackedLayoutShowsCopilotCards() {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
settings.multiAccountMenuLayout = .stacked
self.enableOnly(.copilot, settings)
settings.addTokenAccount(provider: .copilot, label: "Primary", token: "gh_primary")
settings.addTokenAccount(provider: .copilot, label: "Secondary", token: "gh_secondary")
let accounts = settings.tokenAccounts(for: .copilot)
let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
store.accountSnapshots[.copilot] = accounts.enumerated().map { index, account in
TokenAccountUsageSnapshot(
account: account,
snapshot: self.snapshot(percent: Double(10 + index)),
error: nil,
sourceLabel: "test",
cacheKey: store.tokenAccountSnapshotCacheKey(provider: .copilot, account: account))
}
let controller = StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: testStatusBar())
defer { controller.releaseStatusItemsForTesting() }
let menu = controller.makeMenu(for: .copilot)
controller.menuWillOpen(menu)
XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first)
XCTAssertEqual(self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") }, ["menuCard-0", "menuCard-1"])
}
func test_multiAccountStackedRefreshStartsAccountFetchesConcurrently() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
settings.multiAccountMenuLayout = .stacked
self.enableOnlyClaude(settings)
settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary")
settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary")
let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
let blocker = BlockingTokenAccountFetchStrategy()
self.installBlockingClaudeProvider(on: store, blocker: blocker)
let refreshTask = Task { @MainActor in
await store.refreshProvider(.claude)
}
await blocker.waitUntilStarted(count: 2)
let startedBeforeResume = await blocker.startedCallCount()
XCTAssertEqual(startedBeforeResume, 2)
await blocker.resumeAll(with: .success(self.snapshot(percent: 17)))
await refreshTask.value
XCTAssertEqual(store.accountSnapshots[.claude]?.count, 2)
}
func test_multiAccountStackedLayoutIgnoresStaleSnapshotsAndKeepsMenuCapped() {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
settings.multiAccountMenuLayout = .stacked
self.enableOnly(.copilot, settings)
for index in 0..<8 {
settings.addTokenAccount(provider: .copilot, label: "Account \(index)", token: "gh_\(index)")
}
settings.setActiveTokenAccountIndex(7, for: .copilot)
let accounts = settings.tokenAccounts(for: .copilot)
let staleAccounts = (0..<2).map { index in
ProviderTokenAccount(
id: UUID(),
label: "Removed \(index)",
token: "stale_\(index)",
addedAt: TimeInterval(index),
lastUsed: nil)
}
let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
let staleSnapshots = staleAccounts.enumerated().map { index, account in
TokenAccountUsageSnapshot(
account: account,
snapshot: self.snapshot(percent: Double(70 + index)),
error: nil,
sourceLabel: "stale",
cacheKey: store.tokenAccountSnapshotCacheKey(provider: .copilot, account: account))
}
let currentSnapshots = accounts.enumerated().map { index, account in
TokenAccountUsageSnapshot(
account: account,
snapshot: self.snapshot(percent: Double(10 + index)),
error: nil,
sourceLabel: "current",
cacheKey: store.tokenAccountSnapshotCacheKey(provider: .copilot, account: account))
}
store.accountSnapshots[.copilot] = staleSnapshots + currentSnapshots
let controller = StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: testStatusBar())
defer { controller.releaseStatusItemsForTesting() }
let menu = controller.makeMenu(for: .copilot)
controller.menuWillOpen(menu)
XCTAssertNil(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first)
XCTAssertEqual(
self.representedIDs(in: menu).filter { $0.hasPrefix("menuCard") },
["menuCard-0", "menuCard-1", "menuCard-2", "menuCard-3", "menuCard-4", "menuCard-5"])
}
func test_multiAccountStackedLayoutRejectsSnapshotsAfterCredentialOrBaseURLChanges() throws {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.multiAccountMenuLayout = .stacked
self.enableOnly(.sub2api, settings)
settings.updateProviderConfig(provider: .sub2api) { config in
config.enterpriseHost = "https://first.example.test"
}
settings.addTokenAccount(provider: .sub2api, label: "Primary", token: "p1")
settings.addTokenAccount(provider: .sub2api, label: "Secondary", token: "p2")
let originalAccounts = settings.tokenAccounts(for: .sub2api)
let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
store.accountSnapshots[.sub2api] = originalAccounts.map { account in
TokenAccountUsageSnapshot(
account: account,
snapshot: self.snapshot(),
error: nil,
sourceLabel: "fixture",
cacheKey: store.tokenAccountSnapshotCacheKey(provider: .sub2api, account: account))
}
let controller = StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: testStatusBar())
defer { controller.releaseStatusItemsForTesting() }
XCTAssertEqual(try XCTUnwrap(controller.tokenAccountMenuDisplay(for: .sub2api)).snapshots.count, 2)
settings.updateTokenAccount(
provider: .sub2api,
accountID: originalAccounts[0].id,
token: "rotated-p1")
XCTAssertEqual(
try XCTUnwrap(controller.tokenAccountMenuDisplay(for: .sub2api)).snapshots.map(\.account.id),
[originalAccounts[1].id])
settings.updateProviderConfig(provider: .sub2api) { config in
config.enterpriseHost = "https://second.example.test"
}
XCTAssertTrue(try XCTUnwrap(controller.tokenAccountMenuDisplay(for: .sub2api)).snapshots.isEmpty)
}
func test_multiAccountStackedCancellationCannotRestoreCredentialStaleSnapshots() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.multiAccountMenuLayout = .stacked
self.enableOnlyClaude(settings)
settings.addTokenAccount(provider: .claude, label: "Primary", token: "p1")
settings.addTokenAccount(provider: .claude, label: "Secondary", token: "p2")
let originalAccounts = settings.tokenAccounts(for: .claude)
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
store.accountSnapshots[.claude] = originalAccounts.map { account in
TokenAccountUsageSnapshot(
account: account,
snapshot: self.snapshot(),
error: nil,
sourceLabel: "fixture",
cacheKey: store.tokenAccountSnapshotCacheKey(provider: .claude, account: account))
}
settings.updateTokenAccount(
provider: .claude,
accountID: originalAccounts[0].id,
token: "rotated-p1")
let blocker = BlockingTokenAccountFetchStrategy()
self.installBlockingClaudeProvider(on: store, blocker: blocker)
let refreshTask = Task { @MainActor in
await store.refreshProvider(.claude)
}
await blocker.waitUntilStarted(count: 2)
await blocker.resumeAll(with: .failure(CancellationError()))
await refreshTask.value
XCTAssertEqual(store.accountSnapshots[.claude]?.map(\.account.id), [originalAccounts[1].id])
}
func test_validTokenAccountSnapshotsHandlesDuplicateAccountIDsWithoutTrapping() {
let settings = self.makeSettings()
self.enableOnlyClaude(settings)
let id = UUID()
let first = ProviderTokenAccount(
id: id,
label: "First",
token: "f1",
addedAt: 1,
lastUsed: nil)
let duplicate = ProviderTokenAccount(
id: id,
label: "Duplicate",
token: "d1",
addedAt: 2,
lastUsed: nil)
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
store.accountSnapshots[.claude] = [
TokenAccountUsageSnapshot(
account: first,
snapshot: self.snapshot(),
error: nil,
sourceLabel: "fixture",
cacheKey: store.tokenAccountSnapshotCacheKey(provider: .claude, account: first)),
]
XCTAssertTrue(store.validTokenAccountSnapshots(provider: .claude, accounts: [first, duplicate]).isEmpty)
}
func test_duplicateAccountIDsRejectCrossCredentialPublication() async {
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
self.enableOnlyClaude(settings)
settings.addTokenAccount(provider: .claude, label: "First", token: "f1")
settings.addTokenAccount(provider: .claude, label: "Second", token: "s1")
let accounts = settings.tokenAccounts(for: .claude)
let duplicate = ProviderTokenAccount(
id: accounts[0].id,
label: accounts[1].label,
token: accounts[1].token,
addedAt: accounts[1].addedAt,
lastUsed: accounts[1].lastUsed)
settings.updateProviderConfig(provider: .claude) { config in
config.tokenAccounts = ProviderTokenAccountData(
version: 1,
accounts: [accounts[0], duplicate],
activeIndex: 1)
}
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
store._test_providerFetchOutcomeOverride = { _ in
ProviderFetchOutcome(
result: .success(ProviderFetchResult(
usage: self.snapshot(percent: 66),
credits: nil,
dashboard: nil,
sourceLabel: "fixture",
strategyID: "fixture",
strategyKind: .apiToken)),
attempts: [])
}
await store.refreshProvider(.claude)
XCTAssertNil(store.snapshot(for: .claude))
XCTAssertNil(store.accountSnapshots[.claude])
}
func test_authorizedTokenRotationPublishesAndCachesUnderTheRotatedCredential() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.multiAccountMenuLayout = .segmented
self.enableOnly(.antigravity, settings)
settings.addTokenAccount(provider: .antigravity, label: "Primary", token: "p1")
settings.addTokenAccount(provider: .antigravity, label: "Secondary", token: "p2")
settings.setActiveTokenAccountIndex(0, for: .antigravity)
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
self.installRotatingProvider(on: store, provider: .antigravity, rotatedToken: "n1")
await store.refreshProvider(.antigravity)
let accountsAfterPrimaryRefresh = settings.tokenAccounts(for: .antigravity)
XCTAssertEqual(accountsAfterPrimaryRefresh[0].token, "n1")
XCTAssertEqual(store.snapshot(for: .antigravity)?.primary?.usedPercent, 37)
XCTAssertEqual(
store.accountSnapshots[.antigravity]?.first?.cacheKey,
store.tokenAccountSnapshotCacheKey(provider: .antigravity, account: accountsAfterPrimaryRefresh[0]))
settings.setActiveTokenAccountIndex(1, for: .antigravity)
await store.refreshProvider(.antigravity)
settings.setActiveTokenAccountIndex(0, for: .antigravity)
store.activateCachedTokenAccountSnapshot(
provider: .antigravity,
accountID: accountsAfterPrimaryRefresh[0].id)
XCTAssertEqual(store.snapshot(for: .antigravity)?.primary?.usedPercent, 37)
XCTAssertEqual(store.accountSnapshots[.antigravity]?.count, 2)
}
func test_tokenAccountSwitchDefersOpenMenuRebuildUntilAfterSwitcherAction() async throws {
self.disableMenuCardsForTesting()
StatusItemController.setMenuRefreshEnabledForTesting(true)
defer { StatusItemController.setMenuRefreshEnabledForTesting(false) }
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = true
settings.selectedMenuProvider = .claude
settings.multiAccountMenuLayout = .segmented
let registry = ProviderRegistry.shared
for provider in UsageProvider.allCases {
guard let metadata = registry.metadata[provider] else { continue }
settings.setProviderEnabled(
provider: provider,
metadata: metadata,
enabled: provider == .claude || provider == .codex)
}
settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary")
settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary")
settings.setActiveTokenAccountIndex(0, for: .claude)
let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
let blocker = BlockingTokenAccountFetchStrategy()
self.installBlockingClaudeProvider(on: store, blocker: blocker)
let controller = StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: testStatusBar())
defer { controller.releaseStatusItemsForTesting() }
let menu = controller.makeMenu()
controller.menuWillOpen(menu)
let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first)
var rebuildCount = 0
controller._test_openMenuRebuildObserver = { _ in
rebuildCount += 1
}
defer { controller._test_openMenuRebuildObserver = nil }
let selectionTask = try XCTUnwrap(switcher._test_select(index: 1))
XCTAssertEqual(rebuildCount, 0)
for _ in 0..<20 where rebuildCount == 0 {
await Task.yield()
}
XCTAssertEqual(rebuildCount, 1)
await blocker.waitUntilStarted(count: 1)
await blocker.resumeAll(with: .success(self.snapshot(percent: 17)))
await selectionTask.value
for _ in 0..<20 where rebuildCount < 2 {
await Task.yield()
}
XCTAssertEqual(rebuildCount, 2)
}
func test_tokenAccountSwitchUsesSelectedAccountCacheWhileRefreshIsInFlight() async throws {
self.disableMenuCardsForTesting()
StatusItemController.setMenuRefreshEnabledForTesting(true)
defer { StatusItemController.setMenuRefreshEnabledForTesting(false) }
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
settings.multiAccountMenuLayout = .segmented
self.enableOnlyClaude(settings)
settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary")
settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary")
settings.setActiveTokenAccountIndex(0, for: .claude)
let accounts = settings.tokenAccounts(for: .claude)
let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
store.snapshots[.claude] = self.snapshot(percent: 11)
store.lastKnownResetSnapshots[.claude] = self.snapshot(percent: 11)
store.errors[.claude] = "primary-error"
store.lastSourceLabels[.claude] = "primary-cache"
store.accountSnapshots[.claude] = [
TokenAccountUsageSnapshot(
account: accounts[0],
snapshot: self.snapshot(percent: 11),
error: nil,
sourceLabel: "primary-cache",
cacheKey: store.tokenAccountSnapshotCacheKey(provider: .claude, account: accounts[0])),
TokenAccountUsageSnapshot(
account: accounts[1],
snapshot: self.snapshot(percent: 72),
error: nil,
sourceLabel: "secondary-cache",
cacheKey: store.tokenAccountSnapshotCacheKey(provider: .claude, account: accounts[1])),
]
let blocker = BlockingTokenAccountFetchStrategy()
self.installBlockingClaudeProvider(on: store, blocker: blocker)
let controller = StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: testStatusBar())
defer { controller.releaseStatusItemsForTesting() }
let menu = controller.makeMenu(for: .claude)
controller.menuWillOpen(menu)
let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first)
let selectionTask = try XCTUnwrap(switcher._test_select(index: 1))
XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 72)
XCTAssertEqual(store.lastKnownResetSnapshots[.claude]?.primary?.usedPercent, 72)
XCTAssertNil(store.errors[.claude])
XCTAssertEqual(store.sourceLabel(for: .claude), "secondary-cache")
await blocker.waitUntilStarted(count: 1)
await blocker.resumeAll(with: .success(self.snapshot(percent: 45)))
await selectionTask.value
}
func test_tokenAccountSwitchClearsPreviousAccountIdentityUntilSelectedRefreshCompletes() async throws {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.mergeIcons = false
settings.multiAccountMenuLayout = .segmented
self.enableOnlyClaude(settings)
settings.addTokenAccount(provider: .claude, label: "Primary", token: "Bearer sk-ant-oat-primary")
settings.addTokenAccount(provider: .claude, label: "Secondary", token: "Bearer sk-ant-oat-secondary")
settings.setActiveTokenAccountIndex(0, for: .claude)
let accounts = settings.tokenAccounts(for: .claude)
let fetcher = UsageFetcher()
let store = UsageStore(fetcher: fetcher, browserDetection: BrowserDetection(cacheTTL: 0), settings: settings)
store.snapshots[.claude] = self.snapshot(percent: 11)
store.lastKnownResetSnapshots[.claude] = self.snapshot(percent: 11)
store.errors[.claude] = "primary-error"
store.lastSourceLabels[.claude] = "primary-cache"
store.accountSnapshots[.claude] = [
TokenAccountUsageSnapshot(
account: accounts[0],
snapshot: self.snapshot(percent: 11),
error: nil,
sourceLabel: "primary-cache",
cacheKey: store.tokenAccountSnapshotCacheKey(provider: .claude, account: accounts[0])),
]
let blocker = BlockingTokenAccountFetchStrategy()
self.installBlockingClaudeProvider(on: store, blocker: blocker)
let controller = StatusItemController(
store: store,
settings: settings,
account: fetcher.loadAccountInfo(),
updater: DisabledUpdaterController(),
preferencesSelection: PreferencesSelection(),
statusBar: testStatusBar())
defer { controller.releaseStatusItemsForTesting() }
let menu = controller.makeMenu(for: .claude)
controller.menuWillOpen(menu)
let switcher = try XCTUnwrap(menu.items.compactMap { $0.view as? TokenAccountSwitcherView }.first)
let selectionTask = try XCTUnwrap(switcher._test_select(index: 1))
XCTAssertNil(store.snapshot(for: .claude))
XCTAssertNil(store.snapshot(for: .claude)?.identity(for: .claude))
let pausedModel = try XCTUnwrap(controller.menuCardModel(for: .claude))
XCTAssertTrue(pausedModel.email.isEmpty)
XCTAssertTrue(pausedModel.metrics.isEmpty)
XCTAssertNil(store.lastKnownResetSnapshots[.claude])
XCTAssertNil(store.errors[.claude])
XCTAssertNil(store.lastSourceLabels[.claude])
await blocker.waitUntilStarted(count: 1)
await blocker.resumeAll(with: .success(self.snapshot(percent: 45)))
await selectionTask.value
XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 45)
XCTAssertEqual(
store.accountSnapshots[.claude]?.first(where: { $0.account.id == accounts[1].id })?
.snapshot?.primary?.usedPercent,
45)
}
func test_segmentedRefreshPreservesValidAccountCacheAndInvalidatesCredentialChanges() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.multiAccountMenuLayout = .segmented
self.enableOnlyClaude(settings)
settings.addTokenAccount(provider: .claude, label: "Primary", token: "p1")
settings.addTokenAccount(provider: .claude, label: "Secondary", token: "p2")
settings.setActiveTokenAccountIndex(0, for: .claude)
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
store._test_providerFetchOutcomeOverride = { _ in
let percent = settings.selectedTokenAccount(for: .claude)?.label == "Primary" ? 11.0 : 72.0
return ProviderFetchOutcome(
result: .success(ProviderFetchResult(
usage: self.snapshot(percent: percent),
credits: nil,
dashboard: nil,
sourceLabel: "fixture",
strategyID: "fixture",
strategyKind: .apiToken)),
attempts: [])
}
await store.refreshProvider(.claude)
let originalAccounts = settings.tokenAccounts(for: .claude)
XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 11)
XCTAssertEqual(store.accountSnapshots[.claude]?.count, 1)
settings.setActiveTokenAccountIndex(1, for: .claude)
store.activateCachedTokenAccountSnapshot(provider: .claude, accountID: originalAccounts[1].id)
XCTAssertNil(store.snapshot(for: .claude))
await store.refreshProvider(.claude)
XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 72)
XCTAssertEqual(store.accountSnapshots[.claude]?.count, 2)
settings.setActiveTokenAccountIndex(0, for: .claude)
store.activateCachedTokenAccountSnapshot(provider: .claude, accountID: originalAccounts[0].id)
XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 11)
settings.updateTokenAccount(
provider: .claude,
accountID: originalAccounts[0].id,
token: "rotated-p1")
store.activateCachedTokenAccountSnapshot(provider: .claude, accountID: originalAccounts[0].id)
XCTAssertNil(store.snapshot(for: .claude))
XCTAssertEqual(store.accountSnapshots[.claude]?.map(\.account.id), [originalAccounts[1].id])
settings.removeTokenAccount(provider: .claude, accountID: originalAccounts[1].id)
store.pruneTokenAccountSnapshots(provider: .claude, accounts: settings.tokenAccounts(for: .claude))
XCTAssertNil(store.accountSnapshots[.claude])
}
}
extension StatusMenuTokenAccountSwitcherTests {
func test_segmentedRefreshClearsLiveSnapshotWhenCredentialChangesAndReplacementFails() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.multiAccountMenuLayout = .segmented
self.enableOnlyClaude(settings)
settings.addTokenAccount(provider: .claude, label: "Primary", token: "p1")
let account = try? XCTUnwrap(settings.selectedTokenAccount(for: .claude))
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
store._test_providerFetchOutcomeOverride = { _ in
ProviderFetchOutcome(
result: .success(ProviderFetchResult(
usage: self.snapshot(percent: 45),
credits: nil,
dashboard: nil,
sourceLabel: "fixture",
strategyID: "fixture",
strategyKind: .apiToken)),
attempts: [])
}
await store.refreshProvider(.claude)
XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 45)
XCTAssertEqual(store.sourceLabel(for: .claude), "fixture")
if let account {
settings.updateTokenAccount(provider: .claude, accountID: account.id, token: "rotated-p1")
}
store._test_providerFetchOutcomeOverride = { _ in
ProviderFetchOutcome(result: .failure(StatusMenuTokenAccountTestError.rejected), attempts: [])
}
await store.refreshProvider(.claude)
XCTAssertNil(store.snapshot(for: .claude))
XCTAssertNil(store.lastSourceLabels[.claude])
XCTAssertNil(store.lastKnownResetSnapshots[.claude])
XCTAssertNil(store.accountSnapshots[.claude])
}
func test_segmentedRefreshClearsLiveSnapshotWhenBaseURLChangesAndReplacementFails() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.multiAccountMenuLayout = .segmented
self.enableOnly(.sub2api, settings)
settings.updateProviderConfig(provider: .sub2api) { config in
config.enterpriseHost = "https://first.example.test"
}
settings.addTokenAccount(provider: .sub2api, label: "Primary", token: "p1")
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
store._test_providerFetchOutcomeOverride = { _ in
ProviderFetchOutcome(
result: .success(ProviderFetchResult(
usage: self.snapshot(percent: 45),
credits: nil,
dashboard: nil,
sourceLabel: "fixture",
strategyID: "fixture",
strategyKind: .apiToken)),
attempts: [])
}
await store.refreshProvider(.sub2api)
XCTAssertEqual(store.snapshot(for: .sub2api)?.primary?.usedPercent, 45)
settings.updateProviderConfig(provider: .sub2api) { config in
config.enterpriseHost = "https://second.example.test"
}
store._test_providerFetchOutcomeOverride = { _ in
ProviderFetchOutcome(result: .failure(StatusMenuTokenAccountTestError.rejected), attempts: [])
}
await store.refreshProvider(.sub2api)
XCTAssertNil(store.snapshot(for: .sub2api))
XCTAssertNil(store.lastSourceLabels[.sub2api])
XCTAssertNil(store.lastKnownResetSnapshots[.sub2api])
XCTAssertNil(store.accountSnapshots[.sub2api])
}
func test_segmentedRefreshClearsLiveSnapshotWhenLastAccountIsRemovedAndFallbackFails() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.multiAccountMenuLayout = .segmented
self.enableOnlyClaude(settings)
settings.addTokenAccount(provider: .claude, label: "Primary", token: "p1")
let accountID = settings.selectedTokenAccount(for: .claude)?.id
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
store._test_providerFetchOutcomeOverride = { _ in
ProviderFetchOutcome(
result: .success(ProviderFetchResult(
usage: self.snapshot(percent: 45),
credits: nil,
dashboard: nil,
sourceLabel: "fixture",
strategyID: "fixture",
strategyKind: .apiToken)),
attempts: [])
}
await store.refreshProvider(.claude)
XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 45)
if let accountID {
settings.removeTokenAccount(provider: .claude, accountID: accountID)
}
store._test_providerFetchOutcomeOverride = { _ in
ProviderFetchOutcome(result: .failure(StatusMenuTokenAccountTestError.rejected), attempts: [])
}
await store.refreshProvider(.claude)
XCTAssertNil(store.snapshot(for: .claude))
XCTAssertNil(store.lastSourceLabels[.claude])
XCTAssertNil(store.lastKnownResetSnapshots[.claude])
XCTAssertNil(store.accountSnapshots[.claude])
}
func test_segmentedRefreshClearsTokenAccountErrorWhenFailedAccountIsRemovedAndFallbackCancels() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.multiAccountMenuLayout = .segmented
self.enableOnlyClaude(settings)
settings.addTokenAccount(provider: .claude, label: "Primary", token: "p1")
let accountID = settings.selectedTokenAccount(for: .claude)?.id
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
store._test_providerFetchOutcomeOverride = { _ in
ProviderFetchOutcome(result: .failure(StatusMenuTokenAccountTestError.rejected), attempts: [])
}
await store.refreshProvider(.claude)
XCTAssertNotNil(store.userFacingError(for: .claude))
XCTAssertTrue(store.tokenAccountLiveStateProviders.contains(.claude))
if let accountID {
settings.removeTokenAccount(provider: .claude, accountID: accountID)
}
store._test_providerFetchOutcomeOverride = { _ in
ProviderFetchOutcome(result: .failure(CancellationError()), attempts: [])
}
await store.refreshProvider(.claude)
XCTAssertNil(store.snapshot(for: .claude))
XCTAssertNil(store.userFacingError(for: .claude))
XCTAssertNil(store.knownLimitsAvailabilityByProvider[.claude])
XCTAssertFalse(store.tokenAccountLiveStateProviders.contains(.claude))
}
func test_segmentedRefreshPreservesAmbientSnapshotWithoutTokenAccountOwnership() async {
self.disableMenuCardsForTesting()
let settings = self.makeSettings()
settings.statusChecksEnabled = false
settings.refreshFrequency = .manual
settings.multiAccountMenuLayout = .segmented
self.enableOnlyClaude(settings)
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
store._setSnapshotForTesting(self.snapshot(percent: 45), provider: .claude)
store._test_providerFetchOutcomeOverride = { _ in
ProviderFetchOutcome(result: .failure(CancellationError()), attempts: [])
}
await store.refreshProvider(.claude)
XCTAssertEqual(store.snapshot(for: .claude)?.primary?.usedPercent, 45)
XCTAssertFalse(store.tokenAccountLiveStateProviders.contains(.claude))
}
}
private enum StatusMenuTokenAccountTestError: Error {
case rejected
}
private struct StatusMenuTokenAccountFetchStrategy: ProviderFetchStrategy {
let loader: @Sendable () async throws -> UsageSnapshot
var id: String {
"status-menu-token-account-test"
}
var kind: ProviderFetchKind {
.cli
}
func isAvailable(_: ProviderFetchContext) async -> Bool {
true
}
func fetch(_: ProviderFetchContext) async throws -> ProviderFetchResult {
let snapshot = try await self.loader()
return self.makeResult(usage: snapshot, sourceLabel: "status-menu-token-account-test")
}
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}
}
private struct RotatingTokenAccountFetchStrategy: ProviderFetchStrategy {
let provider: UsageProvider
let rotatedToken: String
let snapshot: UsageSnapshot
var id: String {
"rotating-token-account-test"
}
var kind: ProviderFetchKind {
.apiToken
}
func isAvailable(_: ProviderFetchContext) async -> Bool {
true
}
func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult {
guard let accountID = context.selectedTokenAccountID,
let updater = context.tokenAccountTokenUpdater
else {
throw RotatingTokenAccountTestError.missingUpdater
}
await updater(self.provider, accountID, self.rotatedToken)
return self.makeResult(usage: self.snapshot, sourceLabel: "rotating-token-account-test")
}
func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool {
false
}
}
private enum RotatingTokenAccountTestError: Error {
case missingUpdater
}
private actor BlockingTokenAccountFetchStrategy {
private var waiters: [CheckedContinuation<Result<UsageSnapshot, Error>, Never>] = []
private var startedWaiters: [(count: Int, continuation: CheckedContinuation<Void, Never>)] = []
private var resolvedResult: Result<UsageSnapshot, Error>?
private var startedCount = 0
func awaitResult() async throws -> UsageSnapshot {
if let resolvedResult {
self.startedCount += 1
self.resumeStartedWaiters()
return try resolvedResult.get()
}
let result = await withCheckedContinuation { continuation in
self.waiters.append(continuation)
self.startedCount += 1
self.resumeStartedWaiters()
}
return try result.get()
}
func waitUntilStarted(count: Int) async {
if self.startedCount >= count {
return
}
await withCheckedContinuation { continuation in
self.startedWaiters.append((count: count, continuation: continuation))
}
}
func startedCallCount() -> Int {
self.startedCount
}
func resumeAll(with result: Result<UsageSnapshot, Error>) {
self.resolvedResult = result
self.waiters.forEach { $0.resume(returning: result) }
self.waiters.removeAll()
}
private func resumeStartedWaiters() {
let ready = self.startedWaiters.filter { self.startedCount >= $0.count }
self.startedWaiters.removeAll { self.startedCount >= $0.count }
ready.forEach { $0.continuation.resume() }
}
}