From aa46b5362a2b74d50777a754c57b23bebac01327 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 30 Jan 2026 13:16:16 -0800 Subject: [PATCH 1/2] feat: add Poe provider for point balance display Add Poe as a new provider that displays current point balance via the POE_API_KEY environment variable. Features: - Fetches balance from https://api.poe.com/usage/current_balance - Displays formatted points (e.g., "295.9M pts", "1.5K pts") - Environment variable authentication only (no settings UI needed) Files added: - PoeProviderDescriptor.swift - Provider metadata and API strategy - PoeUsageFetcher.swift - API call and error handling - PoeModels.swift - Response types and formatting - PoeProviderImplementation.swift - UI availability hooks - ProviderIcon-poe.svg - Poe logo icon Co-Authored-By: Claude Opus 4.5 --- .../Poe/PoeProviderImplementation.swift | 18 +++++ .../ProviderImplementationRegistry.swift | 1 + .../CodexBar/Resources/ProviderIcon-poe.svg | 5 ++ Sources/CodexBar/UsageStore.swift | 7 ++ Sources/CodexBarCLI/TokenAccountCLI.swift | 2 +- .../CodexBarCore/Logging/LogCategories.swift | 1 + .../Providers/Poe/PoeModels.swift | 56 ++++++++++++++++ .../Providers/Poe/PoeProviderDescriptor.swift | 66 +++++++++++++++++++ .../Providers/Poe/PoeUsageFetcher.swift | 64 ++++++++++++++++++ .../Providers/ProviderDescriptor.swift | 1 + .../Providers/ProviderTokenResolver.swift | 10 +++ .../CodexBarCore/Providers/Providers.swift | 2 + .../Vendored/CostUsage/CostUsageScanner.swift | 2 + .../CodexBarWidgetProvider.swift | 1 + .../CodexBarWidget/CodexBarWidgetViews.swift | 3 + 15 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 Sources/CodexBar/Providers/Poe/PoeProviderImplementation.swift create mode 100644 Sources/CodexBar/Resources/ProviderIcon-poe.svg create mode 100644 Sources/CodexBarCore/Providers/Poe/PoeModels.swift create mode 100644 Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift create mode 100644 Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift diff --git a/Sources/CodexBar/Providers/Poe/PoeProviderImplementation.swift b/Sources/CodexBar/Providers/Poe/PoeProviderImplementation.swift new file mode 100644 index 00000000..6b0e5cae --- /dev/null +++ b/Sources/CodexBar/Providers/Poe/PoeProviderImplementation.swift @@ -0,0 +1,18 @@ +import CodexBarCore +import CodexBarMacroSupport +import Foundation + +@ProviderImplementationRegistration +struct PoeProviderImplementation: ProviderImplementation { + let id: UsageProvider = .poe + + @MainActor + func isAvailable(context: ProviderAvailabilityContext) -> Bool { + ProviderTokenResolver.poeToken(environment: context.environment) != nil + } + + @MainActor + func sourceMode(context _: ProviderSourceModeContext) -> ProviderSourceMode { + .api + } +} diff --git a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift index f6a9b2a3..3bbfec40 100644 --- a/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift +++ b/Sources/CodexBar/Providers/Shared/ProviderImplementationRegistry.swift @@ -30,6 +30,7 @@ enum ProviderImplementationRegistry { case .kimik2: KimiK2ProviderImplementation() case .amp: AmpProviderImplementation() case .synthetic: SyntheticProviderImplementation() + case .poe: PoeProviderImplementation() } } diff --git a/Sources/CodexBar/Resources/ProviderIcon-poe.svg b/Sources/CodexBar/Resources/ProviderIcon-poe.svg new file mode 100644 index 00000000..850030e1 --- /dev/null +++ b/Sources/CodexBar/Resources/ProviderIcon-poe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 69491056..fac42015 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1229,6 +1229,13 @@ extension UsageStore { let text = "JetBrains AI debug log not yet implemented" await MainActor.run { self.probeLogs[.jetbrains] = text } return text + case .poe: + let resolution = ProviderTokenResolver.poeResolution() + let hasAny = resolution != nil + let source = resolution?.source.rawValue ?? "none" + let text = "POE_API_KEY=\(hasAny ? "present" : "missing") source=\(source)" + await MainActor.run { self.probeLogs[.poe] = text } + return text } }.value } diff --git a/Sources/CodexBarCLI/TokenAccountCLI.swift b/Sources/CodexBarCLI/TokenAccountCLI.swift index a0a88371..c7d91884 100644 --- a/Sources/CodexBarCLI/TokenAccountCLI.swift +++ b/Sources/CodexBarCLI/TokenAccountCLI.swift @@ -147,7 +147,7 @@ struct TokenAccountCLIContext { return self.makeSnapshot( jetbrains: ProviderSettingsSnapshot.JetBrainsProviderSettings( ideBasePath: nil)) - case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic: + case .gemini, .antigravity, .copilot, .kiro, .vertexai, .kimik2, .synthetic, .poe: return nil } } diff --git a/Sources/CodexBarCore/Logging/LogCategories.swift b/Sources/CodexBarCore/Logging/LogCategories.swift index d3d31c8f..ef8c0f05 100644 --- a/Sources/CodexBarCore/Logging/LogCategories.swift +++ b/Sources/CodexBarCore/Logging/LogCategories.swift @@ -41,6 +41,7 @@ public enum LogCategories { public static let openAIWeb = "openai-web" public static let openAIWebview = "openai-webview" public static let opencodeUsage = "opencode-usage" + public static let poeUsage = "poe-usage" public static let providerDetection = "provider-detection" public static let providers = "providers" public static let sessionQuota = "sessionQuota" diff --git a/Sources/CodexBarCore/Providers/Poe/PoeModels.swift b/Sources/CodexBarCore/Providers/Poe/PoeModels.swift new file mode 100644 index 00000000..943ffe75 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Poe/PoeModels.swift @@ -0,0 +1,56 @@ +import Foundation + +public struct PoeBalanceResponse: Decodable, Sendable { + public let currentPointBalance: Int + + private enum CodingKeys: String, CodingKey { + case currentPointBalance = "current_point_balance" + } +} + +public struct PoeUsageSnapshot: Sendable { + public let pointBalance: Int + public let updatedAt: Date + + public init(pointBalance: Int, updatedAt: Date) { + self.pointBalance = pointBalance + self.updatedAt = updatedAt + } + + public func toUsageSnapshot() -> UsageSnapshot { + let formatted = Self.formatPoints(self.pointBalance) + + let rateWindow = RateWindow( + usedPercent: 0, + windowMinutes: nil, + resetsAt: nil, + resetDescription: formatted) + + let identity = ProviderIdentitySnapshot( + providerID: .poe, + accountEmail: nil, + accountOrganization: nil, + loginMethod: nil) + + return UsageSnapshot( + primary: rateWindow, + secondary: nil, + tertiary: nil, + providerCost: nil, + updatedAt: self.updatedAt, + identity: identity) + } + + static func formatPoints(_ points: Int) -> String { + switch points { + case 1_000_000_000...: + return String(format: "%.1fB pts", Double(points) / 1_000_000_000) + case 1_000_000...: + return String(format: "%.1fM pts", Double(points) / 1_000_000) + case 1_000...: + return String(format: "%.1fK pts", Double(points) / 1_000) + default: + return "\(points) pts" + } + } +} diff --git a/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift new file mode 100644 index 00000000..04cd67eb --- /dev/null +++ b/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift @@ -0,0 +1,66 @@ +import CodexBarMacroSupport +import Foundation + +@ProviderDescriptorRegistration +@ProviderDescriptorDefinition +public enum PoeProviderDescriptor { + static func makeDescriptor() -> ProviderDescriptor { + ProviderDescriptor( + id: .poe, + metadata: ProviderMetadata( + id: .poe, + displayName: "Poe", + sessionLabel: "Points", + weeklyLabel: "Points", + opusLabel: nil, + supportsOpus: false, + supportsCredits: false, + creditsHint: "", + toggleTitle: "Show Poe usage", + cliName: "poe", + defaultEnabled: false, + isPrimaryProvider: false, + usesAccountFallback: false, + browserCookieOrder: nil, + dashboardURL: "https://poe.com", + statusPageURL: nil), + branding: ProviderBranding( + iconStyle: .poe, + iconResourceName: "ProviderIcon-poe", + color: ProviderColor(red: 101 / 255, green: 78 / 255, blue: 163 / 255)), + tokenCost: ProviderTokenCostConfig( + supportsTokenCost: false, + noDataMessage: { "Poe cost summary is not available." }), + fetchPlan: ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { _ in [PoeAPIFetchStrategy()] })), + cli: ProviderCLIConfig( + name: "poe", + versionDetector: nil)) + } +} + +struct PoeAPIFetchStrategy: ProviderFetchStrategy { + let id: String = "poe.api" + let kind: ProviderFetchKind = .apiToken + + func isAvailable(_ context: ProviderFetchContext) async -> Bool { + Self.resolveToken(environment: context.env) != nil + } + + func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard let apiKey = Self.resolveToken(environment: context.env) else { + throw PoeUsageError.missingCredentials + } + let usage = try await PoeUsageFetcher.fetchUsage(apiKey: apiKey) + return self.makeResult( + usage: usage.toUsageSnapshot(), + sourceLabel: "api") + } + + func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { false } + + private static func resolveToken(environment: [String: String]) -> String? { + ProviderTokenResolver.poeToken(environment: environment) + } +} diff --git a/Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift new file mode 100644 index 00000000..b542e8d3 --- /dev/null +++ b/Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift @@ -0,0 +1,64 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public enum PoeUsageError: LocalizedError, Sendable { + case missingCredentials + case networkError(String) + case apiError(String) + case parseFailed(String) + + public var errorDescription: String? { + switch self { + case .missingCredentials: + "Missing POE_API_KEY environment variable." + case let .networkError(message): + "Poe network error: \(message)" + case let .apiError(message): + "Poe API error: \(message)" + case let .parseFailed(message): + "Failed to parse Poe response: \(message)" + } + } +} + +public struct PoeUsageFetcher: Sendable { + private static let log = CodexBarLog.logger(LogCategories.poeUsage) + private static let balanceURL = URL(string: "https://api.poe.com/usage/current_balance")! + + public static func fetchUsage(apiKey: String) async throws -> PoeUsageSnapshot { + guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw PoeUsageError.missingCredentials + } + + var request = URLRequest(url: self.balanceURL) + request.httpMethod = "GET" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw PoeUsageError.networkError("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let body = String(data: data, encoding: .utf8) ?? "HTTP \(httpResponse.statusCode)" + Self.log.error("Poe API returned \(httpResponse.statusCode): \(body)") + throw PoeUsageError.apiError(body) + } + + if let jsonString = String(data: data, encoding: .utf8) { + Self.log.debug("Poe API response: \(jsonString)") + } + + do { + let decoded = try JSONDecoder().decode(PoeBalanceResponse.self, from: data) + return PoeUsageSnapshot(pointBalance: decoded.currentPointBalance, updatedAt: Date()) + } catch { + Self.log.error("Poe JSON decoding error: \(error.localizedDescription)") + throw PoeUsageError.parseFailed(error.localizedDescription) + } + } +} diff --git a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift index 6aff8369..a424e7f7 100644 --- a/Sources/CodexBarCore/Providers/ProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/ProviderDescriptor.swift @@ -71,6 +71,7 @@ public enum ProviderDescriptorRegistry { .kimik2: KimiK2ProviderDescriptor.descriptor, .amp: AmpProviderDescriptor.descriptor, .synthetic: SyntheticProviderDescriptor.descriptor, + .poe: PoeProviderDescriptor.descriptor, ] private static let bootstrap: Void = { for provider in UsageProvider.allCases { diff --git a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift index 6b978775..26544bbf 100644 --- a/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift +++ b/Sources/CodexBarCore/Providers/ProviderTokenResolver.swift @@ -45,6 +45,10 @@ public enum ProviderTokenResolver { self.kimiK2Resolution(environment: environment)?.token } + public static func poeToken(environment: [String: String] = ProcessInfo.processInfo.environment) -> String? { + self.poeResolution(environment: environment)?.token + } + public static func zaiResolution( environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? { @@ -100,6 +104,12 @@ public enum ProviderTokenResolver { self.resolveEnv(KimiK2SettingsReader.apiKey(environment: environment)) } + public static func poeResolution( + environment: [String: String] = ProcessInfo.processInfo.environment) -> ProviderTokenResolution? + { + self.resolveEnv(self.cleaned(environment["POE_API_KEY"])) + } + private static func cleaned(_ raw: String?) -> String? { guard var value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil diff --git a/Sources/CodexBarCore/Providers/Providers.swift b/Sources/CodexBarCore/Providers/Providers.swift index a267fb95..53354b38 100644 --- a/Sources/CodexBarCore/Providers/Providers.swift +++ b/Sources/CodexBarCore/Providers/Providers.swift @@ -21,6 +21,7 @@ public enum UsageProvider: String, CaseIterable, Sendable, Codable { case kimik2 case amp case synthetic + case poe } // swiftformat:enable sortDeclarations @@ -44,6 +45,7 @@ public enum IconStyle: Sendable, CaseIterable { case jetbrains case amp case synthetic + case poe case combined } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index c50b106c..c7a83955 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -99,6 +99,8 @@ enum CostUsageScanner { return CostUsageDailyReport(data: [], summary: nil) case .synthetic: return CostUsageDailyReport(data: [], summary: nil) + case .poe: + return CostUsageDailyReport(data: [], summary: nil) } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift index 1634611e..c3cc9501 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetProvider.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetProvider.swift @@ -59,6 +59,7 @@ enum ProviderChoice: String, AppEnum { case .kimik2: return nil // Kimi K2 not yet supported in widgets case .amp: return nil // Amp not yet supported in widgets case .synthetic: return nil // Synthetic not yet supported in widgets + case .poe: return nil // Poe not yet supported in widgets } } } diff --git a/Sources/CodexBarWidget/CodexBarWidgetViews.swift b/Sources/CodexBarWidget/CodexBarWidgetViews.swift index ed39b450..44589cb8 100644 --- a/Sources/CodexBarWidget/CodexBarWidgetViews.swift +++ b/Sources/CodexBarWidget/CodexBarWidgetViews.swift @@ -275,6 +275,7 @@ private struct ProviderSwitchChip: View { case .kimik2: "Kimi K2" case .amp: "Amp" case .synthetic: "Synthetic" + case .poe: "Poe" } } } @@ -605,6 +606,8 @@ enum WidgetColors { Color(red: 220 / 255, green: 38 / 255, blue: 38 / 255) // Amp red case .synthetic: Color(red: 20 / 255, green: 20 / 255, blue: 20 / 255) // Synthetic charcoal + case .poe: + Color(red: 101 / 255, green: 78 / 255, blue: 163 / 255) // Poe purple } } } From f25b9ec6a71c6e0a5ad48e4bfd2a9ac17cee57d2 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 30 Jan 2026 14:01:35 -0800 Subject: [PATCH 2/2] test: add comprehensive tests for Poe provider - Add PoeModelsTests for formatPoints() validation - Add PoeUsageFetcherTests for JSON parsing - Add ProviderTokenResolver tests for POE_API_KEY - Fix SettingsStoreTests to include .poe in expected order - Fix ProviderIconResourcesTests to check for poe icon - Make formatPoints() public for testing - Add _parseBalanceForTesting() for response parsing tests - Add request timeout (30s) to prevent hanging - Fix weeklyLabel to empty string (no weekly window for Poe) Co-Authored-By: Claude Opus 4.5 --- .../Providers/Poe/PoeModels.swift | 2 +- .../Providers/Poe/PoeProviderDescriptor.swift | 2 +- .../Providers/Poe/PoeUsageFetcher.swift | 10 ++++ Tests/CodexBarTests/PoeModelsTests.swift | 49 ++++++++++++++++ .../CodexBarTests/PoeUsageFetcherTests.swift | 56 +++++++++++++++++++ .../ProviderIconResourcesTests.swift | 1 + .../ProviderTokenResolverTests.swift | 29 ++++++++++ Tests/CodexBarTests/SettingsStoreTests.swift | 1 + 8 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 Tests/CodexBarTests/PoeModelsTests.swift create mode 100644 Tests/CodexBarTests/PoeUsageFetcherTests.swift diff --git a/Sources/CodexBarCore/Providers/Poe/PoeModels.swift b/Sources/CodexBarCore/Providers/Poe/PoeModels.swift index 943ffe75..c1ddb651 100644 --- a/Sources/CodexBarCore/Providers/Poe/PoeModels.swift +++ b/Sources/CodexBarCore/Providers/Poe/PoeModels.swift @@ -41,7 +41,7 @@ public struct PoeUsageSnapshot: Sendable { identity: identity) } - static func formatPoints(_ points: Int) -> String { + public static func formatPoints(_ points: Int) -> String { switch points { case 1_000_000_000...: return String(format: "%.1fB pts", Double(points) / 1_000_000_000) diff --git a/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift index 04cd67eb..21215904 100644 --- a/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Poe/PoeProviderDescriptor.swift @@ -11,7 +11,7 @@ public enum PoeProviderDescriptor { id: .poe, displayName: "Poe", sessionLabel: "Points", - weeklyLabel: "Points", + weeklyLabel: "", opusLabel: nil, supportsOpus: false, supportsCredits: false, diff --git a/Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift index b542e8d3..036c73f5 100644 --- a/Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Poe/PoeUsageFetcher.swift @@ -34,6 +34,7 @@ public struct PoeUsageFetcher: Sendable { var request = URLRequest(url: self.balanceURL) request.httpMethod = "GET" + request.timeoutInterval = 30 request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") @@ -53,6 +54,15 @@ public struct PoeUsageFetcher: Sendable { Self.log.debug("Poe API response: \(jsonString)") } + return try Self.parseBalance(from: data) + } + + /// Internal method for testing response parsing. + public static func _parseBalanceForTesting(_ data: Data) throws -> PoeUsageSnapshot { + try self.parseBalance(from: data) + } + + private static func parseBalance(from data: Data) throws -> PoeUsageSnapshot { do { let decoded = try JSONDecoder().decode(PoeBalanceResponse.self, from: data) return PoeUsageSnapshot(pointBalance: decoded.currentPointBalance, updatedAt: Date()) diff --git a/Tests/CodexBarTests/PoeModelsTests.swift b/Tests/CodexBarTests/PoeModelsTests.swift new file mode 100644 index 00000000..a8459775 --- /dev/null +++ b/Tests/CodexBarTests/PoeModelsTests.swift @@ -0,0 +1,49 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite +struct PoeModelsTests { + @Test + func formatPointsRawNumbers() { + #expect(PoeUsageSnapshot.formatPoints(0) == "0 pts") + #expect(PoeUsageSnapshot.formatPoints(1) == "1 pts") + #expect(PoeUsageSnapshot.formatPoints(500) == "500 pts") + #expect(PoeUsageSnapshot.formatPoints(999) == "999 pts") + } + + @Test + func formatPointsThousands() { + #expect(PoeUsageSnapshot.formatPoints(1_000) == "1.0K pts") + #expect(PoeUsageSnapshot.formatPoints(1_500) == "1.5K pts") + #expect(PoeUsageSnapshot.formatPoints(10_000) == "10.0K pts") + #expect(PoeUsageSnapshot.formatPoints(999_999) == "1000.0K pts") + } + + @Test + func formatPointsMillions() { + #expect(PoeUsageSnapshot.formatPoints(1_000_000) == "1.0M pts") + #expect(PoeUsageSnapshot.formatPoints(1_500_000) == "1.5M pts") + #expect(PoeUsageSnapshot.formatPoints(295_932_027) == "295.9M pts") + #expect(PoeUsageSnapshot.formatPoints(999_999_999) == "1000.0M pts") + } + + @Test + func formatPointsBillions() { + #expect(PoeUsageSnapshot.formatPoints(1_000_000_000) == "1.0B pts") + #expect(PoeUsageSnapshot.formatPoints(1_500_000_000) == "1.5B pts") + #expect(PoeUsageSnapshot.formatPoints(10_000_000_000) == "10.0B pts") + } + + @Test + func toUsageSnapshotCreatesValidSnapshot() { + let snapshot = PoeUsageSnapshot(pointBalance: 295_932_027, updatedAt: Date()) + let usageSnapshot = snapshot.toUsageSnapshot() + + #expect(usageSnapshot.primary?.resetDescription == "295.9M pts") + #expect(usageSnapshot.primary?.usedPercent == 0) + #expect(usageSnapshot.identity?.providerID == .poe) + #expect(usageSnapshot.secondary == nil) + #expect(usageSnapshot.tertiary == nil) + } +} diff --git a/Tests/CodexBarTests/PoeUsageFetcherTests.swift b/Tests/CodexBarTests/PoeUsageFetcherTests.swift new file mode 100644 index 00000000..cab5d779 --- /dev/null +++ b/Tests/CodexBarTests/PoeUsageFetcherTests.swift @@ -0,0 +1,56 @@ +import CodexBarCore +import Foundation +import Testing + +@Suite +struct PoeUsageFetcherTests { + @Test + func parseBalanceSucceeds() throws { + let json = """ + {"current_point_balance": 295932027} + """ + let data = json.data(using: .utf8)! + let snapshot = try PoeUsageFetcher._parseBalanceForTesting(data) + #expect(snapshot.pointBalance == 295_932_027) + } + + @Test + func parseBalanceHandlesZero() throws { + let json = """ + {"current_point_balance": 0} + """ + let data = json.data(using: .utf8)! + let snapshot = try PoeUsageFetcher._parseBalanceForTesting(data) + #expect(snapshot.pointBalance == 0) + } + + @Test + func parseBalanceHandlesLargeValue() throws { + let json = """ + {"current_point_balance": 10000000000} + """ + let data = json.data(using: .utf8)! + let snapshot = try PoeUsageFetcher._parseBalanceForTesting(data) + #expect(snapshot.pointBalance == 10_000_000_000) + } + + @Test + func parseBalanceFailsOnInvalidJSON() throws { + let json = """ + {"invalid": "response"} + """ + let data = json.data(using: .utf8)! + #expect(throws: PoeUsageError.self) { + try PoeUsageFetcher._parseBalanceForTesting(data) + } + } + + @Test + func parseBalanceFailsOnMalformedJSON() throws { + let json = "not json at all" + let data = json.data(using: .utf8)! + #expect(throws: PoeUsageError.self) { + try PoeUsageFetcher._parseBalanceForTesting(data) + } + } +} diff --git a/Tests/CodexBarTests/ProviderIconResourcesTests.swift b/Tests/CodexBarTests/ProviderIconResourcesTests.swift index 7ecbe7b2..7da47c52 100644 --- a/Tests/CodexBarTests/ProviderIconResourcesTests.swift +++ b/Tests/CodexBarTests/ProviderIconResourcesTests.swift @@ -21,6 +21,7 @@ struct ProviderIconResourcesTests { "antigravity", "factory", "copilot", + "poe", ] for slug in slugs { let url = resources.appending(path: "ProviderIcon-\(slug).svg") diff --git a/Tests/CodexBarTests/ProviderTokenResolverTests.swift b/Tests/CodexBarTests/ProviderTokenResolverTests.swift index 004b63b1..ab88f6cb 100644 --- a/Tests/CodexBarTests/ProviderTokenResolverTests.swift +++ b/Tests/CodexBarTests/ProviderTokenResolverTests.swift @@ -17,4 +17,33 @@ struct ProviderTokenResolverTests { let resolution = ProviderTokenResolver.copilotResolution(environment: env) #expect(resolution?.token == "token") } + + @Test + func poeResolutionUsesEnvironmentToken() { + let env = ["POE_API_KEY": "sk-poe-token"] + let resolution = ProviderTokenResolver.poeResolution(environment: env) + #expect(resolution?.token == "sk-poe-token") + #expect(resolution?.source == .environment) + } + + @Test + func poeResolutionTrimsToken() { + let env = ["POE_API_KEY": " sk-poe-token "] + let resolution = ProviderTokenResolver.poeResolution(environment: env) + #expect(resolution?.token == "sk-poe-token") + } + + @Test + func poeResolutionReturnsNilForEmptyToken() { + let env = ["POE_API_KEY": " "] + let resolution = ProviderTokenResolver.poeResolution(environment: env) + #expect(resolution == nil) + } + + @Test + func poeResolutionReturnsNilForMissingKey() { + let env: [String: String] = [:] + let resolution = ProviderTokenResolver.poeResolution(environment: env) + #expect(resolution == nil) + } } diff --git a/Tests/CodexBarTests/SettingsStoreTests.swift b/Tests/CodexBarTests/SettingsStoreTests.swift index 108d31bd..da185308 100644 --- a/Tests/CodexBarTests/SettingsStoreTests.swift +++ b/Tests/CodexBarTests/SettingsStoreTests.swift @@ -364,6 +364,7 @@ struct SettingsStoreTests { .kimik2, .amp, .synthetic, + .poe, ]) // Move one provider; ensure it's persisted across instances.