Files
steipete--codexbar/Tests/CodexBarTests/CodexRateLimitResetCreditsTests.swift
2026-07-13 12:22:33 +08:00

254 lines
10 KiB
Swift

import Foundation
import Testing
@testable import CodexBarCore
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif
struct CodexRateLimitResetCreditsTests {
@Test
func `resolves URL from chat GPT config`() {
let config = "chatgpt_base_url = \"https://chatgpt.com/backend-api/\"\n"
let url = CodexOAuthUsageFetcher._resolveRateLimitResetCreditsURLForTesting(configContents: config)
#expect(url.absoluteString == "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits")
}
@Test
func `request scopes auth and account with bounded timeout`() async throws {
let transport = ProviderHTTPTransportStub { request in
#expect(request.url?.absoluteString == "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits")
#expect(request.httpMethod == "GET")
#expect(request.timeoutInterval == 4)
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
#expect(request.value(forHTTPHeaderField: "ChatGPT-Account-ID") == "account-123")
#expect(request.value(forHTTPHeaderField: "OpenAI-Beta") == "codex-1")
#expect(request.value(forHTTPHeaderField: "originator") == "Codex Desktop")
let url = try #require(request.url)
let response = try #require(HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil))
return (Data(#"{"credits":[],"available_count":0}"#.utf8), response)
}
let snapshot = try await CodexAuthenticatedHTTPTransport.$overrideForTesting.withValue(transport) {
try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits(
accessToken: "test-token",
accountId: "account-123",
env: ["CODEX_HOME": "/tmp/codexbar-reset-credit-request-test"])
}
#expect(snapshot.availableCount == 0)
#expect(await transport.requests().count == 1)
}
@Test
func `rejects negative available count`() async throws {
let transport = ProviderHTTPTransportStub { request in
let url = try #require(request.url)
let response = try #require(HTTPURLResponse(
url: url,
statusCode: 200,
httpVersion: nil,
headerFields: nil))
return (Data(#"{"credits":[],"available_count":-1}"#.utf8), response)
}
do {
_ = try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits(
accessToken: "test-token",
accountId: nil,
env: ["CODEX_HOME": "/tmp/codexbar-negative-reset-credit-test"],
session: transport)
Issue.record("Expected invalid response")
} catch CodexOAuthFetchError.invalidResponse {
// Expected.
} catch {
Issue.record("Unexpected error: \(error)")
}
}
@Test
func `decodes credits and skips stale available expiry`() throws {
let json = """
{
"credits": [
{
"id": "RateLimitResetCredit_expired_available",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-05-18T00:39:53Z",
"expires_at": "2026-06-17T00:39:53Z"
},
{
"id": "RateLimitResetCredit_later",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-18T00:39:53.731630Z",
"expires_at": "2026-07-18T00:39:53.731630Z",
"redeem_started_at": null,
"redeemed_at": null,
"profile_image_url": "https://example.com/codex.png",
"profile_user_id": "Codex Team",
"title": "One free rate limit reset",
"description": "Thanks for using Codex!"
},
{
"id": "RateLimitResetCredit_earlier",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-12T04:03:43.263391Z",
"expires_at": "2026-07-12T04:03:43.263391Z",
"redeem_started_at": null,
"redeemed_at": null,
"title": "One free rate limit reset",
"description": "Thanks for using Codex!"
},
{
"id": "RateLimitResetCredit_future_status",
"reset_type": "codex_rate_limits",
"status": "future_status",
"granted_at": "2026-06-12T04:03:43Z",
"expires_at": "2026-07-10T04:03:43Z",
"redeem_started_at": null,
"redeemed_at": null,
"title": "One free rate limit reset",
"description": "Thanks for using Codex!"
}
],
"available_count": 2
}
"""
let now = try #require(ISO8601DateFormatter().date(from: "2026-07-01T00:00:00Z"))
let snapshot = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting(
Data(json.utf8),
now: now)
#expect(snapshot.availableCount == 2)
#expect(snapshot.credits.count == 4)
#expect(snapshot.credits[0].resetType == "codex_rate_limits")
#expect(snapshot.credits[3].status == .unknown("future_status"))
#expect(snapshot.nextExpiringAvailableCredit?.id == CodexRateLimitResetCredit.stableID(
forProviderID: "RateLimitResetCredit_earlier"))
#expect(snapshot.credits.allSatisfy { !$0.id.contains("RateLimitResetCredit_") })
let usage = UsageSnapshot(
primary: nil,
secondary: nil,
codexResetCredits: snapshot,
updatedAt: now)
let encoded = try JSONEncoder().encode(usage)
let encodedText = try #require(String(data: encoded, encoding: .utf8))
#expect(!encodedText.contains("RateLimitResetCredit_earlier"))
#expect(!String(reflecting: usage).contains("RateLimitResetCredit_earlier"))
let roundTripped = try JSONDecoder().decode(UsageSnapshot.self, from: encoded)
#expect(roundTripped.codexResetCredits?.credits.map(\.id) == snapshot.credits.map(\.id))
}
@Test
func `available inventory keeps no-expiry credits and sorts deterministically`() {
let now = Date(timeIntervalSince1970: 1_788_134_400)
let tiedExpiry = now.addingTimeInterval(3600)
let snapshot = CodexRateLimitResetCreditsSnapshot(
credits: [
Self.credit(id: "nil-b", status: .available, expiresAt: nil),
Self.credit(id: "expired", status: .available, expiresAt: now),
Self.credit(id: "finite-b", status: .available, expiresAt: tiedExpiry),
Self.credit(id: "redeemed", status: .redeemed, expiresAt: now.addingTimeInterval(7200)),
Self.credit(id: "nil-a", status: .available, expiresAt: nil),
Self.credit(id: "finite-a", status: .available, expiresAt: tiedExpiry),
],
availableCount: 99,
updatedAt: now)
let inventory = snapshot.availableInventory(at: now)
#expect(inventory.count == 4)
let expectedFiniteIDs = ["finite-a", "finite-b"]
.map(CodexRateLimitResetCredit.stableID(forProviderID:))
.sorted()
let expectedNoExpiryIDs = ["nil-a", "nil-b"]
.map(CodexRateLimitResetCredit.stableID(forProviderID:))
.sorted()
#expect(inventory.credits.map(\.id) == expectedFiniteIDs + expectedNoExpiryIDs)
#expect(inventory.nextExpiringCredit?.id == expectedFiniteIDs.first)
}
@Test
func `provider IDs always hash even when shaped like persisted stable IDs`() throws {
let canonicalLookingRawID = "codex-reset-credit-v1-" + String(repeating: "a", count: 64)
let json = """
{
"credits": [{
"id": "\(canonicalLookingRawID)",
"reset_type": "codex_rate_limits",
"status": "available",
"granted_at": "2026-06-18T00:39:53Z",
"expires_at": null
}],
"available_count": 1
}
"""
let now = Date(timeIntervalSince1970: 1_788_134_400)
let decoded = try CodexOAuthUsageFetcher._decodeRateLimitResetCreditsForTesting(
Data(json.utf8),
now: now)
let decodedID = try #require(decoded.credits.first?.id)
let expectedID = CodexRateLimitResetCredit.stableID(forProviderID: canonicalLookingRawID)
#expect(decodedID == expectedID)
#expect(decodedID != canonicalLookingRawID)
let publicModel = Self.credit(id: canonicalLookingRawID, status: .available, expiresAt: nil)
#expect(publicModel.id == expectedID)
#expect(publicModel.id != canonicalLookingRawID)
let encoded = try JSONEncoder().encode(publicModel)
let roundTripped = try JSONDecoder().decode(CodexRateLimitResetCredit.self, from: encoded)
#expect(roundTripped.id == expectedID)
let ordinaryFirst = Self.credit(id: "ordinary-provider-id", status: .available, expiresAt: nil)
let ordinarySecond = Self.credit(id: "ordinary-provider-id", status: .available, expiresAt: nil)
#expect(ordinaryFirst.id == ordinarySecond.id)
#expect(ordinaryFirst.id != "ordinary-provider-id")
}
@Test
func `reset credit GET preserves transport cancellation`() async throws {
let transport = ProviderHTTPTransportStub { request in
#expect(request.httpMethod == "GET")
throw URLError(.cancelled)
}
await #expect(throws: CancellationError.self) {
_ = try await CodexOAuthUsageFetcher.fetchRateLimitResetCredits(
accessToken: "test-token",
accountId: "account-123",
env: ["CODEX_HOME": "/tmp/codexbar-reset-credit-cancellation-test"],
session: transport)
}
}
private static func credit(
id: String,
status: CodexRateLimitResetCreditStatus,
expiresAt: Date?) -> CodexRateLimitResetCredit
{
CodexRateLimitResetCredit(
id: id,
resetType: "codex_rate_limits",
status: status,
grantedAt: Date(timeIntervalSince1970: 1_788_000_000),
expiresAt: expiresAt,
redeemStartedAt: nil,
redeemedAt: nil,
title: nil,
description: nil)
}
}