Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 39 additions & 31 deletions ClaudeMeter/Models/API/UsageAPIResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,42 +49,35 @@ enum MappingError: LocalizedError {
/// Extension to map API response to domain model
extension UsageAPIResponse {
func toDomain() throws -> UsageData {
// Configure ISO8601 formatter with proper options
let iso8601Formatter = ISO8601DateFormatter()
iso8601Formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]

// Parse reset dates (must be present and valid)
let sessionResetDate: Date
let weeklyResetDate: Date

guard let sessionResetString = fiveHour.resetsAt,
let parsedDate = iso8601Formatter.date(from: sessionResetString) else {
throw MappingError.missingCriticalField(field: "fiveHour.resetsAt")
}
sessionResetDate = parsedDate

guard let weeklyResetString = sevenDay.resetsAt,
let parsedDate = iso8601Formatter.date(from: weeklyResetString) else {
throw MappingError.missingCriticalField(field: "sevenDay.resetsAt")
}
weeklyResetDate = parsedDate

// Handle optional sonnet usage
let sonnetLimit: UsageLimit? = sevenDaySonnet.flatMap { sonnet in
let sonnetResetDate: Date

if let sonnetResetString = sonnet.resetsAt,
let parsedDate = iso8601Formatter.date(from: sonnetResetString) {
sonnetResetDate = parsedDate
} else {
// Default to 7 days in the future if no reset date
sonnetResetDate = Date().addingTimeInterval(7 * 24 * 3600)
}
// The Claude API legitimately returns `resets_at: null` for windows with no usage in
// them yet (e.g. a freshly created session window or a client account that hasn't been
// used today). In that case fall back to the end of the rolling window so the UI shows
// a sensible "Resets in …" hint instead of refusing the response. We only treat a
// present-but-malformed date string as a hard error.
let sessionResetDate = try parseResetDate(
from: fiveHour.resetsAt,
field: "fiveHour.resetsAt",
formatter: iso8601Formatter,
fallback: Constants.Pacing.sessionWindow
)
let weeklyResetDate = try parseResetDate(
from: sevenDay.resetsAt,
field: "sevenDay.resetsAt",
formatter: iso8601Formatter,
fallback: Constants.Pacing.weeklyWindow
)

return UsageLimit(
utilization: sonnet.utilization,
resetAt: sonnetResetDate
let sonnetLimit: UsageLimit? = try sevenDaySonnet.flatMap { sonnet -> UsageLimit? in
let sonnetResetDate = try parseResetDate(
from: sonnet.resetsAt,
field: "sevenDaySonnet.resetsAt",
formatter: iso8601Formatter,
fallback: Constants.Pacing.weeklyWindow
)
return UsageLimit(utilization: sonnet.utilization, resetAt: sonnetResetDate)
}

return UsageData(
Expand All @@ -100,4 +93,19 @@ extension UsageAPIResponse {
lastUpdated: Date()
)
}

private func parseResetDate(
from raw: String?,
field: String,
formatter: ISO8601DateFormatter,
fallback: TimeInterval
) throws -> Date {
guard let raw else {
return Date().addingTimeInterval(fallback)
}
guard let date = formatter.date(from: raw) else {
throw MappingError.missingCriticalField(field: field)
}
return date
}
}
48 changes: 46 additions & 2 deletions ClaudeMeterTests/UsageServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,59 @@ final class UsageServiceTests: XCTestCase {
assertDate(usageData.weeklyUsage.resetAt, equalsIso8601String: TestConstants.weeklyResetDateString)
}

func test_usageFetch_withInvalidPayload_surfacesInvalidResponse() async throws {
func test_usageFetch_withMissingResetAt_fillsInFallbackWindow() async throws {
// The Claude API returns `resets_at: null` for windows with no usage in them yet
// (typical for an account that hasn't been used today). The mapper should fall
// back to "now + window duration" rather than refusing the response.
let responseData = try makeUsageResponseData(
sessionUtilization: TestConstants.sessionPercentage,
sessionUtilization: 0,
weeklyUtilization: TestConstants.weeklyPercentage,
sessionResetAt: nil,
weeklyResetAt: TestConstants.weeklyResetDateString,
sonnetUtilization: nil,
sonnetResetAt: nil
)
let networkService = NetworkServiceStub(responseData: responseData)
let cacheRepository = CacheRepositoryFake()
let keychainRepository = KeychainRepositoryFake()
let settingsRepository = SettingsRepositoryFake()

let service = UsageService(
networkService: networkService,
cacheRepository: cacheRepository,
keychainRepository: keychainRepository,
settingsRepository: settingsRepository
)

try await keychainRepository.save(
sessionKey: TestConstants.sessionKeyValue,
account: "default"
)
var settings = AppSettings.default
settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString)
try await settingsRepository.save(settings)

let usageData = try await service.fetchUsage(forceRefresh: true)

XCTAssertEqual(usageData.sessionUsage.utilization, 0)
// Reset date should be in the future, roughly one session window from now.
XCTAssertGreaterThan(usageData.sessionUsage.resetAt.timeIntervalSinceNow, 0)
XCTAssertLessThanOrEqual(
usageData.sessionUsage.resetAt.timeIntervalSinceNow,
Constants.Pacing.sessionWindow + 5
)
}

func test_usageFetch_withMalformedResetAt_surfacesInvalidResponse() async throws {
// A non-null but unparseable date string is still treated as a hard error.
let responseData = try makeUsageResponseData(
sessionUtilization: TestConstants.sessionPercentage,
weeklyUtilization: TestConstants.weeklyPercentage,
sessionResetAt: "not-a-date",
weeklyResetAt: TestConstants.weeklyResetDateString,
sonnetUtilization: nil,
sonnetResetAt: nil
)

let networkService = NetworkServiceStub(responseData: responseData)
let cacheRepository = CacheRepositoryFake()
Expand Down