@@ -1,8 +1,28 @@
11import XCTest
2+import Foundation
3+import Security
24@testable import HyperCreditsMenubar
35
46final class CreditsCheckerTests: XCTestCase {
57
8+ override func setUp() {
9+ super.setUp()
10+ StubURLProtocol.reset()
11+ }
12+
13+ override func tearDown() {
14+ StubURLProtocol.reset()
15+ super.tearDown()
16+ }
17+
18+ /// A checker whose requests are served by `StubURLProtocol`, with a backoff short
19+ /// enough that the retry tests run in milliseconds rather than seconds.
20+ private func makeStubbedChecker(retryBaseDelay: Duration = .milliseconds(1)) -> CreditsChecker {
21+ let config = URLSessionConfiguration.ephemeral
22+ config.protocolClasses = [StubURLProtocol.self]
23+ return CreditsChecker(session: URLSession(configuration: config), retryBaseDelay: retryBaseDelay)
24+ }
25+
626 // MARK: - BalanceResponse Decoding
727
828 func testDecodeValidBalance() throws {
@@ -35,6 +55,196 @@ final class CreditsCheckerTests: XCTestCase {
3555 XCTAssertEqual(response.balance, -5)
3656 }
3757
58+ // MARK: - Fractional balance decoding
59+
60+ func testDecodeFractionalBalanceRoundsUp() throws {
61+ let json = #"{"balance": 42.5}"#.data(using: .utf8)!
62+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
63+ XCTAssertEqual(response.balance, 43)
64+ }
65+
66+ func testDecodeFractionalBalanceRoundsDown() throws {
67+ let json = #"{"balance": 42.4}"#.data(using: .utf8)!
68+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
69+ XCTAssertEqual(response.balance, 42)
70+ }
71+
72+ func testDecodeNegativeFractionalBalanceRoundsAwayFromZero() throws {
73+ let json = #"{"balance": -2.5}"#.data(using: .utf8)!
74+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
75+ XCTAssertEqual(response.balance, -3)
76+ }
77+
78+ func testDecodeFractionalZeroBalance() throws {
79+ let json = #"{"balance": 0.2}"#.data(using: .utf8)!
80+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
81+ XCTAssertEqual(response.balance, 0)
82+ }
83+
84+ func testDecodeIntegerValuedDoubleBalance() throws {
85+ let json = #"{"balance": 100.0}"#.data(using: .utf8)!
86+ let response = try JSONDecoder().decode(BalanceResponse.self, from: json)
87+ XCTAssertEqual(response.balance, 100)
88+ }
89+
90+ func testDecodeBalanceTooLargeForIntThrows() {
91+ // Not representable as an Int, so rounding cannot save it.
92+ let json = #"{"balance": 1e30}"#.data(using: .utf8)!
93+ XCTAssertThrowsError(try JSONDecoder().decode(BalanceResponse.self, from: json))
94+ }
95+
96+ func testDecodeStringBalanceThrows() {
97+ let json = #"{"balance": "42"}"#.data(using: .utf8)!
98+ XCTAssertThrowsError(try JSONDecoder().decode(BalanceResponse.self, from: json))
99+ }
100+
101+ func testFetchBalanceRoundsFractionalResponse() async throws {
102+ StubURLProtocol.enqueue(.status(200, #"{"balance": 12.7}"#.data(using: .utf8)!))
103+
104+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
105+
106+ XCTAssertEqual(balance, 13)
107+ }
108+
109+ // MARK: - Request construction
110+
111+ func testRequestSendsNoCacheAndAuthorizationHeaders() async throws {
112+ StubURLProtocol.enqueue(.status(200, #"{"balance": 1}"#.data(using: .utf8)!))
113+
114+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
115+
116+ let request = try XCTUnwrap(StubURLProtocol.recordedRequests.first)
117+ XCTAssertEqual(request.value(forHTTPHeaderField: "Cache-Control"), "no-cache")
118+ XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer sk-test")
119+ XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json")
120+ }
121+
122+ // MARK: - Retry behaviour
123+
124+ func testRetriesOnServerErrorThenSucceeds() async throws {
125+ StubURLProtocol.enqueue(
126+ .status(500, Data()),
127+ .status(503, Data()),
128+ .status(200, #"{"balance": 7}"#.data(using: .utf8)!)
129+ )
130+
131+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
132+
133+ XCTAssertEqual(balance, 7)
134+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
135+ }
136+
137+ func testRetriesOnNetworkTimeoutThenSucceeds() async throws {
138+ StubURLProtocol.enqueue(
139+ .failure(URLError(.timedOut)),
140+ .status(200, #"{"balance": 5}"#.data(using: .utf8)!)
141+ )
142+
143+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
144+
145+ XCTAssertEqual(balance, 5)
146+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
147+ }
148+
149+ func testGivesUpAfterTwoRetries() async {
150+ // The last enqueued outcome repeats, so every attempt sees a 500.
151+ StubURLProtocol.enqueue(.status(500, Data()))
152+
153+ do {
154+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
155+ XCTFail("Expected requestFailed error")
156+ } catch CreditsError.requestFailed(let message) {
157+ XCTAssertEqual(message, "HTTP 500")
158+ } catch {
159+ XCTFail("Unexpected error: \(error)")
160+ }
161+
162+ // Initial attempt + 2 retries.
163+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
164+ }
165+
166+ func testGivesUpAfterTwoRetriesOnPersistentNetworkFailure() async {
167+ StubURLProtocol.enqueue(.failure(URLError(.networkConnectionLost)))
168+
169+ do {
170+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
171+ XCTFail("Expected requestFailed error")
172+ } catch CreditsError.requestFailed {
173+ // expected
174+ } catch {
175+ XCTFail("Unexpected error: \(error)")
176+ }
177+
178+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
179+ }
180+
181+ func testDoesNotRetryOnUnauthorized() async {
182+ StubURLProtocol.enqueue(.status(401, Data()))
183+
184+ do {
185+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-bad")
186+ XCTFail("Expected invalidAPIKey error")
187+ } catch CreditsError.invalidAPIKey {
188+ // expected
189+ } catch {
190+ XCTFail("Unexpected error: \(error)")
191+ }
192+
193+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
194+ }
195+
196+ func testDoesNotRetryOnClientError() async {
197+ StubURLProtocol.enqueue(.status(404, Data()))
198+
199+ do {
200+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
201+ XCTFail("Expected requestFailed error")
202+ } catch CreditsError.requestFailed(let message) {
203+ XCTAssertEqual(message, "HTTP 404")
204+ } catch {
205+ XCTFail("Unexpected error: \(error)")
206+ }
207+
208+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
209+ }
210+
211+ func testDoesNotRetryOnDecodingFailure() async {
212+ StubURLProtocol.enqueue(.status(200, #"{"nope": true}"#.data(using: .utf8)!))
213+
214+ do {
215+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
216+ XCTFail("Expected decodingFailed error")
217+ } catch CreditsError.decodingFailed {
218+ // expected
219+ } catch {
220+ XCTFail("Unexpected error: \(error)")
221+ }
222+
223+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
224+ }
225+
226+ func testSucceedsWithoutRetryOnFirstAttempt() async throws {
227+ StubURLProtocol.enqueue(.status(200, #"{"balance": 99}"#.data(using: .utf8)!))
228+
229+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
230+
231+ XCTAssertEqual(balance, 99)
232+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
233+ }
234+
235+ func testBackoffGrowsExponentially() async {
236+ StubURLProtocol.enqueue(.status(500, Data()))
237+
238+ let clock = ContinuousClock()
239+ let start = clock.now
240+ _ = try? await makeStubbedChecker(retryBaseDelay: .milliseconds(20)).fetchBalance(apiKey: "sk-test")
241+ let elapsed = clock.now - start
242+
243+ // Two retries wait 20ms then 40ms. Only a lower bound is asserted: sleeps can
244+ // overshoot under load, but they never return early.
245+ XCTAssertGreaterThanOrEqual(elapsed, .milliseconds(60))
246+ }
247+
38248 // MARK: - CreditsChecker Errors
39249
40250 func testEmptyAPIKeyThrowsNoAPIKey() async {
@@ -49,6 +259,11 @@ final class CreditsCheckerTests: XCTestCase {
49259 }
50260 }
51261
262+ func testEmptyAPIKeyDoesNotHitTheNetwork() async {
263+ _ = try? await makeStubbedChecker().fetchBalance(apiKey: "")
264+ XCTAssertEqual(StubURLProtocol.requestCount, 0)
265+ }
266+
52267 // MARK: - CreditsError cases
53268
54269 func testInvalidAPIKeyErrorDescription() {
@@ -92,6 +307,249 @@ final class CreditsCheckerTests: XCTestCase {
92307 }
93308}
94309
310+// MARK: - KeychainStore
311+
312+/// These tests drive `KeychainStore` with stubbed `SecItem*` calls rather than
313+/// `KeychainHelper`, which would read and write the user's real login keychain entry —
314+/// and `save()` deletes before adding, so a test run would destroy a real API key.
315+final class KeychainStoreTests: XCTestCase {
316+
317+ private func makeStore(backing: FakeKeychain) -> KeychainStore {
318+ KeychainStore(
319+ service: "com.example.tests",
320+ account: "test-account",
321+ addItem: backing.add,
322+ copyMatching: backing.copyMatching,
323+ deleteItem: backing.delete
324+ )
325+ }
326+
327+ func testSaveReturnsTrueOnSuccess() {
328+ let store = makeStore(backing: FakeKeychain())
329+ XCTAssertTrue(store.save("sk-test"))
330+ }
331+
332+ func testSaveReturnsFalseWhenItemCannotBeAdded() {
333+ let backing = FakeKeychain()
334+ backing.addStatus = errSecInteractionNotAllowed // e.g. the keychain is locked
335+ let store = makeStore(backing: backing)
336+
337+ XCTAssertFalse(store.save("sk-test"))
338+ XCTAssertNil(store.load(), "A rejected save must not leave a value behind")
339+ }
340+
341+ func testSaveReturnsFalseWhenKeychainIsUnavailable() {
342+ let backing = FakeKeychain()
343+ backing.addStatus = errSecNotAvailable
344+ XCTAssertFalse(makeStore(backing: backing).save("sk-test"))
345+ }
346+
347+ func testSaveThenLoadRoundTrips() {
348+ let store = makeStore(backing: FakeKeychain())
349+
350+ XCTAssertTrue(store.save("sk-round-trip"))
351+ XCTAssertEqual(store.load(), "sk-round-trip")
352+ }
353+
354+ func testSaveOverwritesExistingKey() {
355+ let backing = FakeKeychain()
356+ let store = makeStore(backing: backing)
357+
358+ XCTAssertTrue(store.save("sk-first"))
359+ XCTAssertTrue(store.save("sk-second"))
360+
361+ XCTAssertEqual(store.load(), "sk-second")
362+ // Overwriting means deleting first; otherwise the add would hit errSecDuplicateItem.
363+ XCTAssertEqual(backing.deleteCallCount, 2)
364+ }
365+
366+ func testSaveEmptyStringIsStored() {
367+ // An empty string is still valid UTF-8, so this is a successful save of an empty
368+ // value — not a failure. The failure path is the keychain rejecting the add.
369+ let store = makeStore(backing: FakeKeychain())
370+
371+ XCTAssertTrue(store.save(""))
372+ XCTAssertEqual(store.load(), "")
373+ }
374+
375+ func testLoadReturnsNilWhenNothingStored() {
376+ XCTAssertNil(makeStore(backing: FakeKeychain()).load())
377+ }
378+
379+ func testLoadReturnsNilWhenKeychainErrors() {
380+ let backing = FakeKeychain()
381+ backing.copyStatus = errSecInteractionNotAllowed
382+ XCTAssertNil(makeStore(backing: backing).load())
383+ }
384+
385+ func testDeleteRemovesStoredKey() {
386+ let backing = FakeKeychain()
387+ let store = makeStore(backing: backing)
388+
389+ XCTAssertTrue(store.save("sk-test"))
390+ XCTAssertTrue(store.delete())
391+ XCTAssertNil(store.load())
392+ }
393+
394+ func testDeleteOfMissingItemSucceeds() {
395+ // Deleting something that was never there is not an error.
396+ XCTAssertTrue(makeStore(backing: FakeKeychain()).delete())
397+ }
398+
399+ func testDeleteReturnsFalseOnRealFailure() {
400+ let backing = FakeKeychain()
401+ backing.deleteStatus = errSecInteractionNotAllowed
402+ XCTAssertFalse(makeStore(backing: backing).delete())
403+ }
404+}
405+
406+// MARK: - Fake keychain
407+
408+/// An in-memory stand-in for the `SecItem*` API, with overridable statuses so the failure
409+/// paths can be exercised. Never touches the real keychain.
410+final class FakeKeychain {
411+ /// When set, `add` fails with this status instead of storing.
412+ var addStatus: OSStatus?
413+ /// When set, `copyMatching` fails with this status instead of reading.
414+ var copyStatus: OSStatus?
415+ /// When set, `delete` fails with this status instead of removing.
416+ var deleteStatus: OSStatus?
417+
418+ private(set) var addCallCount = 0
419+ private(set) var deleteCallCount = 0
420+
421+ private var storage: [String: Data] = [:]
422+
423+ func add(_ query: CFDictionary, _ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus {
424+ addCallCount += 1
425+ if let addStatus { return addStatus }
426+
427+ let attributes = Self.attributes(from: query)
428+ guard let account = attributes[kSecAttrAccount as String] as? String,
429+ let data = attributes[kSecValueData as String] as? Data
430+ else { return errSecParam }
431+
432+ guard storage[account] == nil else { return errSecDuplicateItem }
433+ storage[account] = data
434+ return errSecSuccess
435+ }
436+
437+ func copyMatching(_ query: CFDictionary, _ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus {
438+ if let copyStatus { return copyStatus }
439+
440+ let attributes = Self.attributes(from: query)
441+ guard let account = attributes[kSecAttrAccount as String] as? String,
442+ let data = storage[account]
443+ else { return errSecItemNotFound }
444+
445+ result?.pointee = data as CFData
446+ return errSecSuccess
447+ }
448+
449+ func delete(_ query: CFDictionary) -> OSStatus {
450+ deleteCallCount += 1
451+ if let deleteStatus { return deleteStatus }
452+
453+ let attributes = Self.attributes(from: query)
454+ guard let account = attributes[kSecAttrAccount as String] as? String,
455+ storage.removeValue(forKey: account) != nil
456+ else { return errSecItemNotFound }
457+
458+ return errSecSuccess
459+ }
460+
461+ private static func attributes(from query: CFDictionary) -> [String: Any] {
462+ (query as NSDictionary) as? [String: Any] ?? [:]
463+ }
464+}
465+
466+// MARK: - Stub URLProtocol
467+
468+/// Serves canned responses to `URLSession` so the retry logic can be driven without a network.
469+final class StubURLProtocol: URLProtocol {
470+
471+ enum Outcome {
472+ case status(Int, Data)
473+ case failure(Error)
474+ }
475+
476+ private enum StubError: Error {
477+ case noOutcomeConfigured
478+ case missingURL
479+ }
480+
481+ private static let lock = NSLock()
482+ private static var outcomes: [Outcome] = []
483+ private static var lastOutcome: Outcome?
484+ private static var requests: [URLRequest] = []
485+
486+ /// Queues the outcomes for successive requests. Once the queue is drained the final
487+ /// outcome repeats, so a persistent failure needs only one entry.
488+ static func enqueue(_ outcomes: Outcome...) {
489+ lock.lock()
490+ defer { lock.unlock() }
491+ Self.outcomes.append(contentsOf: outcomes)
492+ }
493+
494+ static func reset() {
495+ lock.lock()
496+ defer { lock.unlock() }
497+ outcomes = []
498+ lastOutcome = nil
499+ requests = []
500+ }
501+
502+ static var recordedRequests: [URLRequest] {
503+ lock.lock()
504+ defer { lock.unlock() }
505+ return requests
506+ }
507+
508+ static var requestCount: Int {
509+ recordedRequests.count
510+ }
511+
512+ /// Records the request and pops the outcome that should answer it.
513+ private static func nextOutcome(for request: URLRequest) -> Outcome {
514+ lock.lock()
515+ defer { lock.unlock() }
516+ requests.append(request)
517+
518+ if !outcomes.isEmpty {
519+ lastOutcome = outcomes.removeFirst()
520+ }
521+ return lastOutcome ?? .failure(StubError.noOutcomeConfigured)
522+ }
523+
524+ override class func canInit(with request: URLRequest) -> Bool { true }
525+
526+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
527+
528+ override func startLoading() {
529+ switch Self.nextOutcome(for: request) {
530+ case .failure(let error):
531+ client?.urlProtocol(self, didFailWithError: error)
532+
533+ case .status(let statusCode, let body):
534+ guard let url = request.url else {
535+ client?.urlProtocol(self, didFailWithError: StubError.missingURL)
536+ return
537+ }
538+ let response = HTTPURLResponse(
539+ url: url,
540+ statusCode: statusCode,
541+ httpVersion: "HTTP/1.1",
542+ headerFields: ["Content-Type": "application/json"]
543+ )!
544+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
545+ client?.urlProtocol(self, didLoad: body)
546+ client?.urlProtocolDidFinishLoading(self)
547+ }
548+ }
549+
550+ override func stopLoading() {}
551+}
552+
95553// MARK: - Mock CreditsChecker
96554
97555/// A mock `CreditsChecking` implementation for testing the ViewModel.