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

260 lines
11 KiB
Swift

import CodexBarCore
import Foundation
import Testing
@testable import CodexBar
@MainActor
struct ClaudeProviderRuntimeTests {
@Test
func `disabling adapter immediately clears retained accounts`() {
let (settings, store) = self.makeStore()
store.claudeSwapAccountSnapshots = [self.accountSnapshot()]
store.claudeSwapLastRefreshAt = Date()
store.claudeSwapLastError = "stale"
let runtime = ClaudeProviderRuntime()
runtime.settingsDidChange(context: ProviderRuntimeContext(provider: .claude, settings: settings, store: store))
#expect(store.claudeSwapAccountSnapshots.isEmpty)
#expect(store.claudeSwapLastRefreshAt == nil)
#expect(store.claudeSwapLastError == nil)
}
@Test
func `disabled Claude provider does not restart adapter`() {
let (settings, store) = self.makeStore()
settings.claudeSwapExecutablePath = "/path/to/cswap"
settings.claudeSwapEnabled = true
let runtime = ClaudeProviderRuntime()
let context = ProviderRuntimeContext(provider: .claude, settings: settings, store: store)
runtime.stop(context: context)
runtime.settingsDidChange(context: context)
#expect(!store.isEnabled(.claude))
#expect(store.claudeSwapRefreshTask == nil)
}
@Test
func `late adapter result is rejected after executable path changes`() async throws {
let (settings, store) = self.makeStore()
let executable = try self.makeFakeExecutable()
let metadata = try #require(ProviderRegistry.shared.metadata[.claude])
settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
settings.claudeSwapExecutablePath = executable
settings.claudeSwapEnabled = true
let refresh = Task { @MainActor in
await store.refreshClaudeSwapAccounts()
}
try await Task.sleep(for: .milliseconds(100))
settings.claudeSwapExecutablePath = "/new/path/to/cswap"
await refresh.value
#expect(store.claudeSwapAccountSnapshots.isEmpty)
#expect(store.claudeSwapLastRefreshAt == nil)
}
@Test
func `explicit account activation is serialized through claude swap and refreshes Claude`() async throws {
let (settings, store) = self.makeStore()
let marker = FileManager.default.temporaryDirectory
.appendingPathComponent("claude-swap-switch-args-\(UUID().uuidString)")
let executable = try self.makeSwitchExecutable(marker: marker)
let metadata = try #require(ProviderRegistry.shared.metadata[.claude])
settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
settings.claudeSwapExecutablePath = executable
settings.claudeSwapEnabled = true
let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2")
store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot(
id: accountID,
provider: .claude,
displayLabel: "switch@example.com",
isActive: false,
canActivate: true,
snapshot: nil,
error: nil,
sourceLabel: ClaudeSwapAccountProjection.sourceLabel)]
var refreshedProviders: [UsageProvider] = []
store._test_providerRefreshOverride = { refreshedProviders.append($0) }
defer { store._test_providerRefreshOverride = nil }
store.switchClaudeSwapAccount(accountID)
let task = try #require(store.claudeSwapTransientState.task)
await task.value
let arguments = try String(contentsOf: marker, encoding: .utf8)
#expect(arguments == "--switch-to\n2\n--json\n")
#expect(refreshedProviders == [.claude])
#expect(store.claudeSwapTransientState.task == nil)
#expect(store.claudeSwapTransientState.switchingAccountID == nil)
#expect(store.claudeSwapTransientState.lastError == nil)
#expect(store.claudeSwapTransientState.lastErrorAccountID == nil)
}
@Test
func `non actionable account cannot start credential transaction`() throws {
let (settings, store) = self.makeStore()
let metadata = try #require(ProviderRegistry.shared.metadata[.claude])
settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
settings.claudeSwapExecutablePath = "/path/to/cswap"
settings.claudeSwapEnabled = true
let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2")
store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot(
id: accountID,
provider: .claude,
displayLabel: "expired@example.com",
isActive: false,
canActivate: false,
snapshot: nil,
error: "Token expired",
sourceLabel: ClaudeSwapAccountProjection.sourceLabel)]
store.switchClaudeSwapAccount(accountID)
#expect(store.claudeSwapTransientState.task == nil)
}
@Test
func `failed activation stays scoped to its requested account`() async throws {
let (settings, store) = self.makeStore()
let executable = try self.makeFailedSwitchExecutable()
let metadata = try #require(ProviderRegistry.shared.metadata[.claude])
settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
settings.claudeSwapExecutablePath = executable
settings.claudeSwapEnabled = true
let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2")
store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot(
id: accountID,
provider: .claude,
displayLabel: "switch@example.com",
isActive: false,
canActivate: true,
snapshot: nil,
error: nil,
sourceLabel: ClaudeSwapAccountProjection.sourceLabel)]
store._test_providerRefreshOverride = { _ in }
defer { store._test_providerRefreshOverride = nil }
store.switchClaudeSwapAccount(accountID)
let task = try #require(store.claudeSwapTransientState.task)
await task.value
#expect(store.claudeSwapTransientState.lastError?.contains("credentials missing") == true)
#expect(store.claudeSwapTransientState.lastErrorAccountID == accountID)
}
@Test
func `configuration change during provider refresh discards switch result`() async throws {
let (settings, store) = self.makeStore()
let executable = try self.makeFailedSwitchExecutable()
let metadata = try #require(ProviderRegistry.shared.metadata[.claude])
settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
settings.claudeSwapExecutablePath = executable
settings.claudeSwapEnabled = true
let accountID = ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "2")
store.claudeSwapAccountSnapshots = [ProviderAccountUsageSnapshot(
id: accountID,
provider: .claude,
displayLabel: "switch@example.com",
isActive: false,
canActivate: true,
snapshot: nil,
error: nil,
sourceLabel: ClaudeSwapAccountProjection.sourceLabel)]
store._test_providerRefreshOverride = { _ in
settings.claudeSwapExecutablePath = "/new/path/to/cswap"
}
defer { store._test_providerRefreshOverride = nil }
store.switchClaudeSwapAccount(accountID)
let task = try #require(store.claudeSwapTransientState.task)
await task.value
#expect(store.claudeSwapTransientState.task == nil)
#expect(store.claudeSwapTransientState.switchingAccountID == nil)
#expect(store.claudeSwapTransientState.lastError == nil)
#expect(store.claudeSwapTransientState.lastErrorAccountID == nil)
}
private func makeStore() -> (SettingsStore, UsageStore) {
let suite = "ClaudeProviderRuntimeTests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defaults.removePersistentDomain(forName: suite)
let settings = SettingsStore(
userDefaults: defaults,
configStore: testConfigStore(suiteName: suite),
zaiTokenStore: NoopZaiTokenStore(),
syntheticTokenStore: NoopSyntheticTokenStore())
let store = UsageStore(
fetcher: UsageFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0),
settings: settings)
return (settings, store)
}
private func accountSnapshot() -> ProviderAccountUsageSnapshot {
ProviderAccountUsageSnapshot(
id: ProviderAccountIdentity(source: ClaudeSwapAccountProjection.sourceName, opaqueID: "1"),
provider: .claude,
displayLabel: "account@example.com",
isActive: false,
snapshot: nil,
error: "Token expired",
sourceLabel: ClaudeSwapAccountProjection.sourceLabel)
}
private func makeFakeExecutable() throws -> String {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("claude-runtime-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("cswap")
let script = """
#!/bin/sh
if [ "$1" = "--version" ]; then
echo 'cswap 0.16.0'
exit 0
fi
sleep 0.3
cat <<'EOF'
{"schemaVersion":1,"activeAccountNumber":1,"accounts":[
{"number":1,"email":"a@b.c","active":true,"usageStatus":"ok","usage":{"fiveHour":{"pct":12.5}}}
]}
EOF
"""
try script.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
return url.path
}
private func makeSwitchExecutable(marker: URL) throws -> String {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("claude-switch-runtime-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("cswap")
let script = """
#!/bin/sh
printf '%s\n' "$@" > '\(marker.path)'
echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":2},"reason":"switched"}'
"""
try script.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
return url.path
}
private func makeFailedSwitchExecutable() throws -> String {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("claude-failed-switch-runtime-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("cswap")
let script = """
#!/bin/sh
echo '{"schemaVersion":1,"error":{"type":"SwitchError","message":"credentials missing"}}'
exit 1
"""
try script.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
return url.path
}
}