@@ -0,0 +1,752 @@
1+import XCTest
2+import Foundation
3+@testable import HyperCreditsMenubar
4+
5+/// The instant every test pretends it is. Injected into the client, so token expiry is
6+/// a decision the test makes rather than a race against the wall clock.
7+private let fixedNow = Date(timeIntervalSince1970: 1_700_000_000)
8+
9+final class ClaudeUsageClientTests: XCTestCase {
10+
11+ override func setUp() {
12+ super.setUp()
13+ StubURLProtocol.reset()
14+ }
15+
16+ override func tearDown() {
17+ StubURLProtocol.reset()
18+ super.tearDown()
19+ }
20+
21+ // MARK: - Fixtures
22+
23+ /// A client whose requests are served by `StubURLProtocol` and whose credentials
24+ /// come from memory, so no test here touches the real Keychain, the real
25+ /// `~/.claude/.credentials.json`, or the network. The backoff is short enough that
26+ /// the retry tests run in milliseconds rather than seconds.
27+ private func makeClient(
28+ store: ClaudeCredentialStoring,
29+ retryBaseDelay: Duration = .milliseconds(1)
30+ ) -> ClaudeUsageClient {
31+ let config = URLSessionConfiguration.ephemeral
32+ config.protocolClasses = [StubURLProtocol.self]
33+ return ClaudeUsageClient(
34+ session: URLSession(configuration: config),
35+ store: store,
36+ retryBaseDelay: retryBaseDelay,
37+ now: { fixedNow }
38+ )
39+ }
40+
41+ private func credentials(
42+ accessToken: String = "access-1",
43+ refreshToken: String = "refresh-1",
44+ expiresAt: Date = fixedNow.addingTimeInterval(3600),
45+ subscriptionType: String? = "max"
46+ ) -> ClaudeOAuthCredentials {
47+ ClaudeOAuthCredentials(
48+ claudeAiOauth: .init(
49+ accessToken: accessToken,
50+ refreshToken: refreshToken,
51+ expiresAt: Int64(expiresAt.timeIntervalSince1970 * 1000),
52+ scopes: ["user:inference"],
53+ subscriptionType: subscriptionType,
54+ rateLimitTier: nil
55+ )
56+ )
57+ }
58+
59+ /// Credentials whose access token is already spent, so a fetch has to refresh first.
60+ private func expiredCredentials() -> ClaudeOAuthCredentials {
61+ credentials(expiresAt: fixedNow.addingTimeInterval(-1))
62+ }
63+
64+ private func usageJSON(fiveHour: Int = 68, sevenDay: Int = 41) -> Data {
65+ Data("""
66+ {
67+ "five_hour": {"utilization": \(fiveHour), "resets_at": "2023-11-14T23:53:20.000Z"},
68+ "seven_day": {"utilization": \(sevenDay), "resets_at": "2023-11-20T23:53:20.000Z"}
69+ }
70+ """.utf8)
71+ }
72+
73+ private func tokenJSON(
74+ accessToken: String = "access-2",
75+ refreshToken: String? = "refresh-2",
76+ expiresIn: Int? = 3600
77+ ) -> Data {
78+ var fields = [#""access_token": "\#(accessToken)""#]
79+ if let refreshToken { fields.append(#""refresh_token": "\#(refreshToken)""#) }
80+ if let expiresIn { fields.append(#""expires_in": \#(expiresIn)"#) }
81+ return Data("{\(fields.joined(separator: ", "))}".utf8)
82+ }
83+
84+ private func requests(to url: URL) -> [URLRequest] {
85+ StubURLProtocol.recordedRequests.filter { $0.url == url }
86+ }
87+
88+ // MARK: - Usage decoding
89+
90+ func testDecodesUsagePayload() throws {
91+ let json = Data("""
92+ {
93+ "five_hour": {"utilization": 68.4, "resets_at": "2026-07-11T20:00:00.000Z"},
94+ "seven_day": {"utilization": 41, "resets_at": "2026-07-15T20:00:00.000Z"},
95+ "seven_day_opus": {"utilization": 12, "resets_at": null},
96+ "limits": [
97+ {"percent": 41, "is_active": false, "resets_at": "2026-07-15T20:00:00.000Z"},
98+ {"percent": 68, "is_active": true, "resets_at": "2026-07-11T20:00:00.000Z"}
99+ ]
100+ }
101+ """.utf8)
102+
103+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
104+
105+ XCTAssertEqual(usage.fiveHour?.utilization, 68.4)
106+ XCTAssertEqual(usage.sevenDay?.utilization, 41)
107+ XCTAssertEqual(usage.sevenDayOpus?.utilization, 12)
108+ XCTAssertNil(usage.sevenDayOpus?.resetsAt)
109+ XCTAssertEqual(usage.limits?.count, 2)
110+ XCTAssertFalse(usage.isEmpty)
111+ }
112+
113+ func testDecodesEmptyPayload() throws {
114+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: Data("{}".utf8))
115+
116+ XCTAssertNil(usage.fiveHour)
117+ XCTAssertNil(usage.limits)
118+ XCTAssertTrue(usage.isEmpty, "An account with no usage has nothing worth a section")
119+ }
120+
121+ func testIgnoresWindowsItDoesNotModel() throws {
122+ // Anthropic adds and retires windows without notice; an unknown one must not
123+ // take the rest of the payload down with it.
124+ let json = Data("""
125+ {
126+ "five_hour": {"utilization": 5},
127+ "tangelo": {"utilization": 99},
128+ "iguana_necktie": {"utilization": 99},
129+ "spend": {"percent": 3, "enabled": true}
130+ }
131+ """.utf8)
132+
133+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
134+
135+ XCTAssertEqual(usage.fiveHour?.utilization, 5)
136+ XCTAssertFalse(usage.isEmpty)
137+ }
138+
139+ func testMissingUtilizationDefaultsToZero() throws {
140+ let json = Data(#"{"five_hour": {"resets_at": "2026-07-11T20:00:00.000Z"}}"#.utf8)
141+
142+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
143+
144+ XCTAssertEqual(usage.fiveHour?.utilization, 0)
145+ }
146+
147+ func testFractionalPercentIsRounded() throws {
148+ // The field is documented as an integer, but decoding it as one would throw on
149+ // a fractional value and lose the whole payload.
150+ let json = Data(#"{"limits": [{"percent": 68.6, "is_active": true}]}"#.utf8)
151+
152+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
153+
154+ XCTAssertEqual(usage.activeLimit?.percent, 69)
155+ }
156+
157+ func testActiveLimitIsTheOneTheServerFlags() throws {
158+ let json = Data("""
159+ {"limits": [
160+ {"percent": 41, "is_active": false},
161+ {"percent": 68, "is_active": true}
162+ ]}
163+ """.utf8)
164+
165+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
166+
167+ XCTAssertEqual(usage.activeLimit?.percent, 68)
168+ }
169+
170+ func testActiveLimitIsNilWhenNoneIsFlagged() throws {
171+ let json = Data(#"{"limits": [{"percent": 41}, {"percent": 12}]}"#.utf8)
172+
173+ let usage = try JSONDecoder().decode(ClaudeUsage.self, from: json)
174+
175+ XCTAssertNil(usage.activeLimit)
176+ }
177+
178+ // MARK: - Reset countdown
179+
180+ func testResetsInFormatsHoursAndMinutes() {
181+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(3 * 3600 + 20 * 60)))
182+
183+ XCTAssertEqual(window.resetsIn(from: fixedNow), "3h 20m")
184+ }
185+
186+ func testResetsInFormatsDaysAndHours() {
187+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(28 * 3600)))
188+
189+ XCTAssertEqual(window.resetsIn(from: fixedNow), "1d 4h")
190+ }
191+
192+ func testResetsInFormatsMinutesAlone() {
193+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(12 * 60)))
194+
195+ XCTAssertEqual(window.resetsIn(from: fixedNow), "12m")
196+ }
197+
198+ func testResetsInIsNilOnceTheWindowHasPassed() {
199+ let window = UsageWindow(utilization: 50, resetsAt: iso(fixedNow.addingTimeInterval(-60)))
200+
201+ XCTAssertNil(window.resetsIn(from: fixedNow))
202+ }
203+
204+ func testResetsInIsNilWithoutATimestamp() {
205+ XCTAssertNil(UsageWindow(utilization: 0).resetsIn(from: fixedNow))
206+ }
207+
208+ func testResetsAtParsesTimestampsWithoutFractionalSeconds() {
209+ // Claude's timestamps normally carry fractional seconds; this spelling has to
210+ // parse too, rather than silently costing us the countdown.
211+ let window = UsageWindow(utilization: 50, resetsAt: "2026-07-11T20:00:00Z")
212+
213+ XCTAssertEqual(window.resetsAtDate?.timeIntervalSince1970, 1_783_800_000)
214+ }
215+
216+ func testResetsAtIsNilForAnUnparseableTimestamp() {
217+ XCTAssertNil(UsageWindow(utilization: 50, resetsAt: "not a date").resetsAtDate)
218+ }
219+
220+ func testLimitAlsoCountsDown() {
221+ let limit = Limit(percent: 68, resetsAt: iso(fixedNow.addingTimeInterval(90 * 60)), isActive: true)
222+
223+ XCTAssertEqual(limit.resetsIn(from: fixedNow), "1h 30m")
224+ }
225+
226+ // MARK: - Fetching
227+
228+ func testFetchSendsTheOAuthHeaders() async throws {
229+ let store = FakeClaudeCredentialStore(credentials: credentials())
230+ StubURLProtocol.enqueue(.status(200, usageJSON()))
231+
232+ let report = try await makeClient(store: store).fetchUsage()
233+
234+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
235+ XCTAssertEqual(report.subscriptionType, "max")
236+
237+ let request = try XCTUnwrap(StubURLProtocol.recordedRequests.first)
238+ XCTAssertEqual(request.url, ClaudeUsageClient.usageEndpoint)
239+ XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer access-1")
240+ XCTAssertEqual(request.value(forHTTPHeaderField: "anthropic-beta"), "oauth-2025-04-20")
241+ XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
242+ }
243+
244+ func testMissingCredentialsThrowWithoutTouchingTheNetwork() async {
245+ let store = FakeClaudeCredentialStore(credentials: nil)
246+
247+ do {
248+ _ = try await makeClient(store: store).fetchUsage()
249+ XCTFail("Expected noCredentials error")
250+ } catch ClaudeUsageError.noCredentials {
251+ // expected — the caller reads this as "Claude Code isn't set up here"
252+ } catch {
253+ XCTFail("Unexpected error: \(error)")
254+ }
255+
256+ XCTAssertEqual(StubURLProtocol.requestCount, 0)
257+ }
258+
259+ func testValidTokenIsUsedWithoutRefreshing() async throws {
260+ let store = FakeClaudeCredentialStore(credentials: credentials())
261+ StubURLProtocol.enqueue(.status(200, usageJSON()))
262+
263+ _ = try await makeClient(store: store).fetchUsage()
264+
265+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
266+ XCTAssertTrue(requests(to: ClaudeUsageClient.tokenEndpoint).isEmpty)
267+ XCTAssertTrue(store.savedCredentials.isEmpty, "Nothing was rotated, so nothing to write back")
268+ }
269+
270+ // MARK: - Token refresh
271+
272+ func testExpiredTokenIsRefreshedBeforeTheUsageRequest() async throws {
273+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
274+ StubURLProtocol.enqueue(
275+ .status(200, tokenJSON(accessToken: "access-2")),
276+ .status(200, usageJSON())
277+ )
278+
279+ let report = try await makeClient(store: store).fetchUsage()
280+
281+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
282+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
283+
284+ let recorded = StubURLProtocol.recordedRequests
285+ XCTAssertEqual(recorded[0].url, ClaudeUsageClient.tokenEndpoint)
286+ XCTAssertEqual(recorded[0].httpMethod, "POST")
287+ XCTAssertEqual(recorded[1].url, ClaudeUsageClient.usageEndpoint)
288+ XCTAssertEqual(
289+ recorded[1].value(forHTTPHeaderField: "Authorization"),
290+ "Bearer access-2",
291+ "The usage request has to carry the refreshed token, not the spent one"
292+ )
293+ }
294+
295+ func testRefreshRotatesAndPersistsTheCredentials() async throws {
296+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
297+ StubURLProtocol.enqueue(
298+ .status(200, tokenJSON(accessToken: "access-2", refreshToken: "refresh-2", expiresIn: 3600)),
299+ .status(200, usageJSON())
300+ )
301+
302+ _ = try await makeClient(store: store).fetchUsage()
303+
304+ let saved = try XCTUnwrap(store.savedCredentials.last)
305+ let token = saved.credentials.claudeAiOauth
306+ XCTAssertEqual(token.accessToken, "access-2")
307+ XCTAssertEqual(
308+ token.refreshToken,
309+ "refresh-2",
310+ "The server rotated the refresh token; failing to store it would sign Claude Code out"
311+ )
312+ XCTAssertEqual(token.expiresAt, Int64((fixedNow.timeIntervalSince1970 + 3600) * 1000))
313+ XCTAssertEqual(saved.source, .keychain(account: "tester"), "Written back where it was read from")
314+ }
315+
316+ func testRefreshKeepsTheOldRefreshTokenWhenTheServerDoesNotRotateIt() async throws {
317+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
318+ StubURLProtocol.enqueue(
319+ .status(200, tokenJSON(accessToken: "access-2", refreshToken: nil)),
320+ .status(200, usageJSON())
321+ )
322+
323+ _ = try await makeClient(store: store).fetchUsage()
324+
325+ let saved = try XCTUnwrap(store.savedCredentials.last)
326+ XCTAssertEqual(saved.credentials.claudeAiOauth.refreshToken, "refresh-1")
327+ }
328+
329+ func testRefreshWithoutExpiresInAssumesEightHours() async throws {
330+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
331+ StubURLProtocol.enqueue(
332+ .status(200, tokenJSON(accessToken: "access-2", expiresIn: nil)),
333+ .status(200, usageJSON())
334+ )
335+
336+ _ = try await makeClient(store: store).fetchUsage()
337+
338+ let saved = try XCTUnwrap(store.savedCredentials.last)
339+ XCTAssertEqual(
340+ saved.credentials.claudeAiOauth.expiresAt,
341+ Int64((fixedNow.timeIntervalSince1970 + 8 * 3600) * 1000)
342+ )
343+ }
344+
345+ func testRefreshSendsTheGrant() async throws {
346+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
347+ StubURLProtocol.enqueue(.status(200, tokenJSON()), .status(200, usageJSON()))
348+
349+ _ = try await makeClient(store: store).fetchUsage()
350+
351+ let body = try XCTUnwrap(StubURLProtocol.recordedBodies.first ?? nil)
352+ let grant = try XCTUnwrap(try JSONSerialization.jsonObject(with: body) as? [String: String])
353+ XCTAssertEqual(grant["grant_type"], "refresh_token")
354+ XCTAssertEqual(grant["refresh_token"], "refresh-1")
355+ XCTAssertEqual(grant["client_id"], ClaudeUsageClient.clientID)
356+ XCTAssertEqual(grant["scope"], ClaudeUsageClient.scope)
357+ }
358+
359+ func testRefreshFallsBackToFormEncoding() async throws {
360+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
361+ StubURLProtocol.enqueue(
362+ .status(400, Data()), // the JSON body is rejected
363+ .status(200, tokenJSON(accessToken: "access-2")),
364+ .status(200, usageJSON())
365+ )
366+
367+ _ = try await makeClient(store: store).fetchUsage()
368+
369+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
370+ let contentTypes = StubURLProtocol.recordedRequests.prefix(2).map {
371+ $0.value(forHTTPHeaderField: "Content-Type")
372+ }
373+ XCTAssertEqual(contentTypes, ["application/json", "application/x-www-form-urlencoded"])
374+
375+ let form = try XCTUnwrap(StubURLProtocol.recordedBodies[1].flatMap { String(data: $0, encoding: .utf8) })
376+ XCTAssertTrue(form.contains("grant_type=refresh_token"), "Got: \(form)")
377+ XCTAssertTrue(form.contains("refresh_token=refresh-1"), "Got: \(form)")
378+ XCTAssertTrue(
379+ form.contains("user%3Ainference"),
380+ "The colons in the scope have to be percent-encoded, not sent raw. Got: \(form)"
381+ )
382+ }
383+
384+ func testRefreshFailureThrowsTokenRefreshFailed() async {
385+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
386+ StubURLProtocol.enqueue(.status(400, Data())) // repeats, so both encodings fail
387+
388+ do {
389+ _ = try await makeClient(store: store).fetchUsage()
390+ XCTFail("Expected tokenRefreshFailed error")
391+ } catch ClaudeUsageError.tokenRefreshFailed {
392+ // expected
393+ } catch {
394+ XCTFail("Unexpected error: \(error)")
395+ }
396+
397+ // Both encodings tried, and no usage request made with a token we never got.
398+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
399+ XCTAssertTrue(requests(to: ClaudeUsageClient.usageEndpoint).isEmpty)
400+ }
401+
402+ func testConcurrentFetchesShareASingleRefresh() async throws {
403+ // Two refreshes at once would each spend the refresh token, and whichever
404+ // finished second would be left holding one the server had already retired.
405+ let store = FakeClaudeCredentialStore(credentials: expiredCredentials())
406+ StubURLProtocol.enqueue(.status(200, tokenJSON()), .status(200, usageJSON()))
407+
408+ let client = makeClient(store: store)
409+ async let first = client.fetchUsage()
410+ async let second = client.fetchUsage()
411+ _ = try await (first, second)
412+
413+ XCTAssertEqual(requests(to: ClaudeUsageClient.tokenEndpoint).count, 1)
414+ XCTAssertEqual(requests(to: ClaudeUsageClient.usageEndpoint).count, 2)
415+ XCTAssertEqual(store.savedCredentials.count, 1)
416+ }
417+
418+ // MARK: - Rejected tokens
419+
420+ func testRejectedTokenIsRefreshedOnceThenRetried() async throws {
421+ // A token can be revoked server-side well before its stated expiry.
422+ let store = FakeClaudeCredentialStore(credentials: credentials())
423+ StubURLProtocol.enqueue(
424+ .status(401, Data()),
425+ .status(200, tokenJSON(accessToken: "access-2")),
426+ .status(200, usageJSON())
427+ )
428+
429+ let report = try await makeClient(store: store).fetchUsage()
430+
431+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
432+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
433+ XCTAssertEqual(
434+ StubURLProtocol.recordedRequests[2].value(forHTTPHeaderField: "Authorization"),
435+ "Bearer access-2"
436+ )
437+ }
438+
439+ func testRepeatedRejectionSurfacesInvalidCredentials() async {
440+ let store = FakeClaudeCredentialStore(credentials: credentials())
441+ StubURLProtocol.enqueue(
442+ .status(401, Data()),
443+ .status(200, tokenJSON(accessToken: "access-2")),
444+ .status(401, Data())
445+ )
446+
447+ do {
448+ _ = try await makeClient(store: store).fetchUsage()
449+ XCTFail("Expected invalidCredentials error")
450+ } catch ClaudeUsageError.invalidCredentials {
451+ // expected
452+ } catch {
453+ XCTFail("Unexpected error: \(error)")
454+ }
455+
456+ // One refresh, and no attempt at a second: it plainly isn't the token.
457+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
458+ XCTAssertEqual(requests(to: ClaudeUsageClient.tokenEndpoint).count, 1)
459+ }
460+
461+ func testFailedRefreshAfterRejectionSurfacesTheRejection() async {
462+ // "Couldn't refresh" says less than "the server rejected your token", and both
463+ // point at the same fix, so the rejection is the more useful of the two.
464+ let store = FakeClaudeCredentialStore(credentials: credentials())
465+ StubURLProtocol.enqueue(.status(401, Data()), .status(403, Data()))
466+
467+ do {
468+ _ = try await makeClient(store: store).fetchUsage()
469+ XCTFail("Expected invalidCredentials error")
470+ } catch ClaudeUsageError.invalidCredentials {
471+ // expected
472+ } catch {
473+ XCTFail("Unexpected error: \(error)")
474+ }
475+ }
476+
477+ func testForbiddenIsTreatedAsARejectedToken() async {
478+ let store = FakeClaudeCredentialStore(credentials: credentials())
479+ StubURLProtocol.enqueue(.status(403, Data()))
480+
481+ do {
482+ _ = try await makeClient(store: store).fetchUsage()
483+ XCTFail("Expected invalidCredentials error")
484+ } catch ClaudeUsageError.invalidCredentials {
485+ // expected
486+ } catch {
487+ XCTFail("Unexpected error: \(error)")
488+ }
489+ }
490+
491+ // MARK: - Retries
492+
493+ func testRetriesOnServerErrorThenSucceeds() async throws {
494+ let store = FakeClaudeCredentialStore(credentials: credentials())
495+ StubURLProtocol.enqueue(
496+ .status(500, Data()),
497+ .status(503, Data()),
498+ .status(200, usageJSON())
499+ )
500+
501+ let report = try await makeClient(store: store).fetchUsage()
502+
503+ XCTAssertEqual(report.usage.fiveHour?.utilization, 68)
504+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
505+ }
506+
507+ func testRetriesOnRateLimitThenSucceeds() async throws {
508+ let store = FakeClaudeCredentialStore(credentials: credentials())
509+ StubURLProtocol.enqueue(.status(429, Data()), .status(200, usageJSON()))
510+
511+ _ = try await makeClient(store: store).fetchUsage()
512+
513+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
514+ }
515+
516+ func testRetriesOnNetworkErrorThenSucceeds() async throws {
517+ let store = FakeClaudeCredentialStore(credentials: credentials())
518+ StubURLProtocol.enqueue(.failure(URLError(.timedOut)), .status(200, usageJSON()))
519+
520+ _ = try await makeClient(store: store).fetchUsage()
521+
522+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
523+ }
524+
525+ func testGivesUpAfterTwoRetries() async {
526+ let store = FakeClaudeCredentialStore(credentials: credentials())
527+ StubURLProtocol.enqueue(.status(500, Data())) // repeats
528+
529+ do {
530+ _ = try await makeClient(store: store).fetchUsage()
531+ XCTFail("Expected requestFailed error")
532+ } catch ClaudeUsageError.requestFailed(let message) {
533+ XCTAssertEqual(message, "HTTP 500")
534+ } catch {
535+ XCTFail("Unexpected error: \(error)")
536+ }
537+
538+ // Initial attempt + 2 retries.
539+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
540+ }
541+
542+ func testDoesNotRetryOnClientError() async {
543+ let store = FakeClaudeCredentialStore(credentials: credentials())
544+ StubURLProtocol.enqueue(.status(404, Data()))
545+
546+ do {
547+ _ = try await makeClient(store: store).fetchUsage()
548+ XCTFail("Expected requestFailed error")
549+ } catch ClaudeUsageError.requestFailed(let message) {
550+ XCTAssertEqual(message, "HTTP 404")
551+ } catch {
552+ XCTFail("Unexpected error: \(error)")
553+ }
554+
555+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
556+ }
557+
558+ func testDoesNotRetryOnDecodingFailure() async {
559+ let store = FakeClaudeCredentialStore(credentials: credentials())
560+ StubURLProtocol.enqueue(.status(200, Data("not json".utf8)))
561+
562+ do {
563+ _ = try await makeClient(store: store).fetchUsage()
564+ XCTFail("Expected decodingFailed error")
565+ } catch ClaudeUsageError.decodingFailed {
566+ // expected
567+ } catch {
568+ XCTFail("Unexpected error: \(error)")
569+ }
570+
571+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
572+ }
573+
574+ // MARK: - Errors
575+
576+ func testErrorsAreEquatable() {
577+ XCTAssertEqual(ClaudeUsageError.noCredentials, .noCredentials)
578+ XCTAssertNotEqual(ClaudeUsageError.noCredentials, .invalidCredentials)
579+ XCTAssertEqual(ClaudeUsageError.requestFailed("HTTP 500"), .requestFailed("HTTP 500"))
580+ }
581+
582+ func testClaudeUsageClientConformsToProtocol() {
583+ let checker: ClaudeUsageChecking = ClaudeUsageClient()
584+ // If this compiles, ClaudeUsageClient conforms to ClaudeUsageChecking.
585+ XCTAssertNotNil(checker)
586+ }
587+
588+ // MARK: - Helpers
589+
590+ /// An ISO 8601 timestamp with fractional seconds, as Claude's API spells them.
591+ private func iso(_ date: Date) -> String {
592+ let formatter = ISO8601DateFormatter()
593+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
594+ return formatter.string(from: date)
595+ }
596+}
597+
598+// MARK: - Credential store
599+
600+/// These drive `ClaudeCodeCredentialStore` against a throwaway file and a Keychain
601+/// service that cannot exist, so `security` always misses and the file fallback is what
602+/// gets exercised. Nothing here can read — let alone write — the real Claude Code
603+/// credentials, which is the whole point: a test that clobbered them would sign the
604+/// user out of their own CLI.
605+final class ClaudeCodeCredentialStoreTests: XCTestCase {
606+
607+ private var directory: URL!
608+
609+ /// A credentials blob carrying two fields we deliberately don't model.
610+ private let credentialsJSON = """
611+ {
612+ "claudeAiOauth": {
613+ "accessToken": "access-1",
614+ "refreshToken": "refresh-1",
615+ "expiresAt": 4000000000000,
616+ "scopes": ["user:inference", "user:profile"],
617+ "subscriptionType": "max",
618+ "somethingWeDoNotModel": "keep me"
619+ },
620+ "anotherTopLevelKey": "keep me too"
621+ }
622+ """
623+
624+ override func setUpWithError() throws {
625+ try super.setUpWithError()
626+ directory = URL(fileURLWithPath: NSTemporaryDirectory())
627+ .appendingPathComponent("claude-credentials-\(UUID().uuidString)")
628+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
629+ }
630+
631+ override func tearDownWithError() throws {
632+ if let directory {
633+ try? FileManager.default.removeItem(at: directory)
634+ }
635+ try super.tearDownWithError()
636+ }
637+
638+ private func makeStore(file: URL) -> ClaudeCodeCredentialStore {
639+ ClaudeCodeCredentialStore(
640+ keychainService: "com.example.tests.absent-\(UUID().uuidString)",
641+ credentialsFile: file
642+ )
643+ }
644+
645+ private func writeCredentialsFile() throws -> URL {
646+ let file = directory.appendingPathComponent(".credentials.json")
647+ try Data(credentialsJSON.utf8).write(to: file)
648+ return file
649+ }
650+
651+ func testReadsCredentialsFromTheFileWhenTheKeychainHasNothing() throws {
652+ let file = try writeCredentialsFile()
653+
654+ let stored = try XCTUnwrap(makeStore(file: file).load())
655+
656+ XCTAssertEqual(stored.credentials.claudeAiOauth.accessToken, "access-1")
657+ XCTAssertEqual(stored.credentials.claudeAiOauth.refreshToken, "refresh-1")
658+ XCTAssertEqual(stored.credentials.claudeAiOauth.subscriptionType, "max")
659+ XCTAssertEqual(stored.source, .file(file))
660+ }
661+
662+ func testReturnsNilWhenThereIsNeitherAKeychainItemNorAFile() {
663+ let missing = directory.appendingPathComponent("nothing-here.json")
664+
665+ XCTAssertNil(makeStore(file: missing).load())
666+ }
667+
668+ func testReturnsNilForACorruptedFile() throws {
669+ let file = directory.appendingPathComponent(".credentials.json")
670+ try Data("{ not json".utf8).write(to: file)
671+
672+ XCTAssertNil(makeStore(file: file).load())
673+ }
674+
675+ func testWriteBackRotatesTheTokensAndKeepsEverythingElse() throws {
676+ let file = try writeCredentialsFile()
677+ let store = makeStore(file: file)
678+
679+ var stored = try XCTUnwrap(store.load())
680+ stored.credentials.claudeAiOauth.accessToken = "access-2"
681+ stored.credentials.claudeAiOauth.refreshToken = "refresh-2"
682+ stored.credentials.claudeAiOauth.expiresAt = 4_100_000_000_000
683+ store.save(stored)
684+
685+ let written = try XCTUnwrap(
686+ try JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any]
687+ )
688+ let oauth = try XCTUnwrap(written["claudeAiOauth"] as? [String: Any])
689+
690+ XCTAssertEqual(oauth["accessToken"] as? String, "access-2")
691+ XCTAssertEqual(oauth["refreshToken"] as? String, "refresh-2")
692+ XCTAssertEqual((oauth["expiresAt"] as? NSNumber)?.int64Value, 4_100_000_000_000)
693+
694+ // This is the file the user's CLI signs in with. Round-tripping it through our
695+ // own type would drop the fields we don't model, so the write patches the raw
696+ // JSON instead — and these two have to survive it.
697+ XCTAssertEqual(oauth["somethingWeDoNotModel"] as? String, "keep me")
698+ XCTAssertEqual(written["anotherTopLevelKey"] as? String, "keep me too")
699+ XCTAssertEqual(oauth["subscriptionType"] as? String, "max")
700+ }
701+
702+ func testWriteBackLeavesTheFileReadableOnlyByItsOwner() throws {
703+ let file = try writeCredentialsFile()
704+ let store = makeStore(file: file)
705+
706+ let stored = try XCTUnwrap(store.load())
707+ store.save(stored)
708+
709+ let attributes = try FileManager.default.attributesOfItem(atPath: file.path)
710+ XCTAssertEqual(
711+ (attributes[.posixPermissions] as? NSNumber)?.int16Value,
712+ 0o600,
713+ "An atomic write replaces the file, so the mode has to be reapplied"
714+ )
715+ }
716+}
717+
718+// MARK: - Fake credential store
719+
720+/// An in-memory `ClaudeCredentialStoring`. Never goes near the Keychain or the disk.
721+final class FakeClaudeCredentialStore: ClaudeCredentialStoring {
722+ private let lock = NSLock()
723+ private var current: StoredClaudeCredentials?
724+ private var writes: [StoredClaudeCredentials] = []
725+
726+ init(
727+ credentials: ClaudeOAuthCredentials?,
728+ source: ClaudeCredentialSource = .keychain(account: "tester")
729+ ) {
730+ current = credentials.map { StoredClaudeCredentials(credentials: $0, source: source) }
731+ }
732+
733+ /// Every write-back the client has made, oldest first.
734+ var savedCredentials: [StoredClaudeCredentials] {
735+ lock.lock()
736+ defer { lock.unlock() }
737+ return writes
738+ }
739+
740+ func load() -> StoredClaudeCredentials? {
741+ lock.lock()
742+ defer { lock.unlock() }
743+ return current
744+ }
745+
746+ func save(_ stored: StoredClaudeCredentials) {
747+ lock.lock()
748+ defer { lock.unlock() }
749+ current = stored
750+ writes.append(stored)
751+ }
752+}