790 lines
25 KiB
Swift
790 lines
25 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
struct KiloUsageFetcherTests {
|
|
private struct StubClaudeFetcher: ClaudeUsageFetching {
|
|
func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot {
|
|
throw ClaudeUsageError.parseFailed("stub")
|
|
}
|
|
|
|
func debugRawProbe(model _: String) async -> String {
|
|
"stub"
|
|
}
|
|
|
|
func detectVersion() -> String? {
|
|
nil
|
|
}
|
|
}
|
|
|
|
private func makeContext(
|
|
env: [String: String] = [:],
|
|
sourceMode: ProviderSourceMode = .api) -> ProviderFetchContext
|
|
{
|
|
let browserDetection = BrowserDetection(cacheTTL: 0)
|
|
return ProviderFetchContext(
|
|
runtime: .cli,
|
|
sourceMode: sourceMode,
|
|
includeCredits: false,
|
|
webTimeout: 1,
|
|
webDebugDumpHTML: false,
|
|
verbose: false,
|
|
env: env,
|
|
settings: nil,
|
|
fetcher: UsageFetcher(environment: env),
|
|
claudeFetcher: StubClaudeFetcher(),
|
|
browserDetection: browserDetection)
|
|
}
|
|
|
|
@Test
|
|
func `batch URL uses authenticated TRPC batch format`() throws {
|
|
let baseURL = try #require(URL(string: "https://kilo.example/trpc"))
|
|
let url = try KiloUsageFetcher._buildBatchURLForTesting(baseURL: baseURL)
|
|
|
|
#expect(url.path.contains("user.getCreditBlocks,kiloPass.getState,user.getAutoTopUpPaymentMethod"))
|
|
|
|
let components = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false))
|
|
let batch = components.queryItems?.first(where: { $0.name == "batch" })?.value
|
|
let inputValue = components.queryItems?.first(where: { $0.name == "input" })?.value
|
|
|
|
#expect(batch == "1")
|
|
let requiredInput = try #require(inputValue)
|
|
let inputData = Data(requiredInput.utf8)
|
|
let inputObject = try #require(try JSONSerialization.jsonObject(with: inputData) as? [String: Any])
|
|
let first = try #require(inputObject["0"] as? [String: Any])
|
|
let second = try #require(inputObject["1"] as? [String: Any])
|
|
let third = try #require(inputObject["2"] as? [String: Any])
|
|
|
|
#expect(inputObject.keys.contains("0"))
|
|
#expect(inputObject.keys.contains("1"))
|
|
#expect(inputObject.keys.contains("2"))
|
|
#expect(first["json"] is NSNull)
|
|
#expect(second["json"] is NSNull)
|
|
#expect(third["json"] is NSNull)
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot maps business fields and identity`() throws {
|
|
let json = """
|
|
[
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"blocks": [
|
|
{
|
|
"usedCredits": 25,
|
|
"totalCredits": 100,
|
|
"remainingCredits": 75
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"plan": {
|
|
"name": "Kilo Pass Pro"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"enabled": true,
|
|
"paymentMethod": "visa"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
"""
|
|
|
|
let parsed = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8))
|
|
let snapshot = parsed.toUsageSnapshot()
|
|
|
|
#expect(snapshot.primary?.usedPercent == 25)
|
|
#expect(snapshot.identity?.providerID == .kilo)
|
|
#expect(snapshot.loginMethod(for: .kilo)?.contains("Kilo Pass Pro") == true)
|
|
#expect(snapshot.loginMethod(for: .kilo)?.contains("Auto top-up") == true)
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot maps kilo pass window from subscription state`() throws {
|
|
let json = """
|
|
[
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"creditBlocks": [
|
|
{
|
|
"id": "cb-1",
|
|
"effective_date": "2026-02-01T00:00:00Z",
|
|
"expiry_date": null,
|
|
"balance_mUsd": 19000000,
|
|
"amount_mUsd": 19000000,
|
|
"is_free": false
|
|
}
|
|
],
|
|
"totalBalance_mUsd": 19000000,
|
|
"autoTopUpEnabled": false
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"subscription": {
|
|
"tier": "tier_19",
|
|
"currentPeriodUsageUsd": 0,
|
|
"currentPeriodBaseCreditsUsd": 19.0,
|
|
"currentPeriodBonusCreditsUsd": 9.5,
|
|
"nextBillingAt": "2026-03-28T04:00:00.000Z"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"enabled": false,
|
|
"amountCents": 5000,
|
|
"paymentMethod": null
|
|
}
|
|
}
|
|
}
|
|
]
|
|
"""
|
|
|
|
let parsed = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8))
|
|
let snapshot = parsed.toUsageSnapshot()
|
|
|
|
#expect(snapshot.primary?.usedPercent == 0)
|
|
#expect(snapshot.secondary?.usedPercent == 0)
|
|
#expect(snapshot.secondary?.resetsAt != nil)
|
|
#expect(snapshot.secondary?.resetDescription == "$0.00 / $19.00 (+ $9.50 bonus)")
|
|
#expect(snapshot.loginMethod(for: .kilo) == "Starter · Auto top-up: off")
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot maps known tier names and defaults to kilo pass`() throws {
|
|
let proTierJSON = """
|
|
[
|
|
{ "result": { "data": { "creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": false } } },
|
|
{ "result": { "data": { "subscription": { "tier": "tier_49" } } } },
|
|
{ "result": { "data": { "enabled": false, "paymentMethod": null } } }
|
|
]
|
|
"""
|
|
let proTierSnapshot = try KiloUsageFetcher._parseSnapshotForTesting(Data(proTierJSON.utf8)).toUsageSnapshot()
|
|
#expect(proTierSnapshot.loginMethod(for: .kilo) == "Pro · Auto top-up: off")
|
|
|
|
let noTierJSON = """
|
|
[
|
|
{ "result": { "data": { "creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": false } } },
|
|
{ "result": { "data": { "subscription": {
|
|
"currentPeriodUsageUsd": 1.0,
|
|
"currentPeriodBaseCreditsUsd": 19.0
|
|
} } } },
|
|
{ "result": { "data": { "enabled": false, "paymentMethod": null } } }
|
|
]
|
|
"""
|
|
let noTierSnapshot = try KiloUsageFetcher._parseSnapshotForTesting(Data(noTierJSON.utf8)).toUsageSnapshot()
|
|
#expect(noTierSnapshot.loginMethod(for: .kilo) == "Kilo Pass · Auto top-up: off")
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot uses auto top up amount when enabled without payment method`() throws {
|
|
let json = """
|
|
[
|
|
{ "result": { "data": { "creditBlocks": [], "totalBalance_mUsd": 0, "autoTopUpEnabled": true } } },
|
|
{ "result": { "data": { "subscription": null } } },
|
|
{ "result": { "data": { "enabled": true, "amountCents": 5000, "paymentMethod": null } } }
|
|
]
|
|
"""
|
|
|
|
let snapshot = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8)).toUsageSnapshot()
|
|
#expect(snapshot.loginMethod(for: .kilo) == "Auto top-up: $50")
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot fallback pass fields use micro dollar scale`() throws {
|
|
let json = """
|
|
[
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"blocks": [
|
|
{
|
|
"usedCredits": 0,
|
|
"totalCredits": 19,
|
|
"remainingCredits": 19
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"planName": "Starter",
|
|
"amount_mUsd": 28500000,
|
|
"used_mUsd": 3500000,
|
|
"bonus_mUsd": 9500000,
|
|
"nextRenewalAt": "2026-03-28T04:00:00.000Z"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"enabled": false,
|
|
"paymentMethod": null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
"""
|
|
|
|
let snapshot = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8)).toUsageSnapshot()
|
|
#expect(snapshot.secondary?.resetDescription == "$3.50 / $19.00 (+ $9.50 bonus)")
|
|
#expect(snapshot.loginMethod(for: .kilo) == "Starter · Auto top-up: off")
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot treats empty and null business fields as no data success`() throws {
|
|
let json = """
|
|
[
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"blocks": []
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"plan": {
|
|
"name": null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"enabled": null,
|
|
"paymentMethod": null
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
"""
|
|
|
|
let parsed = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8))
|
|
let snapshot = parsed.toUsageSnapshot()
|
|
|
|
#expect(snapshot.primary == nil)
|
|
#expect(snapshot.identity?.providerID == .kilo)
|
|
#expect(snapshot.loginMethod(for: .kilo) == nil)
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot keeps sparse indexed object routing by procedure index`() throws {
|
|
let json = """
|
|
{
|
|
"0": {
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"creditsUsed": 10,
|
|
"creditsRemaining": 90
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"2": {
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"planName": "wrong-route",
|
|
"enabled": true,
|
|
"method": "visa"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
let parsed = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8))
|
|
let snapshot = parsed.toUsageSnapshot()
|
|
|
|
#expect(snapshot.primary?.usedPercent == 10)
|
|
#expect(snapshot.loginMethod(for: .kilo) == "Auto top-up: visa")
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot uses top level credits used fallback`() throws {
|
|
let json = """
|
|
[
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"creditsUsed": 40,
|
|
"creditsRemaining": 60
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
"""
|
|
|
|
let parsed = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8))
|
|
let snapshot = parsed.toUsageSnapshot()
|
|
|
|
#expect(snapshot.primary?.usedPercent == 40)
|
|
#expect(snapshot.primary?.resetDescription == "40/100 credits")
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot keeps zero total visible when activity exists`() throws {
|
|
let json = """
|
|
[
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"creditsUsed": 0,
|
|
"creditsRemaining": 0
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"planName": "Kilo Pass Pro"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"enabled": true,
|
|
"paymentMethod": "visa"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
"""
|
|
|
|
let parsed = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8))
|
|
let snapshot = parsed.toUsageSnapshot()
|
|
|
|
#expect(snapshot.primary?.remainingPercent == 0)
|
|
#expect(snapshot.primary?.usedPercent == 100)
|
|
#expect(snapshot.primary?.resetDescription == "0/0 credits")
|
|
#expect(snapshot.loginMethod(for: .kilo)?.contains("Auto top-up: visa") == true)
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot treats zero balance without credit blocks as visible zero total`() throws {
|
|
let json = """
|
|
[
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"creditBlocks": [],
|
|
"totalBalance_mUsd": 0,
|
|
"isFirstPurchase": true,
|
|
"autoTopUpEnabled": false
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"subscription": null
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"enabled": false,
|
|
"amountCents": 5000,
|
|
"paymentMethod": null
|
|
}
|
|
}
|
|
}
|
|
]
|
|
"""
|
|
|
|
let parsed = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8))
|
|
let snapshot = parsed.toUsageSnapshot()
|
|
|
|
#expect(snapshot.primary?.usedPercent == 100)
|
|
#expect(snapshot.primary?.remainingPercent == 0)
|
|
#expect(snapshot.primary?.resetDescription == "0/0 credits")
|
|
#expect(snapshot.loginMethod(for: .kilo) == "Auto top-up: off")
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot degrades optional auto top up TRPC error`() throws {
|
|
let json = """
|
|
[
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"creditsUsed": 10,
|
|
"creditsRemaining": 90
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"planName": "Starter"
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"error": {
|
|
"json": {
|
|
"message": "Internal server error",
|
|
"data": {
|
|
"code": "INTERNAL_SERVER_ERROR"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
"""
|
|
|
|
let parsed = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8))
|
|
let snapshot = parsed.toUsageSnapshot()
|
|
|
|
#expect(snapshot.primary?.usedPercent == 10)
|
|
#expect(snapshot.loginMethod(for: .kilo) == "Starter")
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot keeps required procedure TRPC error fatal`() {
|
|
let json = """
|
|
[
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": {
|
|
"creditsUsed": 10,
|
|
"creditsRemaining": 90
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"error": {
|
|
"json": {
|
|
"message": "Unauthorized",
|
|
"data": {
|
|
"code": "UNAUTHORIZED"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
"""
|
|
|
|
#expect {
|
|
_ = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8))
|
|
} throws: { error in
|
|
guard let kiloError = error as? KiloUsageError else { return false }
|
|
guard case .unauthorized = kiloError else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot maps unauthorized TRPC error`() {
|
|
let json = """
|
|
[
|
|
{
|
|
"error": {
|
|
"json": {
|
|
"message": "Unauthorized",
|
|
"data": {
|
|
"code": "UNAUTHORIZED"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
"""
|
|
|
|
#expect {
|
|
_ = try KiloUsageFetcher._parseSnapshotForTesting(Data(json.utf8))
|
|
} throws: { error in
|
|
guard let kiloError = error as? KiloUsageError else { return false }
|
|
guard case .unauthorized = kiloError else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `parse snapshot maps invalid JSON to parse error`() {
|
|
#expect {
|
|
_ = try KiloUsageFetcher._parseSnapshotForTesting(Data("not-json".utf8))
|
|
} throws: { error in
|
|
guard let kiloError = error as? KiloUsageError else { return false }
|
|
guard case .parseFailed = kiloError else { return false }
|
|
return true
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `status error mapping covers auth and server failures`() {
|
|
#expect(KiloUsageFetcher._statusErrorForTesting(401) == .unauthorized)
|
|
#expect(KiloUsageFetcher._statusErrorForTesting(403) == .unauthorized)
|
|
#expect(KiloUsageFetcher._statusErrorForTesting(404) == .endpointNotFound)
|
|
|
|
guard let serviceError = KiloUsageFetcher._statusErrorForTesting(503) else {
|
|
Issue.record("Expected service unavailable mapping")
|
|
return
|
|
}
|
|
guard case let .serviceUnavailable(statusCode) = serviceError else {
|
|
Issue.record("Expected service unavailable mapping")
|
|
return
|
|
}
|
|
#expect(statusCode == 503)
|
|
}
|
|
|
|
@Test
|
|
func `fetch usage without credentials fails fast`() async {
|
|
await #expect(throws: KiloUsageError.missingCredentials) {
|
|
_ = try await KiloUsageFetcher.fetchUsage(apiKey: " ", environment: [:])
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `descriptor fetch outcome without credentials returns actionable error`() async {
|
|
let descriptor = ProviderDescriptorRegistry.descriptor(for: .kilo)
|
|
let outcome = await descriptor.fetchOutcome(context: self.makeContext())
|
|
|
|
switch outcome.result {
|
|
case .success:
|
|
Issue.record("Expected missing credentials failure")
|
|
case let .failure(error):
|
|
#expect((error as? KiloUsageError) == .missingCredentials)
|
|
}
|
|
|
|
#expect(outcome.attempts.count == 1)
|
|
#expect(outcome.attempts.first?.strategyID == "kilo.api")
|
|
#expect(outcome.attempts.first?.wasAvailable == true)
|
|
}
|
|
|
|
@Test
|
|
func `descriptor API mode ignores CLI session fallback`() async throws {
|
|
let homeDirectory = try self.makeTemporaryHomeDirectory()
|
|
defer { try? FileManager.default.removeItem(at: homeDirectory) }
|
|
try self.writeKiloAuthFile(
|
|
homeDirectory: homeDirectory,
|
|
contents: #"{"kilo":{"access":"file-token"}}"#)
|
|
|
|
let descriptor = ProviderDescriptorRegistry.descriptor(for: .kilo)
|
|
let outcome = await descriptor.fetchOutcome(context: self.makeContext(
|
|
env: ["HOME": homeDirectory.path],
|
|
sourceMode: .api))
|
|
|
|
switch outcome.result {
|
|
case .success:
|
|
Issue.record("Expected missing API credentials failure")
|
|
case let .failure(error):
|
|
#expect((error as? KiloUsageError) == .missingCredentials)
|
|
}
|
|
|
|
#expect(outcome.attempts.count == 1)
|
|
#expect(outcome.attempts.first?.strategyID == "kilo.api")
|
|
}
|
|
|
|
@Test
|
|
func `descriptor CLI mode missing session returns actionable error`() async throws {
|
|
let homeDirectory = try self.makeTemporaryHomeDirectory()
|
|
defer { try? FileManager.default.removeItem(at: homeDirectory) }
|
|
let expectedPath = KiloSettingsReader.defaultAuthFileURL(homeDirectory: homeDirectory).path
|
|
|
|
let descriptor = ProviderDescriptorRegistry.descriptor(for: .kilo)
|
|
let outcome = await descriptor.fetchOutcome(context: self.makeContext(
|
|
env: ["HOME": homeDirectory.path],
|
|
sourceMode: .cli))
|
|
|
|
switch outcome.result {
|
|
case .success:
|
|
Issue.record("Expected missing CLI session failure")
|
|
case let .failure(error):
|
|
#expect((error as? KiloUsageError) == .cliSessionMissing(expectedPath))
|
|
}
|
|
|
|
#expect(outcome.attempts.count == 1)
|
|
#expect(outcome.attempts.first?.strategyID == "kilo.cli")
|
|
}
|
|
|
|
@Test
|
|
func `descriptor auto mode falls back from API to CLI`() async throws {
|
|
let homeDirectory = try self.makeTemporaryHomeDirectory()
|
|
defer { try? FileManager.default.removeItem(at: homeDirectory) }
|
|
let expectedPath = KiloSettingsReader.defaultAuthFileURL(homeDirectory: homeDirectory).path
|
|
|
|
let descriptor = ProviderDescriptorRegistry.descriptor(for: .kilo)
|
|
let outcome = await descriptor.fetchOutcome(context: self.makeContext(
|
|
env: ["HOME": homeDirectory.path],
|
|
sourceMode: .auto))
|
|
|
|
switch outcome.result {
|
|
case .success:
|
|
Issue.record("Expected missing CLI session failure after API fallback")
|
|
case let .failure(error):
|
|
#expect((error as? KiloUsageError) == .cliSessionMissing(expectedPath))
|
|
}
|
|
|
|
#expect(outcome.attempts.count == 2)
|
|
#expect(outcome.attempts.map(\.strategyID) == ["kilo.api", "kilo.cli"])
|
|
}
|
|
|
|
@Test
|
|
func `api strategy falls back on unauthorized only in auto mode`() {
|
|
let strategy = KiloAPIFetchStrategy()
|
|
#expect(strategy.shouldFallback(
|
|
on: KiloUsageError.unauthorized,
|
|
context: self.makeContext(sourceMode: .auto)))
|
|
#expect(!strategy.shouldFallback(
|
|
on: KiloUsageError.unauthorized,
|
|
context: self.makeContext(sourceMode: .api)))
|
|
}
|
|
|
|
@Test
|
|
func `api strategy falls back on missing credentials only in auto mode`() {
|
|
let strategy = KiloAPIFetchStrategy()
|
|
#expect(strategy.shouldFallback(
|
|
on: KiloUsageError.missingCredentials,
|
|
context: self.makeContext(sourceMode: .auto)))
|
|
#expect(!strategy.shouldFallback(
|
|
on: KiloUsageError.missingCredentials,
|
|
context: self.makeContext(sourceMode: .api)))
|
|
}
|
|
|
|
@Test
|
|
func `request builder adds org header for organization scope`() throws {
|
|
let baseURL = try #require(URL(string: "https://kilo.example/trpc"))
|
|
let request = try KiloUsageFetcher._buildRequestForTesting(
|
|
baseURL: baseURL,
|
|
apiKey: "test-token",
|
|
scope: .organization(id: "org_42", name: "Acme"))
|
|
#expect(request.value(forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") == "org_42")
|
|
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
|
|
}
|
|
|
|
@Test
|
|
func `request builder omits org header for personal scope`() throws {
|
|
let baseURL = try #require(URL(string: "https://kilo.example/trpc"))
|
|
let request = try KiloUsageFetcher._buildRequestForTesting(
|
|
baseURL: baseURL,
|
|
apiKey: "test-token",
|
|
scope: .personal)
|
|
#expect(request.value(forHTTPHeaderField: "X-KILOCODE-ORGANIZATIONID") == nil)
|
|
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
|
|
}
|
|
|
|
@Test
|
|
func `parseOrganizations decodes tRPC array shape`() throws {
|
|
let json = #"""
|
|
[
|
|
{
|
|
"result": {
|
|
"data": {
|
|
"json": [
|
|
{ "id": "org_1", "name": "Alpha", "role": "owner" },
|
|
{ "id": "org_2", "name": "Beta", "role": "member" }
|
|
]
|
|
}
|
|
}
|
|
}
|
|
]
|
|
"""#
|
|
let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8))
|
|
#expect(orgs.count == 2)
|
|
#expect(orgs[0].id == "org_1")
|
|
#expect(orgs[0].name == "Alpha")
|
|
#expect(orgs[0].role == "owner")
|
|
#expect(orgs[1].id == "org_2")
|
|
#expect(orgs[1].role == "member")
|
|
}
|
|
|
|
@Test
|
|
func `parseOrganizations decodes profile REST shape`() throws {
|
|
let json = #"""
|
|
{
|
|
"user": { "email": "test@example.com" },
|
|
"organizations": [
|
|
{ "id": "org_42", "name": "Gamma" }
|
|
]
|
|
}
|
|
"""#
|
|
let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8))
|
|
#expect(orgs.count == 1)
|
|
#expect(orgs[0].id == "org_42")
|
|
#expect(orgs[0].role == nil)
|
|
}
|
|
|
|
@Test
|
|
func `parseOrganizations returns empty for no orgs`() throws {
|
|
let json = #"""
|
|
{ "user": { "email": "x@y" }, "organizations": [] }
|
|
"""#
|
|
let orgs = try KiloUsageFetcher._parseOrganizationsForTesting(Data(json.utf8))
|
|
#expect(orgs.isEmpty)
|
|
}
|
|
|
|
private func makeTemporaryHomeDirectory() throws -> URL {
|
|
let directory = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
return directory
|
|
}
|
|
|
|
private func writeKiloAuthFile(homeDirectory: URL, contents: String) throws {
|
|
let fileURL = KiloSettingsReader.defaultAuthFileURL(homeDirectory: homeDirectory)
|
|
try FileManager.default.createDirectory(
|
|
at: fileURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
try contents.write(to: fileURL, atomically: true, encoding: .utf8)
|
|
}
|
|
}
|