@@ -1,8 +1,28 @@
1import XCTest
1import XCTest
2@testable import HyperCreditsMenubar
4@testable import HyperCreditsMenubar
4final class CreditsCheckerTests: XCTestCase {
6final class CreditsCheckerTests: XCTestCase {
8+ override func setUp() {
10+ StubURLProtocol.reset()
13+ override func tearDown() {
14+ StubURLProtocol.reset()
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)
6 // MARK: - BalanceResponse Decoding
26 // MARK: - BalanceResponse Decoding
8 func testDecodeValidBalance() throws {
28 func testDecodeValidBalance() throws {
@@ -35,6 +55,196 @@ final class CreditsCheckerTests: XCTestCase {
35 XCTAssertEqual(response.balance, -5)
55 XCTAssertEqual(response.balance, -5)
58+ // MARK: - Fractional balance decoding
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)
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)
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)
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)
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)
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))
96+ func testDecodeStringBalanceThrows() {
97+ let json = #"{"balance": "42"}"#.data(using: .utf8)!
98+ XCTAssertThrowsError(try JSONDecoder().decode(BalanceResponse.self, from: json))
101+ func testFetchBalanceRoundsFractionalResponse() async throws {
102+ StubURLProtocol.enqueue(.status(200, #"{"balance": 12.7}"#.data(using: .utf8)!))
104+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
106+ XCTAssertEqual(balance, 13)
109+ // MARK: - Request construction
111+ func testRequestSendsNoCacheAndAuthorizationHeaders() async throws {
112+ StubURLProtocol.enqueue(.status(200, #"{"balance": 1}"#.data(using: .utf8)!))
114+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
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")
122+ // MARK: - Retry behaviour
124+ func testRetriesOnServerErrorThenSucceeds() async throws {
125+ StubURLProtocol.enqueue(
126+ .status(500, Data()),
127+ .status(503, Data()),
128+ .status(200, #"{"balance": 7}"#.data(using: .utf8)!)
131+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
133+ XCTAssertEqual(balance, 7)
134+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
137+ func testRetriesOnNetworkTimeoutThenSucceeds() async throws {
138+ StubURLProtocol.enqueue(
139+ .failure(URLError(.timedOut)),
140+ .status(200, #"{"balance": 5}"#.data(using: .utf8)!)
143+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
145+ XCTAssertEqual(balance, 5)
146+ XCTAssertEqual(StubURLProtocol.requestCount, 2)
149+ func testGivesUpAfterTwoRetries() async {
150+ // The last enqueued outcome repeats, so every attempt sees a 500.
151+ StubURLProtocol.enqueue(.status(500, Data()))
154+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
155+ XCTFail("Expected requestFailed error")
156+ } catch CreditsError.requestFailed(let message) {
157+ XCTAssertEqual(message, "HTTP 500")
159+ XCTFail("Unexpected error: \(error)")
162+ // Initial attempt + 2 retries.
163+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
166+ func testGivesUpAfterTwoRetriesOnPersistentNetworkFailure() async {
167+ StubURLProtocol.enqueue(.failure(URLError(.networkConnectionLost)))
170+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
171+ XCTFail("Expected requestFailed error")
172+ } catch CreditsError.requestFailed {
175+ XCTFail("Unexpected error: \(error)")
178+ XCTAssertEqual(StubURLProtocol.requestCount, 3)
181+ func testDoesNotRetryOnUnauthorized() async {
182+ StubURLProtocol.enqueue(.status(401, Data()))
185+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-bad")
186+ XCTFail("Expected invalidAPIKey error")
187+ } catch CreditsError.invalidAPIKey {
190+ XCTFail("Unexpected error: \(error)")
193+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
196+ func testDoesNotRetryOnClientError() async {
197+ StubURLProtocol.enqueue(.status(404, Data()))
200+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
201+ XCTFail("Expected requestFailed error")
202+ } catch CreditsError.requestFailed(let message) {
203+ XCTAssertEqual(message, "HTTP 404")
205+ XCTFail("Unexpected error: \(error)")
208+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
211+ func testDoesNotRetryOnDecodingFailure() async {
212+ StubURLProtocol.enqueue(.status(200, #"{"nope": true}"#.data(using: .utf8)!))
215+ _ = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
216+ XCTFail("Expected decodingFailed error")
217+ } catch CreditsError.decodingFailed {
220+ XCTFail("Unexpected error: \(error)")
223+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
226+ func testSucceedsWithoutRetryOnFirstAttempt() async throws {
227+ StubURLProtocol.enqueue(.status(200, #"{"balance": 99}"#.data(using: .utf8)!))
229+ let balance = try await makeStubbedChecker().fetchBalance(apiKey: "sk-test")
231+ XCTAssertEqual(balance, 99)
232+ XCTAssertEqual(StubURLProtocol.requestCount, 1)
235+ func testBackoffGrowsExponentially() async {
236+ StubURLProtocol.enqueue(.status(500, Data()))
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
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))
38 // MARK: - CreditsChecker Errors
248 // MARK: - CreditsChecker Errors
40 func testEmptyAPIKeyThrowsNoAPIKey() async {
250 func testEmptyAPIKeyThrowsNoAPIKey() async {
@@ -49,6 +259,11 @@ final class CreditsCheckerTests: XCTestCase {
262+ func testEmptyAPIKeyDoesNotHitTheNetwork() async {
263+ _ = try? await makeStubbedChecker().fetchBalance(apiKey: "")
264+ XCTAssertEqual(StubURLProtocol.requestCount, 0)
52 // MARK: - CreditsError cases
267 // MARK: - CreditsError cases
54 func testInvalidAPIKeyErrorDescription() {
269 func testInvalidAPIKeyErrorDescription() {
@@ -92,6 +307,249 @@ final class CreditsCheckerTests: XCTestCase {
310+// MARK: - KeychainStore
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 {
317+ private func makeStore(backing: FakeKeychain) -> KeychainStore {
319+ service: "com.example.tests",
320+ account: "test-account",
321+ addItem: backing.add,
322+ copyMatching: backing.copyMatching,
323+ deleteItem: backing.delete
327+ func testSaveReturnsTrueOnSuccess() {
328+ let store = makeStore(backing: FakeKeychain())
329+ XCTAssertTrue(store.save("sk-test"))
332+ func testSaveReturnsFalseWhenItemCannotBeAdded() {
333+ let backing = FakeKeychain()
334+ backing.addStatus = errSecInteractionNotAllowed // e.g. the keychain is locked
335+ let store = makeStore(backing: backing)
337+ XCTAssertFalse(store.save("sk-test"))
338+ XCTAssertNil(store.load(), "A rejected save must not leave a value behind")
341+ func testSaveReturnsFalseWhenKeychainIsUnavailable() {
342+ let backing = FakeKeychain()
343+ backing.addStatus = errSecNotAvailable
344+ XCTAssertFalse(makeStore(backing: backing).save("sk-test"))
347+ func testSaveThenLoadRoundTrips() {
348+ let store = makeStore(backing: FakeKeychain())
350+ XCTAssertTrue(store.save("sk-round-trip"))
351+ XCTAssertEqual(store.load(), "sk-round-trip")
354+ func testSaveOverwritesExistingKey() {
355+ let backing = FakeKeychain()
356+ let store = makeStore(backing: backing)
358+ XCTAssertTrue(store.save("sk-first"))
359+ XCTAssertTrue(store.save("sk-second"))
361+ XCTAssertEqual(store.load(), "sk-second")
362+ // Overwriting means deleting first; otherwise the add would hit errSecDuplicateItem.
363+ XCTAssertEqual(backing.deleteCallCount, 2)
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())
371+ XCTAssertTrue(store.save(""))
372+ XCTAssertEqual(store.load(), "")
375+ func testLoadReturnsNilWhenNothingStored() {
376+ XCTAssertNil(makeStore(backing: FakeKeychain()).load())
379+ func testLoadReturnsNilWhenKeychainErrors() {
380+ let backing = FakeKeychain()
381+ backing.copyStatus = errSecInteractionNotAllowed
382+ XCTAssertNil(makeStore(backing: backing).load())
385+ func testDeleteRemovesStoredKey() {
386+ let backing = FakeKeychain()
387+ let store = makeStore(backing: backing)
389+ XCTAssertTrue(store.save("sk-test"))
390+ XCTAssertTrue(store.delete())
391+ XCTAssertNil(store.load())
394+ func testDeleteOfMissingItemSucceeds() {
395+ // Deleting something that was never there is not an error.
396+ XCTAssertTrue(makeStore(backing: FakeKeychain()).delete())
399+ func testDeleteReturnsFalseOnRealFailure() {
400+ let backing = FakeKeychain()
401+ backing.deleteStatus = errSecInteractionNotAllowed
402+ XCTAssertFalse(makeStore(backing: backing).delete())
406+// MARK: - Fake keychain
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?
418+ private(set) var addCallCount = 0
419+ private(set) var deleteCallCount = 0
421+ private var storage: [String: Data] = [:]
423+ func add(_ query: CFDictionary, _ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus {
425+ if let addStatus { return addStatus }
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 }
432+ guard storage[account] == nil else { return errSecDuplicateItem }
433+ storage[account] = data
434+ return errSecSuccess
437+ func copyMatching(_ query: CFDictionary, _ result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus {
438+ if let copyStatus { return copyStatus }
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 }
445+ result?.pointee = data as CFData
446+ return errSecSuccess
449+ func delete(_ query: CFDictionary) -> OSStatus {
450+ deleteCallCount += 1
451+ if let deleteStatus { return deleteStatus }
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 }
458+ return errSecSuccess
461+ private static func attributes(from query: CFDictionary) -> [String: Any] {
462+ (query as NSDictionary) as? [String: Any] ?? [:]
466+// MARK: - Stub URLProtocol
468+/// Serves canned responses to `URLSession` so the retry logic can be driven without a network.
469+final class StubURLProtocol: URLProtocol {
472+ case status(Int, Data)
476+ private enum StubError: Error {
477+ case noOutcomeConfigured
481+ private static let lock = NSLock()
482+ private static var outcomes: [Outcome] = []
483+ private static var lastOutcome: Outcome?
484+ private static var requests: [URLRequest] = []
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...) {
490+ defer { lock.unlock() }
491+ Self.outcomes.append(contentsOf: outcomes)
494+ static func reset() {
496+ defer { lock.unlock() }
502+ static var recordedRequests: [URLRequest] {
504+ defer { lock.unlock() }
508+ static var requestCount: Int {
509+ recordedRequests.count
512+ /// Records the request and pops the outcome that should answer it.
513+ private static func nextOutcome(for request: URLRequest) -> Outcome {
515+ defer { lock.unlock() }
516+ requests.append(request)
518+ if !outcomes.isEmpty {
519+ lastOutcome = outcomes.removeFirst()
521+ return lastOutcome ?? .failure(StubError.noOutcomeConfigured)
524+ override class func canInit(with request: URLRequest) -> Bool { true }
526+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
528+ override func startLoading() {
529+ switch Self.nextOutcome(for: request) {
530+ case .failure(let error):
531+ client?.urlProtocol(self, didFailWithError: error)
533+ case .status(let statusCode, let body):
534+ guard let url = request.url else {
535+ client?.urlProtocol(self, didFailWithError: StubError.missingURL)
538+ let response = HTTPURLResponse(
540+ statusCode: statusCode,
541+ httpVersion: "HTTP/1.1",
542+ headerFields: ["Content-Type": "application/json"]
544+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
545+ client?.urlProtocol(self, didLoad: body)
546+ client?.urlProtocolDidFinishLoading(self)
550+ override func stopLoading() {}
95// MARK: - Mock CreditsChecker
553// MARK: - Mock CreditsChecker
97/// A mock `CreditsChecking` implementation for testing the ViewModel.
555/// A mock `CreditsChecking` implementation for testing the ViewModel.