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

296 lines
12 KiB
Swift

import Foundation
import Testing
@testable import CodexBarCore
struct T3ChatUsageFetcherTests {
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 let now = Date(timeIntervalSince1970: 1_778_000_000)
// 2026-05-21T12:23:36Z, the usage-window reset that must not drive overage reset display.
private static let billingNextResetMilliseconds = 1_779_366_216_920
// 2026-06-06T16:23:29Z, the subscription period end used for overage reset display.
private static let subscriptionPeriodEndSeconds = 1_780_763_009
private static let subscriptionPeriodEndMilliseconds = Self.subscriptionPeriodEndSeconds * 1000
private static let sampleResponse = [
#"{"json":{"0":[[0],[null,0,0]]}}"#,
#"{"json":[0,0,[[{"result":0}],["result",0,1]]]}"#,
#"{"json":[1,0,[[{"data":0}],["data",0,2]]]}"#,
#"{"json":[2,0,[[{"subTier":"pro","subscription":{"# +
#""productId":"pro","productName":"pro","status":"active","# +
#""currentPeriodStart":1778084609000,"currentPeriodEnd":1780763009000,"# +
#""canceledAt":null,"trialEndsAt":null},"lifetimeBalance":0,"usageBand":"max","# +
#""billingNextResetAt":1779366216920,"usageFourHourPercentage":12.5,"# +
#""usageMonthPercentage":34.25,"usageFourHourNextResetAt":1779366216920,"# +
#""usagePeriodPercentage":44,"usageWindowNextResetAt":1779366216920}]]]}"#,
].joined(separator: "\n")
@Test
func `parses customer data from json lines response`() throws {
let snapshot = try T3ChatUsageParser.parseJSONLines(Self.sampleResponse, now: Self.now)
#expect(snapshot.customerData.subTier == "pro")
#expect(snapshot.customerData.usageBand == "max")
#expect(snapshot.customerData.usageFourHourPercentage == 12.5)
#expect(snapshot.customerData.usageMonthPercentage == 34.25)
#expect(snapshot.customerData.subscription?.status == "active")
}
@Test
func `maps customer data to base and overage windows`() throws {
let usage = try T3ChatUsageParser.parseJSONLines(Self.sampleResponse, now: Self.now)
.toUsageSnapshot()
#expect(usage.primary?.usedPercent == 12.5)
#expect(usage.primary?.windowMinutes == 240)
#expect(usage.primary?.resetDescription == "Base - max")
#expect(usage.secondary?.usedPercent == 34.25)
#expect(usage.secondary?.resetDescription == "Overage")
#expect(usage.secondary?.resetsAt.map { Int($0.timeIntervalSince1970) } == Self.subscriptionPeriodEndSeconds)
#expect(usage.identity?.providerID == .t3chat)
#expect(usage.identity?.loginMethod == "Pro")
}
@Test
func `falls back to usage period percentage when month percentage is absent`() throws {
let response = """
{"json":[2,0,[[{"subTier":"free","usageFourHourPercentage":5,"usagePeriodPercentage":65}]]]}
"""
let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now)
.toUsageSnapshot()
#expect(usage.primary?.usedPercent == 5)
#expect(usage.secondary?.usedPercent == 65)
}
@Test
func `overage reset ignores billing next reset`() throws {
let response = Self.customerDataResponse(
#"{"usageMonthPercentage":20,"billingNextResetAt":\#(Self.billingNextResetMilliseconds)}"#)
let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now)
.toUsageSnapshot()
#expect(usage.secondary?.usedPercent == 20)
#expect(usage.secondary?.resetsAt == nil)
}
@Test
func `overage reset uses subscription current period end`() throws {
let currentPeriodEnd = Self.subscriptionPeriodEndMilliseconds
let response = Self.customerDataResponse(
#"{"usageMonthPercentage":20,"subscription":{"currentPeriodEnd":\#(currentPeriodEnd)}}"#)
let usage = try T3ChatUsageParser.parseJSONLines(response, now: Self.now)
.toUsageSnapshot()
#expect(usage.secondary?.usedPercent == 20)
#expect(usage.secondary?.resetsAt.map { Int($0.timeIntervalSince1970) } == Self.subscriptionPeriodEndSeconds)
}
@Test
func `fetch sends trpc headers and cookie`() async throws {
let stub = ProviderHTTPTransportStub { request in
#expect(request.url?.host == "t3.chat")
#expect(request.url?.path == "/api/trpc/getCustomerData")
#expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc")
#expect(request.value(forHTTPHeaderField: "trpc-accept") == "application/jsonl")
#expect(request.value(forHTTPHeaderField: "x-trpc-source") == "web-client")
#expect(request.value(forHTTPHeaderField: "Sec-Fetch-Site") == "same-origin")
#expect(request.url?.query?.contains("batch=1") == true)
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil)!
return (Data(Self.sampleResponse.utf8), response)
}
let snapshot = try await T3ChatUsageFetcher.fetchCustomerData(
cookieHeader: "session=abc",
now: Self.now,
transport: stub)
#expect(snapshot.customerData.planName == "Pro")
}
@Test
func `full curl capture forwards browser fingerprint headers`() async throws {
let curl = """
curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\
-H 'User-Agent: Mozilla/5.0 Firefox/151.0' \\
--header "Referer: https://t3.chat/settings/customization" \\
-H 'trpc-accept: application/jsonl' \\
-H 'x-trpc-source: web-client' \\
-H 'x-trpc-batch: true' \\
-H 'X-Deployment-Id: dpl_test' \\
-H 'x-client-context: eyJjbGllbnQiOnsidmVyc2lvbiI6IjEuMTIuNCJ9fQ==' \\
-H 'Cookie: session=abc'
"""
let stub = ProviderHTTPTransportStub { request in
#expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc")
#expect(request.value(forHTTPHeaderField: "User-Agent") == "Mozilla/5.0 Firefox/151.0")
#expect(request.value(forHTTPHeaderField: "Referer") == "https://t3.chat/settings/customization")
#expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test")
#expect(request.value(forHTTPHeaderField: "x-client-context") ==
"eyJjbGllbnQiOnsidmVyc2lvbiI6IjEuMTIuNCJ9fQ==")
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil)!
return (Data(Self.sampleResponse.utf8), response)
}
let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0))
_ = try await fetcher.fetch(
cookieHeaderOverride: curl,
now: Self.now,
transport: stub)
}
@Test
func `curl capture forwards ansi quoted and equals header forms`() async throws {
let curl = """
curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\
--header=$'User-Agent: Browser\\'s Agent' \\
--header 'X-Deployment-Id: dpl_test' \\
-H 'Cookie: session=abc'
"""
let stub = ProviderHTTPTransportStub { request in
#expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc")
#expect(request.value(forHTTPHeaderField: "User-Agent") == "Browser's Agent")
#expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test")
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil)!
return (Data(Self.sampleResponse.utf8), response)
}
let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0))
_ = try await fetcher.fetch(
cookieHeaderOverride: curl,
now: Self.now,
transport: stub)
}
@Test
func `full curl capture extracts cookie from long header form`() async throws {
let curl = """
curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\
--compressed \\
--header "Referer: https://t3.chat/settings/customization" \\
--header "Cookie: session=abc; cf_clearance=token" \\
--header "X-Deployment-Id: dpl_test"
"""
let stub = ProviderHTTPTransportStub { request in
#expect(request.value(forHTTPHeaderField: "Cookie") == "session=abc; cf_clearance=token")
#expect(request.value(forHTTPHeaderField: "Referer") == "https://t3.chat/settings/customization")
#expect(request.value(forHTTPHeaderField: "X-Deployment-Id") == "dpl_test")
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil)!
return (Data(Self.sampleResponse.utf8), response)
}
let fetcher = T3ChatUsageFetcher(browserDetection: BrowserDetection(cacheTTL: 0))
_ = try await fetcher.fetch(
cookieHeaderOverride: curl,
now: Self.now,
transport: stub)
}
@Test
func `manual strategy accepts full curl capture`() async {
let curl = """
curl 'https://t3.chat/api/trpc/getCustomerData?batch=1&input=ignored' \\
--header "Referer: https://t3.chat/settings/customization" \\
--header "Cookie: session=abc; cf_clearance=token" \\
--header "X-Deployment-Id: dpl_test"
"""
let settings = ProviderSettingsSnapshot.make(
t3chat: ProviderSettingsSnapshot.T3ChatProviderSettings(
cookieSource: .manual,
manualCookieHeader: curl))
#expect(await T3ChatWebFetchStrategy().isAvailable(Self.makeContext(settings: settings)))
}
@Test
func `unauthorized response is invalid credentials`() async throws {
let stub = ProviderHTTPTransportStub { request in
let response = HTTPURLResponse(
url: request.url!,
statusCode: 401,
httpVersion: nil,
headerFields: nil)!
return (Data("unauthorized".utf8), response)
}
await #expect {
_ = try await T3ChatUsageFetcher.fetchCustomerData(
cookieHeader: "session=abc",
now: Self.now,
transport: stub)
} throws: { error in
guard case T3ChatUsageError.invalidCredentials = error else { return false }
return true
}
}
@Test
func `vercel challenge response asks for full curl capture`() async throws {
let stub = ProviderHTTPTransportStub { request in
let response = HTTPURLResponse(
url: request.url!,
statusCode: 429,
httpVersion: nil,
headerFields: ["x-vercel-mitigated": "challenge"])!
return (Data("checkpoint".utf8), response)
}
await #expect {
_ = try await T3ChatUsageFetcher.fetchCustomerData(
cookieHeader: "session=abc",
now: Self.now,
transport: stub)
} throws: { error in
guard case T3ChatUsageError.vercelChallenge = error else { return false }
return true
}
}
private static func customerDataResponse(_ customerDataJSON: String) -> String {
#"{"json":[2,0,[[\#(customerDataJSON)]]]}"# + "\n"
}
private static func makeContext(settings: ProviderSettingsSnapshot) -> ProviderFetchContext {
ProviderFetchContext(
runtime: .app,
sourceMode: .auto,
includeCredits: false,
webTimeout: 1,
webDebugDumpHTML: false,
verbose: false,
env: [:],
settings: settings,
fetcher: UsageFetcher(environment: [:]),
claudeFetcher: StubClaudeFetcher(),
browserDetection: BrowserDetection(cacheTTL: 0))
}
}