diff --git a/ClaudeMeter/Models/API/UsageAPIResponse.swift b/ClaudeMeter/Models/API/UsageAPIResponse.swift index b1bea04..7efc153 100644 --- a/ClaudeMeter/Models/API/UsageAPIResponse.swift +++ b/ClaudeMeter/Models/API/UsageAPIResponse.swift @@ -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( @@ -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 + } } diff --git a/ClaudeMeterTests/UsageServiceTests.swift b/ClaudeMeterTests/UsageServiceTests.swift index f5e1ca0..88f08ea 100644 --- a/ClaudeMeterTests/UsageServiceTests.swift +++ b/ClaudeMeterTests/UsageServiceTests.swift @@ -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()