Files
2026-07-13 12:22:33 +08:00

300 lines
11 KiB
Swift

import Foundation
import Testing
@testable import CodexBarCore
struct QoderUsageFetcherTests {
@Test
func `parses documented member quota summary`() throws {
let snapshot = try QoderUsageFetcher.parseUsage(data: Data(Self.quotaJSON.utf8), now: Self.now)
let usage = snapshot.toUsageSnapshot()
#expect(snapshot.usedCredits == 125)
#expect(snapshot.totalCredits == 500)
#expect(snapshot.remainingCredits == 375)
#expect(snapshot.usagePercentage == 25)
#expect(snapshot.unit == "credit")
#expect(snapshot.resetsAt == Self.resetDate)
#expect(usage.primary?.usedPercent == 25)
#expect(usage.primary?.resetsAt == Self.resetDate)
#expect(usage.primary?.windowMinutes == nil)
#expect(usage.primary?.resetDescription == "125 / 500 credits")
#expect(usage.identity?.providerID == .qoder)
#expect(usage.identity?.loginMethod == nil)
}
@Test
func `parses legacy snake case quota summary`() throws {
let snapshot = try QoderUsageFetcher.parseUsage(data: Data(Self.legacyQuotaJSON.utf8), now: Self.now)
#expect(snapshot.usedCredits == 125)
#expect(snapshot.totalCredits == 500)
#expect(snapshot.remainingCredits == 375)
#expect(snapshot.usagePercentage == 25)
#expect(snapshot.unit == "credit")
#expect(snapshot.resetsAt == Self.resetDate)
}
@Test
func `parses numeric reset timestamp`() throws {
let json = Self.quotaJSON.replacing(
"\"2024-09-01T00:00:00Z\"",
with: "1725148800000")
let snapshot = try QoderUsageFetcher.parseUsage(data: Data(json.utf8), now: Self.now)
#expect(snapshot.resetsAt == Self.resetDate)
}
@Test
func `folds shared quota into displayed totals`() throws {
let snapshot = try QoderUsageFetcher.parseUsage(
data: Data(Self.sharedQuotaJSON.utf8),
now: Self.now)
let usage = snapshot.toUsageSnapshot()
#expect(snapshot.usedCredits == 1700)
#expect(snapshot.totalCredits == 2500)
#expect(snapshot.remainingCredits == 800)
#expect(snapshot.usagePercentage == 68)
#expect(usage.primary?.usedPercent == 68)
#expect(usage.primary?.windowMinutes == nil)
#expect(usage.primary?.resetDescription == "1,700 / 2,500 credits")
#expect(usage.identity?.loginMethod == nil)
}
@Test
func `zero total zero usage without percentage is exhausted`() throws {
let snapshot = try QoderUsageFetcher.parseUsage(
data: Data(Self.zeroTotalQuotaJSON.utf8),
now: Self.now)
let usage = snapshot.toUsageSnapshot()
#expect(snapshot.usedCredits == 0)
#expect(snapshot.totalCredits == 0)
#expect(snapshot.remainingCredits == 0)
#expect(snapshot.usagePercentage == 100)
#expect(usage.primary?.usedPercent == 100)
#expect(usage.primary?.resetDescription == "0 / 0 credits")
}
@Test
func `negative quota values are invalid`() {
#expect(throws: QoderUsageError.parseFailed("quota values must be nonnegative")) {
try QoderUsageFetcher.parseUsage(
data: Data(Self.zeroTotalQuotaJSON.replacing("\"usedValue\": 0", with: "\"usedValue\": -1").utf8),
now: Self.now)
}
#expect(throws: QoderUsageError.parseFailed("quota values must be nonnegative")) {
try QoderUsageFetcher.parseUsage(
data: Data(Self.zeroTotalQuotaJSON.replacing("\"limitValue\": 0", with: "\"limitValue\": -1").utf8),
now: Self.now)
}
#expect(throws: QoderUsageError.parseFailed("quota values must be nonnegative")) {
try QoderUsageFetcher.parseUsage(
data: Data(Self.zeroTotalQuotaJSON.replacing("\"remainingValue\": 0", with: "\"remainingValue\": -1")
.utf8),
now: Self.now)
}
}
@Test
func `zero total with positive usage is invalid`() {
#expect(throws: QoderUsageError.parseFailed("zero total quota must have zero usage and remaining")) {
try QoderUsageFetcher.parseUsage(
data: Data(Self.zeroTotalQuotaJSON.replacing("\"usedValue\": 0", with: "\"usedValue\": 1").utf8),
now: Self.now)
}
#expect(throws: QoderUsageError.parseFailed("zero total quota must have zero usage and remaining")) {
try QoderUsageFetcher.parseUsage(
data: Data(Self.zeroTotalQuotaJSON.replacing("\"remainingValue\": 0", with: "\"remainingValue\": 1")
.utf8),
now: Self.now)
}
}
@Test
func `fetch sends documented Qoder headers`() async throws {
let transport = ProviderHTTPTransportStub { request in
#expect(request.httpMethod == "GET")
#expect(request.timeoutInterval == 42)
#expect(request.url?.absoluteString == "https://qoder.com/api/v2/me/usages/big_model_credits")
#expect(request.value(forHTTPHeaderField: "Cookie") == "sid=abc")
#expect(request.value(forHTTPHeaderField: "Accept") == "application/json, text/plain, */*")
#expect(request.value(forHTTPHeaderField: "Accept-Language") == "en-US,en;q=0.9")
#expect(request.value(forHTTPHeaderField: "Origin") == "https://qoder.com")
#expect(request.value(forHTTPHeaderField: "Referer") == "https://qoder.com/account/usage")
#expect(request.value(forHTTPHeaderField: "X-Requested-With") == "XMLHttpRequest")
#expect(request.value(forHTTPHeaderField: "Bx-V") == "2.5.35")
return (
Data(Self.quotaJSON.utf8),
HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil)!)
}
let snapshot = try await QoderUsageFetcher.fetchUsage(
cookieHeader: "sid=abc",
transport: transport,
now: Self.now,
timeout: 42)
#expect(snapshot.remainingCredits == 375)
}
@Test
func `fetch can target Qoder China site`() async throws {
let transport = ProviderHTTPTransportStub { request in
#expect(request.url?.absoluteString == "https://qoder.com.cn/api/v2/me/usages/big_model_credits")
#expect(request.value(forHTTPHeaderField: "Origin") == "https://qoder.com.cn")
#expect(request.value(forHTTPHeaderField: "Referer") == "https://qoder.com.cn/account/usage")
return (
Data(Self.quotaJSON.utf8),
HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil)!)
}
let snapshot = try await QoderUsageFetcher.fetchUsage(
cookieHeader: "sid=abc",
site: .china,
transport: transport,
now: Self.now)
#expect(snapshot.remainingCredits == 375)
}
@Test
func `unauthorized response maps to invalid credentials`() async {
let transport = ProviderHTTPTransportStub { request in
(
Data(),
HTTPURLResponse(
url: request.url!,
statusCode: 401,
httpVersion: nil,
headerFields: nil)!)
}
await #expect(throws: QoderUsageError.invalidCredentials) {
try await QoderUsageFetcher.fetchUsage(cookieHeader: "sid=expired", transport: transport)
}
}
@Test
func `invalid credentials message is domain neutral`() {
#expect(QoderUsageError.invalidCredentials
.localizedDescription == "Qoder session is invalid or expired. Please sign in to Qoder again.")
}
@Test
func `task cancellation propagates`() async {
let transport = ProviderHTTPTransportStub { _ in
throw CancellationError()
}
await #expect(throws: CancellationError.self) {
try await QoderUsageFetcher.fetchUsage(cookieHeader: "sid=cancelled", transport: transport)
}
}
@Test
func `URL cancellation propagates as task cancellation`() async {
let transport = ProviderHTTPTransportStub { _ in
throw URLError(.cancelled)
}
await #expect(throws: CancellationError.self) {
try await QoderUsageFetcher.fetchUsage(cookieHeader: "sid=cancelled", transport: transport)
}
}
private static let now = Date(timeIntervalSince1970: 1_719_206_400)
private static let resetDate = Date(timeIntervalSince1970: 1_725_148_800)
/// Fixture shape from steipete/CodexBar#1590 (camelCase browser response).
private static let quotaJSON = """
{
"userId": "redacted",
"quotaKey": "big_model_credits",
"nextResetAt": "2024-09-01T00:00:00Z",
"status": "active",
"totalQuota": {
"quotaSummary": {
"usedValue": 125,
"limitValue": 500,
"remainingValue": 375,
"usagePercentage": 25,
"unit": "credit"
},
"quotaDetail": []
}
}
"""
/// Fixture shape from steipete/CodexBar#1590 (snake_case browser response).
private static let legacyQuotaJSON = """
{
"user_id": "redacted",
"quota_key": "big_model_credits",
"next_reset_at": "2024-09-01T00:00:00Z",
"status": "active",
"total_quota": {
"quota_summary": {
"used_value": 125,
"limit_value": 500,
"remaining_value": 375,
"usage_percentage": 25,
"unit": "credit"
},
"quota_detail": []
}
}
"""
/// Team shared add-on credits are separate from totalQuota (plan + resource pack).
private static let sharedQuotaJSON = """
{
"userId": "redacted",
"quotaKey": "big_model_credits",
"status": "active",
"totalQuota": {
"quotaSummary": {
"usedValue": 1500,
"limitValue": 1500,
"remainingValue": 0,
"usagePercentage": 100,
"unit": "credit"
}
},
"sharedQuota": {
"quotaSummary": {
"usedValue": 200,
"limitValue": 1000,
"remainingValue": 800,
"usagePercentage": 20,
"unit": "credit"
}
}
}
"""
private static let zeroTotalQuotaJSON = """
{
"userId": "redacted",
"quotaKey": "big_model_credits",
"totalQuota": {
"quotaSummary": {
"usedValue": 0,
"limitValue": 0,
"remainingValue": 0,
"unit": "credit"
},
"quotaDetail": []
}
}
"""
}