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

363 lines
14 KiB
Swift

import Foundation
import Testing
@testable import CodexBarCore
struct PerplexityUsageFetcherTests {
// Fixed "now" so expiry comparisons are deterministic
private static let now = Date(timeIntervalSince1970: 1_740_000_000) // Feb 20, 2026
private static let futureTs: TimeInterval = 1_750_000_000 // ~Jun 2025, after now
private static let pastTs: TimeInterval = 1_700_000_000 // ~Nov 2023, before now
private static let renewalTs: TimeInterval = 1_743_000_000 // ~Mar 26, 2026
// MARK: - JSON Parsing
@Test
func `parses full response with recurring and promotional credits`() throws {
let json = """
{
"balance_cents": 7250,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 0,
"credit_grants": [
{ "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) },
{ "type": "promotional", "amount_cents": 20000, "expires_at_ts": \(Self.futureTs) }
],
"total_usage_cents": 2750
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
#expect(snapshot.recurringTotal == 10000)
#expect(snapshot.recurringUsed == 2750)
#expect(snapshot.promoTotal == 20000)
#expect(snapshot.promoUsed == 0)
#expect(snapshot.purchasedTotal == 0)
#expect(snapshot.purchasedUsed == 0)
#expect(snapshot.balanceCents == 7250)
#expect(snapshot.totalUsageCents == 2750)
#expect(abs(snapshot.renewalDate.timeIntervalSince1970 - Self.renewalTs) < 1)
}
@Test
func `waterfall attribution recurring then purchased then promo`() throws {
// Usage exceeds recurring, spills into purchased, then promo
let json = """
{
"balance_cents": 0,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 3000,
"credit_grants": [
{ "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) },
{ "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) }
],
"total_usage_cents": 9000
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
#expect(snapshot.recurringUsed == 5000) // recurring fully consumed
#expect(snapshot.purchasedUsed == 3000) // purchased fully consumed
#expect(snapshot.promoUsed == 1000) // 9000 - 5000 - 3000 = 1000 from promo
}
@Test
func `expired promotional grants are excluded`() throws {
let json = """
{
"balance_cents": 0,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 0,
"credit_grants": [
{ "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) },
{ "type": "promotional", "amount_cents": 5000, "expires_at_ts": \(Self.pastTs) }
],
"total_usage_cents": 1000
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
#expect(snapshot.promoTotal == 0) // expired grant excluded
#expect(snapshot.promoUsed == 0)
#expect(snapshot.promoExpiration == nil)
}
@Test
func `empty credit grants produces zero recurring`() throws {
let json = """
{
"balance_cents": 0,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 0,
"credit_grants": [],
"total_usage_cents": 0
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
#expect(snapshot.recurringTotal == 0)
#expect(snapshot.promoTotal == 0)
#expect(snapshot.purchasedTotal == 0)
#expect(snapshot.planName == nil)
}
@Test
func `malformed JSON throws parse failed`() {
let json = """
{ "balance_cents": "not a number", "credit_grants": null }
"""
#expect {
_ = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
} throws: { error in
guard case PerplexityAPIError.parseFailed = error else { return false }
return true
}
}
// MARK: - Plan Name Inference
@Test
func `plan name inference`() throws {
func makeSnapshot(recurringCents: Double) throws -> PerplexityUsageSnapshot {
let json = """
{
"balance_cents": 0,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 0,
"credit_grants": [
{ "type": "recurring", "amount_cents": \(recurringCents), "expires_at_ts": \(Self.futureTs) }
],
"total_usage_cents": 0
}
"""
return try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
}
#expect(try makeSnapshot(recurringCents: 0).planName == nil)
#expect(try makeSnapshot(recurringCents: 500).planName == "Pro")
#expect(try makeSnapshot(recurringCents: 1000).planName == "Pro")
#expect(try makeSnapshot(recurringCents: 10000).planName == "Max")
}
// MARK: - toUsageSnapshot
@Test
func `to usage snapshot always has secondary and tertiary`() throws {
let json = """
{
"balance_cents": 0,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 0,
"credit_grants": [
{ "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }
],
"total_usage_cents": 0
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
.toUsageSnapshot()
// secondary and tertiary always present even when no promo/purchased credits
#expect(snapshot.secondary != nil)
#expect(snapshot.tertiary != nil)
}
@Test
func `to usage snapshot zero recurring bar is fully depleted`() throws {
let json = """
{
"balance_cents": 0,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 0,
"credit_grants": [],
"total_usage_cents": 0
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
.toUsageSnapshot()
let primary = try #require(snapshot.primary)
// No recurring credits → bar renders as empty (100% used), not full (0% used)
#expect(primary.usedPercent == 100.0)
}
@Test
func `to usage snapshot omits primary when only fallback credits remain`() throws {
let json = """
{
"balance_cents": 6000,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 2000,
"credit_grants": [
{ "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) }
],
"total_usage_cents": 0
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
.toUsageSnapshot()
#expect(snapshot.primary == nil)
#expect(snapshot.secondary?.usedPercent == 0.0)
#expect(snapshot.tertiary?.usedPercent == 0.0)
}
@Test
func `to usage snapshot empty pools bars are fully depleted`() throws {
let json = """
{
"balance_cents": 0,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 0,
"credit_grants": [
{ "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }
],
"total_usage_cents": 0
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
.toUsageSnapshot()
let secondary = try #require(snapshot.secondary)
let tertiary = try #require(snapshot.tertiary)
// Empty pools render as 100% used (empty bar) not 0% used (full bar)
#expect(secondary.usedPercent == 100.0)
#expect(tertiary.usedPercent == 100.0)
}
// MARK: - Purchased credits from credit_grants
@Test
func `purchased credits from credit grants array`() throws {
// Purchased credits appear as credit_grant type="purchased" instead of
// current_period_purchased_cents. The snapshot should pick them up.
let json = """
{
"balance_cents": 23065,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 0,
"credit_grants": [
{ "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) },
{ "type": "purchased", "amount_cents": 40000 },
{ "type": "promotional", "amount_cents": 55000, "expires_at_ts": \(Self.futureTs) }
],
"total_usage_cents": 81935
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
#expect(snapshot.recurringTotal == 10000)
#expect(snapshot.purchasedTotal == 40000)
#expect(snapshot.promoTotal == 55000)
// Waterfall: recurring eats 10000, purchased eats 40000, promo eats 31935
#expect(snapshot.recurringUsed == 10000)
#expect(snapshot.purchasedUsed == 40000)
#expect(snapshot.promoUsed == 31935)
}
@Test
func `purchased credits prefer grants over field when both present`() throws {
// When both current_period_purchased_cents AND credit_grants type="purchased"
// are provided, the larger value wins.
let json = """
{
"balance_cents": 0,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 3000,
"credit_grants": [
{ "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) },
{ "type": "purchased", "amount_cents": 8000 },
{ "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) }
],
"total_usage_cents": 14000
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
// Purchased should use max(8000, 3000) = 8000
#expect(snapshot.purchasedTotal == 8000)
// Waterfall: 5000 recurring + 8000 purchased + 1000 promo = 14000
#expect(snapshot.recurringUsed == 5000)
#expect(snapshot.purchasedUsed == 8000)
#expect(snapshot.promoUsed == 1000)
}
@Test
func `purchased credits from field when no grant type`() throws {
// Legacy path: current_period_purchased_cents is set but no "purchased" grant
let json = """
{
"balance_cents": 0,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 3000,
"credit_grants": [
{ "type": "recurring", "amount_cents": 5000, "expires_at_ts": \(Self.futureTs) },
{ "type": "promotional", "amount_cents": 4000, "expires_at_ts": \(Self.futureTs) }
],
"total_usage_cents": 9000
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
// Still picks up purchased from the top-level field
#expect(snapshot.purchasedTotal == 3000)
#expect(snapshot.recurringUsed == 5000)
#expect(snapshot.purchasedUsed == 3000)
#expect(snapshot.promoUsed == 1000)
}
@Test
func `real world max plan with all three pools`() throws {
// Real-world scenario: Max plan, 10k recurring + 40k purchased + 55k bonus
// Total 105,000 available, 23,065 remaining → 81,935 used
let json = """
{
"balance_cents": 23065,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 0,
"credit_grants": [
{ "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) },
{ "type": "purchased", "amount_cents": 40000 },
{ "type": "promotional", "amount_cents": 55000, "expires_at_ts": \(Self.futureTs) }
],
"total_usage_cents": 81935
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
let usage = snapshot.toUsageSnapshot()
// Primary (recurring): fully consumed → 100%
let primary = try #require(usage.primary)
#expect(primary.usedPercent == 100.0)
// Tertiary (purchased): fully consumed → 100%
let tertiary = try #require(usage.tertiary)
#expect(tertiary.usedPercent == 100.0)
// Secondary (bonus): 31935/55000 ≈ 58.06% used → ~42% remaining
let secondary = try #require(usage.secondary)
let expectedPromoPercent = 31935.0 / 55000.0 * 100.0
#expect(abs(secondary.usedPercent - expectedPromoPercent) < 0.1)
}
@Test
func `to usage snapshot primary percent matches usage`() throws {
let json = """
{
"balance_cents": 0,
"renewal_date_ts": \(Self.renewalTs),
"current_period_purchased_cents": 0,
"credit_grants": [
{ "type": "recurring", "amount_cents": 10000, "expires_at_ts": \(Self.futureTs) }
],
"total_usage_cents": 2500
}
"""
let snapshot = try PerplexityUsageFetcher._parseResponseForTesting(Data(json.utf8), now: Self.now)
.toUsageSnapshot()
let primary = try #require(snapshot.primary)
#expect(primary.usedPercent == 25.0)
}
}