diff --git a/ClaudeMeter/Models/API/UsageAPIResponse.swift b/ClaudeMeter/Models/API/UsageAPIResponse.swift index b1bea04..9d69d31 100644 --- a/ClaudeMeter/Models/API/UsageAPIResponse.swift +++ b/ClaudeMeter/Models/API/UsageAPIResponse.swift @@ -49,38 +49,30 @@ 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 + 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 + ) // 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) - } - + 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 @@ -100,4 +92,19 @@ extension UsageAPIResponse { lastUpdated: Date() ) } + + private func parseResetDate( + from rawValue: String?, + field: String, + formatter: ISO8601DateFormatter, + fallback: TimeInterval + ) throws -> Date { + guard let rawValue else { + return Date().addingTimeInterval(fallback) + } + guard let date = formatter.date(from: rawValue) else { + throw MappingError.missingCriticalField(field: field) + } + return date + } } diff --git a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift index 7147cd6..199bf06 100644 --- a/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift +++ b/ClaudeMeter/Views/MenuBar/UsagePopoverView.swift @@ -32,8 +32,7 @@ struct UsagePopoverView: View { }) { if appModel.isRefreshing { ProgressView() - .scaleEffect(0.7) - .frame(width: 20, height: 20) + .controlSize(.small) } else { Image(systemName: "arrow.clockwise") } diff --git a/ClaudeMeterTests/UsageServiceTests.swift b/ClaudeMeterTests/UsageServiceTests.swift index f5e1ca0..a40ae1d 100644 --- a/ClaudeMeterTests/UsageServiceTests.swift +++ b/ClaudeMeterTests/UsageServiceTests.swift @@ -181,9 +181,9 @@ final class UsageServiceTests: XCTestCase { assertDate(usageData.weeklyUsage.resetAt, equalsIso8601String: TestConstants.weeklyResetDateString) } - func test_usageFetch_withInvalidPayload_surfacesInvalidResponse() async throws { + func test_usageFetch_withMissingResetAt_usesFallbackWindow() async throws { let responseData = try makeUsageResponseData( - sessionUtilization: TestConstants.sessionPercentage, + sessionUtilization: 0, weeklyUtilization: TestConstants.weeklyPercentage, sessionResetAt: nil, weeklyResetAt: TestConstants.weeklyResetDateString, @@ -212,6 +212,47 @@ final class UsageServiceTests: XCTestCase { settings.cachedOrganizationId = UUID(uuidString: TestConstants.organizationUUIDString) try await settingsRepository.save(settings) + let usageData = try await service.fetchUsage(forceRefresh: true) + + XCTAssertEqual(usageData.sessionUsage.utilization, 0) + XCTAssertGreaterThan(usageData.sessionUsage.resetAt.timeIntervalSinceNow, 0) + XCTAssertLessThanOrEqual( + usageData.sessionUsage.resetAt.timeIntervalSinceNow, + Constants.Pacing.sessionWindow + 5 + ) + } + + func test_usageFetch_withMalformedResetAt_surfacesInvalidResponse() async throws { + 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() + 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) + do { _ = try await service.fetchUsage(forceRefresh: true) XCTFail("Expected invalidResponse error")