462 lines
17 KiB
Swift
462 lines
17 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import CodexBarCore
|
|
|
|
struct FactorySettingsReaderTests {
|
|
@Test
|
|
func `reads FACTORY_API_KEY from environment`() {
|
|
let key = FactorySettingsReader.apiKey(
|
|
environment: [FactorySettingsReader.apiTokenKey: " fk-env-key "])
|
|
#expect(key == "fk-env-key")
|
|
}
|
|
|
|
@Test
|
|
func `strips quotes from environment API key`() {
|
|
let key = FactorySettingsReader.apiKey(
|
|
environment: [FactorySettingsReader.apiTokenKey: "\"fk-quoted\""])
|
|
#expect(key == "fk-quoted")
|
|
}
|
|
|
|
@Test
|
|
func `falls back to factory dot env file`() throws {
|
|
let home = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-factory-home-\(UUID().uuidString)", isDirectory: true)
|
|
let factoryDir = home.appendingPathComponent(".factory", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: factoryDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: home) }
|
|
|
|
let envFile = factoryDir.appendingPathComponent(".env")
|
|
try "export FACTORY_API_KEY=fk-from-dotenv\n".write(to: envFile, atomically: true, encoding: .utf8)
|
|
|
|
let key = FactorySettingsReader.apiKey(
|
|
environment: ["HOME": home.path])
|
|
#expect(key == "fk-from-dotenv")
|
|
}
|
|
|
|
@Test
|
|
func `environment wins over factory dot env`() throws {
|
|
let home = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("codexbar-factory-home-\(UUID().uuidString)", isDirectory: true)
|
|
let factoryDir = home.appendingPathComponent(".factory", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: factoryDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: home) }
|
|
|
|
let envFile = factoryDir.appendingPathComponent(".env")
|
|
try "FACTORY_API_KEY=fk-from-dotenv\n".write(to: envFile, atomically: true, encoding: .utf8)
|
|
|
|
let key = FactorySettingsReader.apiKey(
|
|
environment: [
|
|
FactorySettingsReader.apiTokenKey: "fk-env",
|
|
"HOME": home.path,
|
|
])
|
|
#expect(key == "fk-env")
|
|
}
|
|
|
|
@Test
|
|
func `skips dotenv when HOME is absent`() {
|
|
let key = FactorySettingsReader.apiKey(environment: [:])
|
|
#expect(key == nil)
|
|
}
|
|
|
|
@Test
|
|
func `parses factory dotenv variants`() {
|
|
#expect(
|
|
FactorySettingsReader.parseFactoryAPIKey(fromDotEnv: "FACTORY_API_KEY=fk-plain") == "fk-plain")
|
|
#expect(
|
|
FactorySettingsReader.parseFactoryAPIKey(fromDotEnv: "export FACTORY_API_KEY='fk-single'")
|
|
== "fk-single")
|
|
#expect(
|
|
FactorySettingsReader.parseFactoryAPIKey(fromDotEnv: "# comment\nFACTORY_API_KEY=\"fk-double\"")
|
|
== "fk-double")
|
|
#expect(FactorySettingsReader.parseFactoryAPIKey(fromDotEnv: "OTHER=1\n") == nil)
|
|
}
|
|
}
|
|
|
|
struct FactoryAPIFetchStrategyTests {
|
|
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
|
|
{
|
|
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(cacheTTL: 0))
|
|
}
|
|
|
|
@Test
|
|
func `descriptor prefers api then web in auto mode`() async {
|
|
let strategies = await FactoryProviderDescriptor.descriptor.fetchPlan.pipeline
|
|
.resolveStrategies(self.makeContext(sourceMode: .auto))
|
|
#expect(strategies.map(\.id) == ["factory.api", "factory.web"])
|
|
}
|
|
|
|
@Test
|
|
func `legacy cli source aliases auto strategies`() async {
|
|
let modes = FactoryProviderDescriptor.descriptor.fetchPlan.sourceModes
|
|
#expect(modes.contains(.cli))
|
|
#expect(modes.contains(.api))
|
|
#expect(modes.contains(.web))
|
|
|
|
let cli = await FactoryProviderDescriptor.descriptor.fetchPlan.pipeline
|
|
.resolveStrategies(self.makeContext(sourceMode: .cli))
|
|
let auto = await FactoryProviderDescriptor.descriptor.fetchPlan.pipeline
|
|
.resolveStrategies(self.makeContext(sourceMode: .auto))
|
|
#expect(cli.map(\.id) == auto.map(\.id))
|
|
#expect(cli.map(\.id) == ["factory.api", "factory.web"])
|
|
}
|
|
|
|
@Test
|
|
func `descriptor isolates api and web source modes`() async {
|
|
let api = await FactoryProviderDescriptor.descriptor.fetchPlan.pipeline
|
|
.resolveStrategies(self.makeContext(sourceMode: .api))
|
|
let web = await FactoryProviderDescriptor.descriptor.fetchPlan.pipeline
|
|
.resolveStrategies(self.makeContext(sourceMode: .web))
|
|
#expect(api.map(\.id) == ["factory.api"])
|
|
#expect(web.map(\.id) == ["factory.web"])
|
|
}
|
|
|
|
@Test
|
|
func `api strategy available in api mode without key`() async {
|
|
let strategy = FactoryAPIFetchStrategy()
|
|
#expect(await strategy.isAvailable(self.makeContext(sourceMode: .api)))
|
|
}
|
|
|
|
@Test
|
|
func `api strategy skipped in auto mode without key`() async {
|
|
let strategy = FactoryAPIFetchStrategy()
|
|
#expect(await !strategy.isAvailable(self.makeContext(sourceMode: .auto)))
|
|
}
|
|
|
|
@Test
|
|
func `api strategy available in auto mode when key present`() async {
|
|
let strategy = FactoryAPIFetchStrategy()
|
|
let context = self.makeContext(
|
|
env: [FactorySettingsReader.apiTokenKey: "fk-test"],
|
|
sourceMode: .auto)
|
|
#expect(await strategy.isAvailable(context))
|
|
}
|
|
|
|
@Test
|
|
func `api strategy falls back in auto and cli but not explicit api mode`() {
|
|
let strategy = FactoryAPIFetchStrategy()
|
|
#expect(strategy.shouldFallback(
|
|
on: FactoryStatusProbeError.unauthorizedAPIKey,
|
|
context: self.makeContext(sourceMode: .auto)))
|
|
#expect(strategy.shouldFallback(
|
|
on: FactoryStatusProbeError.unauthorizedAPIKey,
|
|
context: self.makeContext(sourceMode: .cli)))
|
|
#expect(!strategy.shouldFallback(
|
|
on: FactoryStatusProbeError.unauthorizedAPIKey,
|
|
context: self.makeContext(sourceMode: .api)))
|
|
}
|
|
|
|
@Test
|
|
func `api strategy surfaces missing key in api mode`() async {
|
|
let strategy = FactoryAPIFetchStrategy()
|
|
do {
|
|
_ = try await strategy.fetch(self.makeContext(env: [:], sourceMode: .api))
|
|
Issue.record("Expected missingAPIKey")
|
|
} catch let error as FactoryStatusProbeError {
|
|
#expect(error == .missingAPIKey)
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FactoryAPIKeyProbeFetchTests {
|
|
@Test
|
|
func `fetch with api key uses bearer billing limits path`() async throws {
|
|
let transport = FactoryAPIKeyStubTransport()
|
|
transport.handler = { request in
|
|
guard let url = request.url else { throw URLError(.badURL) }
|
|
if url.host == "api.factory.ai", url.path == "/api/app/auth/me" {
|
|
let body = """
|
|
{
|
|
"organization": {
|
|
"id": "org_1",
|
|
"name": "Acme",
|
|
"subscription": {
|
|
"factoryTier": "team",
|
|
"orbSubscription": {
|
|
"plan": { "name": "Team", "id": "plan_1" },
|
|
"status": "active"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
return Self.makeResponse(url: url, body: body)
|
|
}
|
|
if url.host == "api.factory.ai", url.path == "/api/billing/limits" {
|
|
let body = """
|
|
{
|
|
"usesTokenRateLimitsBilling": true,
|
|
"limits": {
|
|
"standard": {
|
|
"fiveHour": { "usedPercent": 12, "secondsRemaining": 3600 },
|
|
"weekly": { "usedPercent": 34, "secondsRemaining": 86400 },
|
|
"monthly": { "usedPercent": 56, "secondsRemaining": 604800 }
|
|
}
|
|
},
|
|
"extraUsageBalanceCents": 0,
|
|
"extraUsageAllowed": false,
|
|
"tokenRateLimitsRolloutEligible": true
|
|
}
|
|
"""
|
|
return Self.makeResponse(url: url, body: body)
|
|
}
|
|
return Self.makeResponse(url: url, body: "{}", statusCode: 404)
|
|
}
|
|
|
|
let probe = FactoryStatusProbe(
|
|
timeout: 1.0,
|
|
browserDetection: BrowserDetection(
|
|
homeDirectory: "/tmp/codexbar-empty-browser-home",
|
|
cacheTTL: 0,
|
|
fileExists: { _ in false },
|
|
directoryContents: { _ in nil }),
|
|
transport: transport)
|
|
|
|
let snapshot = try await probe.fetch(apiKey: "fk-test-key")
|
|
let usage = snapshot.toUsageSnapshot()
|
|
#expect(usage.primary?.usedPercent == 12)
|
|
#expect(usage.secondary?.usedPercent == 34)
|
|
#expect(usage.tertiary?.usedPercent == 56)
|
|
#expect(transport.requests.contains { request in
|
|
request.url?.path == "/api/billing/limits"
|
|
&& request.value(forHTTPHeaderField: "Authorization") == "Bearer fk-test-key"
|
|
})
|
|
}
|
|
|
|
@Test
|
|
func `fetch with api key maps 401 to notLoggedIn for strategy remapping`() async {
|
|
let transport = FactoryAPIKeyStubTransport()
|
|
transport.handler = { request in
|
|
guard let url = request.url else { throw URLError(.badURL) }
|
|
if url.path == "/api/app/auth/me" {
|
|
return Self.makeResponse(url: url, body: "{}", statusCode: 401)
|
|
}
|
|
return Self.makeResponse(url: url, body: "{}", statusCode: 404)
|
|
}
|
|
|
|
let probe = FactoryStatusProbe(
|
|
timeout: 1.0,
|
|
browserDetection: BrowserDetection(
|
|
homeDirectory: "/tmp/codexbar-empty-browser-home",
|
|
cacheTTL: 0,
|
|
fileExists: { _ in false },
|
|
directoryContents: { _ in nil }),
|
|
transport: transport)
|
|
|
|
do {
|
|
_ = try await probe.fetch(apiKey: "fk-bad")
|
|
Issue.record("Expected notLoggedIn")
|
|
} catch let error as FactoryStatusProbeError {
|
|
#expect(error == .notLoggedIn)
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `preserves api host 401 over app host 404 for unauthorized mapping`() async {
|
|
let transport = FactoryAPIKeyStubTransport()
|
|
transport.handler = { request in
|
|
guard let url = request.url else { throw URLError(.badURL) }
|
|
if url.host == "api.factory.ai", url.path == "/api/app/auth/me" {
|
|
return Self.makeResponse(url: url, body: "{}", statusCode: 401)
|
|
}
|
|
if url.host == "app.factory.ai", url.path == "/api/app/auth/me" {
|
|
return Self.makeResponse(url: url, body: "{}", statusCode: 404)
|
|
}
|
|
return Self.makeResponse(url: url, body: "{}", statusCode: 404)
|
|
}
|
|
|
|
let probe = FactoryStatusProbe(
|
|
timeout: 1.0,
|
|
browserDetection: BrowserDetection(
|
|
homeDirectory: "/tmp/codexbar-empty-browser-home",
|
|
cacheTTL: 0,
|
|
fileExists: { _ in false },
|
|
directoryContents: { _ in nil }),
|
|
transport: transport)
|
|
|
|
do {
|
|
_ = try await probe.fetch(apiKey: "fk-bad")
|
|
Issue.record("Expected notLoggedIn")
|
|
} catch let error as FactoryStatusProbeError {
|
|
#expect(error == .notLoggedIn)
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `preserves api host 403 over app host 404 for unauthorized mapping`() async {
|
|
let transport = FactoryAPIKeyStubTransport()
|
|
transport.handler = { request in
|
|
guard let url = request.url else { throw URLError(.badURL) }
|
|
if url.host == "api.factory.ai", url.path == "/api/app/auth/me" {
|
|
return Self.makeResponse(url: url, body: #"{"detail":"forbidden"}"#, statusCode: 403)
|
|
}
|
|
if url.host == "app.factory.ai", url.path == "/api/app/auth/me" {
|
|
return Self.makeResponse(url: url, body: "{}", statusCode: 404)
|
|
}
|
|
return Self.makeResponse(url: url, body: "{}", statusCode: 404)
|
|
}
|
|
|
|
let probe = FactoryStatusProbe(
|
|
timeout: 1.0,
|
|
browserDetection: BrowserDetection(
|
|
homeDirectory: "/tmp/codexbar-empty-browser-home",
|
|
cacheTTL: 0,
|
|
fileExists: { _ in false },
|
|
directoryContents: { _ in nil }),
|
|
transport: transport)
|
|
|
|
do {
|
|
_ = try await probe.fetch(apiKey: "fk-bad")
|
|
Issue.record("Expected networkError HTTP 403")
|
|
} catch let error as FactoryStatusProbeError {
|
|
switch error {
|
|
case let .networkError(message):
|
|
#expect(message.contains("HTTP 403"))
|
|
default:
|
|
Issue.record("Expected networkError, got \(error)")
|
|
}
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
}
|
|
|
|
@Test
|
|
func `api strategy falls back on recoverable api failures in auto mode`() {
|
|
let strategy = FactoryAPIFetchStrategy()
|
|
let autoContext = ProviderFetchContext(
|
|
runtime: .cli,
|
|
sourceMode: .auto,
|
|
includeCredits: false,
|
|
webTimeout: 1,
|
|
webDebugDumpHTML: false,
|
|
verbose: false,
|
|
env: [:],
|
|
settings: nil,
|
|
fetcher: UsageFetcher(environment: [:]),
|
|
claudeFetcher: StubClaudeFetcher(),
|
|
browserDetection: BrowserDetection(cacheTTL: 0))
|
|
let apiContext = ProviderFetchContext(
|
|
runtime: .cli,
|
|
sourceMode: .api,
|
|
includeCredits: false,
|
|
webTimeout: 1,
|
|
webDebugDumpHTML: false,
|
|
verbose: false,
|
|
env: [:],
|
|
settings: nil,
|
|
fetcher: UsageFetcher(environment: [:]),
|
|
claudeFetcher: StubClaudeFetcher(),
|
|
browserDetection: BrowserDetection(cacheTTL: 0))
|
|
|
|
#expect(strategy.shouldFallback(
|
|
on: FactoryStatusProbeError.networkError("HTTP 500"),
|
|
context: autoContext))
|
|
#expect(strategy.shouldFallback(
|
|
on: FactoryStatusProbeError.parseFailed("bad json"),
|
|
context: autoContext))
|
|
#expect(strategy.shouldFallback(
|
|
on: FactoryStatusProbeError.unauthorizedAPIKey,
|
|
context: autoContext))
|
|
#expect(strategy.shouldFallback(
|
|
on: URLError(.timedOut),
|
|
context: autoContext))
|
|
#expect(!strategy.shouldFallback(
|
|
on: CancellationError(),
|
|
context: autoContext))
|
|
#expect(!strategy.shouldFallback(
|
|
on: FactoryStatusProbeError.networkError("HTTP 500"),
|
|
context: apiContext))
|
|
#expect(!strategy.shouldFallback(
|
|
on: FactoryStatusProbeError.parseFailed("bad json"),
|
|
context: apiContext))
|
|
}
|
|
|
|
@Test
|
|
func `empty api key throws missingAPIKey`() async {
|
|
let probe = FactoryStatusProbe(
|
|
browserDetection: BrowserDetection(cacheTTL: 0),
|
|
transport: FactoryAPIKeyStubTransport())
|
|
do {
|
|
_ = try await probe.fetch(apiKey: " ")
|
|
Issue.record("Expected missingAPIKey")
|
|
} catch let error as FactoryStatusProbeError {
|
|
#expect(error == .missingAPIKey)
|
|
} catch {
|
|
Issue.record("Unexpected error: \(error)")
|
|
}
|
|
}
|
|
|
|
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 static func makeResponse(
|
|
url: URL,
|
|
body: String,
|
|
statusCode: Int = 200) -> (HTTPURLResponse, Data)
|
|
{
|
|
let response = HTTPURLResponse(
|
|
url: url,
|
|
statusCode: statusCode,
|
|
httpVersion: "HTTP/1.1",
|
|
headerFields: ["Content-Type": "application/json"])!
|
|
return (response, Data(body.utf8))
|
|
}
|
|
}
|
|
|
|
private final class FactoryAPIKeyStubTransport: ProviderHTTPTransport, @unchecked Sendable {
|
|
var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
|
|
var requests: [URLRequest] = []
|
|
|
|
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
|
self.requests.append(request)
|
|
guard let handler else {
|
|
throw URLError(.badServerResponse)
|
|
}
|
|
let (response, data) = try handler(request)
|
|
return (data, response)
|
|
}
|
|
}
|