300 lines
11 KiB
Swift
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": []
|
|
}
|
|
}
|
|
"""
|
|
}
|