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

202 lines
7.4 KiB
Swift

import Foundation
import Testing
@testable import CodexBarCore
/// Reader tests use fake executables only: no real
/// claude-swap install, no credentials, no Keychain access.
struct ClaudeSwapAccountReaderTests {
private func makeFakeExecutable(_ script: String) throws -> String {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("claude-swap-reader-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("cswap")
try "#!/bin/sh\n\(script)\n".write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
return url.path
}
@Test
func `reads and parses a schema v1 list from the executable`() async throws {
let path = try self.makeFakeExecutable("""
[ "$1" = "--list" ] || exit 2
[ "$2" = "--json" ] || exit 2
cat <<'EOF'
{"schemaVersion": 1, "activeAccountNumber": 1, "accounts": [
{"number": 1, "email": "a@b.c", "active": true, "usageStatus": "ok",
"usage": {"fiveHour": {"pct": 12.5}}}
]}
EOF
""")
let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: path)
#expect(list.activeAccountNumber == 1)
#expect(list.accounts.first?.fiveHour?.usedPercent == 12.5)
}
@Test
func `surfaces the error envelope from a non zero exit`() async throws {
let path = try self.makeFakeExecutable("""
echo '{"schemaVersion": 1, "error": {"type": "SwitchError", "message": "store locked"}}'
exit 1
""")
await #expect(throws: ClaudeSwapListParserError.reportedError(type: "SwitchError", message: "store locked")) {
try await ClaudeSwapAccountReader.readAccountList(executablePath: path)
}
}
@Test
func `terminates executables that exceed the timeout`() async throws {
let path = try self.makeFakeExecutable("sleep 30")
await #expect(throws: (any Error).self) {
try await ClaudeSwapAccountReader.readAccountList(executablePath: path, timeout: 0.5)
}
}
@Test
func `rejects oversized output before parsing`() async throws {
let path = try self.makeFakeExecutable("""
i=0
while [ $i -lt 5000 ]; do
printf '%s' '{"filler": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"}'
i=$((i+1))
done
""")
await #expect(throws: (any Error).self) {
try await ClaudeSwapAccountReader.readAccountList(executablePath: path)
}
}
@Test
func `fails cleanly when the executable is missing`() async throws {
await #expect(throws: (any Error).self) {
try await ClaudeSwapAccountReader.readAccountList(
executablePath: "/nonexistent/path/to/cswap")
}
await #expect(throws: ClaudeSwapAccountReaderError.self) {
try await ClaudeSwapAccountReader.readAccountList(executablePath: " ")
}
}
@Test
func `reads the executable version`() async throws {
let path = try self.makeFakeExecutable("""
[ "$1" = "--version" ] || exit 2
echo 'cswap 0.16.0'
""")
let version = await ClaudeSwapAccountReader.readVersion(executablePath: path)
#expect(version == "0.16.0")
}
@Test
func `switches only by validated numeric slot with fixed arguments`() async throws {
let path = try self.makeFakeExecutable("""
[ "$1" = "--switch-to" ] || exit 2
[ "$2" = "7" ] || exit 2
[ "$3" = "--json" ] || exit 2
[ -z "$4" ] || exit 2
echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":7},"reason":"switched"}'
""")
let result = try await ClaudeSwapAccountReader.switchAccount(
executablePath: path,
accountNumber: 7)
#expect(result.switched)
#expect(result.fromAccountNumber == 1)
#expect(result.toAccountNumber == 7)
}
@Test
func `rejects switch result for another slot`() async throws {
let path = try self.makeFakeExecutable("""
echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":8},"reason":"switched"}'
""")
await #expect(throws: ClaudeSwapSwitchParserError.mismatchedTarget(expected: 7, actual: 8)) {
try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 7)
}
}
@Test
func `surfaces switch error envelope from non zero exit`() async throws {
let path = try self.makeFakeExecutable("""
echo '{"schemaVersion":1,"error":{"type":"SwitchError","message":"credentials missing"}}'
exit 1
""")
await #expect(throws: ClaudeSwapSwitchParserError.reportedError(
type: "SwitchError",
message: "credentials missing"))
{
try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 2)
}
}
@Test
func `started credential switch reaches natural exit after caller cancellation`() async throws {
let marker = FileManager.default.temporaryDirectory
.appendingPathComponent("claude-swap-switch-finished-\(UUID().uuidString)")
let path = try self.makeFakeExecutable("""
sleep 0.3
touch '\(marker.path)'
echo '{"schemaVersion":1,"switched":true,"from":{"number":1},"to":{"number":2},"reason":"switched"}'
""")
let task = Task {
try await ClaudeSwapAccountReader.switchAccount(executablePath: path, accountNumber: 2)
}
try await Task.sleep(for: .milliseconds(100))
task.cancel()
let result = try await task.value
#expect(result.switched)
#expect(FileManager.default.fileExists(atPath: marker.path))
}
@Test
func `version probe returns nil when the executable fails`() async throws {
let path = try self.makeFakeExecutable("exit 3")
let version = await ClaudeSwapAccountReader.readVersion(executablePath: path)
#expect(version == nil)
}
@Test
func `cancellation during version probe prevents account list launch`() async throws {
let marker = FileManager.default.temporaryDirectory
.appendingPathComponent("claude-swap-list-launched-\(UUID().uuidString)")
let path = try self.makeFakeExecutable("""
if [ "$1" = "--version" ]; then
sleep 30
exit 0
fi
touch '\(marker.path)'
echo '{"schemaVersion":1,"activeAccountNumber":null,"accounts":[]}'
""")
let task = Task {
_ = await ClaudeSwapAccountReader.readVersion(executablePath: path)
return try await ClaudeSwapAccountReader.readAccountList(executablePath: path)
}
try await Task.sleep(for: .milliseconds(100))
task.cancel()
await #expect(throws: CancellationError.self) {
try await task.value
}
#expect(!FileManager.default.fileExists(atPath: marker.path))
}
@Test
func `expands tilde in configured paths`() throws {
let resolved = try ClaudeSwapAccountReader.resolvedExecutablePath("~/bin/cswap")
#expect(resolved.hasPrefix("/"))
#expect(!resolved.contains("~"))
#expect(resolved.hasSuffix("/bin/cswap"))
}
}