From a06e00856447de9778805a627306ac5a59765b62 Mon Sep 17 00:00:00 2001 From: Polat Olu Date: Wed, 8 Oct 2025 22:27:12 +0100 Subject: [PATCH 1/4] Cache priority and tests --- FlagsmithClient/Classes/Flagsmith.swift | 140 ++++- .../Tests/APIErrorCacheFallbackTests.swift | 535 ++++++++++++++++++ Package.swift | 2 +- 3 files changed, 673 insertions(+), 4 deletions(-) create mode 100644 FlagsmithClient/Tests/APIErrorCacheFallbackTests.swift diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index e8c0e88..4c1394f 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -148,7 +148,7 @@ public final class Flagsmith: @unchecked Sendable { self.updateFlagStreamAndLastUpdatedAt(thisIdentity.flags) completion(.success(thisIdentity.flags)) case let .failure(error): - self.handleFlagsError(error, completion: completion) + self.handleFlagsErrorForIdentity(error, identity: identity, completion: completion) } } } @@ -171,13 +171,147 @@ public final class Flagsmith: @unchecked Sendable { } private func handleFlagsError(_ error: any Error, completion: @Sendable @escaping (Result<[Flag], any Error>) -> Void) { - if defaultFlags.isEmpty { - completion(.failure(error)) + // Priority: 1. Try cached flags, 2. Fall back to default flags, 3. Return error + + // First, try to get cached flags if caching is enabled + if cacheConfig.useCache { + if let cachedFlags = getCachedFlags() { + completion(.success(cachedFlags)) + return + } + } + + // If no cached flags available, try default flags + if !defaultFlags.isEmpty { + completion(.success(defaultFlags)) } else { + completion(.failure(error)) + } + } + + private func handleFlagsErrorForIdentity(_ error: any Error, identity: String, completion: @Sendable @escaping (Result<[Flag], any Error>) -> Void) { + // Priority: 1. Try cached flags for identity, 2. Try general cached flags, 3. Fall back to default flags, 4. Return error + + // First, try to get cached flags for the specific identity if caching is enabled + if cacheConfig.useCache { + if let cachedFlags = getCachedFlags(forIdentity: identity) { + completion(.success(cachedFlags)) + return + } + + // If no identity-specific cache, try general flags cache + if let cachedFlags = getCachedFlags() { + completion(.success(cachedFlags)) + return + } + } + + // If no cached flags available, try default flags + if !defaultFlags.isEmpty { completion(.success(defaultFlags)) + } else { + completion(.failure(error)) + } + } + + private func getCachedFlags() -> [Flag]? { + let cache = cacheConfig.cache + + // Create request for general flags + let request = URLRequest(url: baseURL.appendingPathComponent("flags/")) + + // Check if we have a cached response + if let cachedResponse = cache.cachedResponse(for: request) { + // Check if cache is still valid based on TTL + if isCacheValid(cachedResponse: cachedResponse) { + do { + let flags = try JSONDecoder().decode([Flag].self, from: cachedResponse.data) + return flags + } catch { + // Cache data is corrupted, return nil + return nil + } + } } + + return nil + } + + private func getCachedFlags(forIdentity identity: String) -> [Flag]? { + let cache = cacheConfig.cache + + // Create request for identity-specific flags + let identityURL = baseURL.appendingPathComponent("identities/") + var components = URLComponents(url: identityURL, resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "identifier", value: identity)] + + guard let url = components.url else { return nil } + let request = URLRequest(url: url) + + // Check if we have a cached response + if let cachedResponse = cache.cachedResponse(for: request) { + // Check if cache is still valid based on TTL + if isCacheValid(cachedResponse: cachedResponse) { + do { + let identity = try JSONDecoder().decode(Identity.self, from: cachedResponse.data) + return identity.flags + } catch { + // Cache data is corrupted, return nil + return nil + } + } + } + + return nil } + + private func isCacheValid(cachedResponse: CachedURLResponse) -> Bool { + guard let httpResponse = cachedResponse.response as? HTTPURLResponse else { return false } + + // Check if we have a cache control header + if let cacheControl = httpResponse.allHeaderFields["Cache-Control"] as? String { + if let maxAge = extractMaxAge(from: cacheControl) { + // Check if cache is still valid based on max-age + if let dateString = httpResponse.allHeaderFields["Date"] as? String, + let date = HTTPURLResponse.dateFormatter.date(from: dateString) { + let age = Date().timeIntervalSince(date) + return age < maxAge + } + } + } + + // If no cache control, assume valid for the configured TTL + return true + } + + private func extractMaxAge(from cacheControl: String) -> TimeInterval? { + let components = cacheControl.split(separator: ",") + for component in components { + let trimmed = component.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("max-age=") { + let maxAgeString = String(trimmed.dropFirst(8)) + return TimeInterval(maxAgeString) + } + } + return nil + } +} + +// MARK: - HTTPURLResponse Extensions + +extension HTTPURLResponse { + static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(abbreviation: "GMT") + return formatter + }() +} + +// MARK: - Public API Methods +extension Flagsmith { /// Check feature exists and is enabled optionally for a specific identity /// /// - Parameters: diff --git a/FlagsmithClient/Tests/APIErrorCacheFallbackTests.swift b/FlagsmithClient/Tests/APIErrorCacheFallbackTests.swift new file mode 100644 index 0000000..8e971d4 --- /dev/null +++ b/FlagsmithClient/Tests/APIErrorCacheFallbackTests.swift @@ -0,0 +1,535 @@ +// +// APIErrorCacheFallbackTests.swift +// FlagsmithClientTests +// +// Tests for API error scenarios with cache fallback behavior +// Customer requirement: "When fetching flags and we run into an error and have a valid cache we should return the cached flags" +// + +@testable import FlagsmithClient +import XCTest + +final class APIErrorCacheFallbackTests: FlagsmithClientTestCase { + var testCache: URLCache! + + override func setUp() { + super.setUp() + + // Create isolated cache for testing + testCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024, directory: nil) + + // Reset Flagsmith to known state using TestConfig + Flagsmith.shared.apiKey = TestConfig.hasRealApiKey ? TestConfig.apiKey : "mock-test-api-key" + Flagsmith.shared.baseURL = TestConfig.baseURL + Flagsmith.shared.enableRealtimeUpdates = false + Flagsmith.shared.cacheConfig.useCache = true + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.cacheConfig.cache = testCache + Flagsmith.shared.cacheConfig.cacheTTL = 300 + Flagsmith.shared.defaultFlags = [] + } + + override func tearDown() { + testCache.removeAllCachedResponses() + Flagsmith.shared.cacheConfig.useCache = false + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.apiKey = nil + super.tearDown() + } + + // MARK: - Test Helper Methods + + private func extractStringValue(from typedValue: TypedValue?) -> String? { + guard let typedValue = typedValue else { return nil } + switch typedValue { + case .string(let value): + return value + case .int(let value): + return String(value) + case .bool(let value): + return String(value) + case .float(let value): + return String(value) + case .null: + return nil + } + } + + private func createMockCachedResponse(for request: URLRequest, with flags: [Flag]) -> CachedURLResponse { + let jsonData = try! JSONEncoder().encode(flags) + let httpResponse = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Cache-Control": "max-age=300" + ] + )! + return CachedURLResponse(response: httpResponse, data: jsonData) + } + + private func createMockIdentityCachedResponse(for request: URLRequest, with identity: Identity) -> CachedURLResponse { + // Create JSON manually since Identity doesn't conform to Encodable + let jsonString = """ + { + "identifier": "test-user-123", + "traits": [], + "flags": [ + { + "id": 1, + "feature": { + "id": 1, + "name": "\(identity.flags.first?.feature.name ?? "test_feature")", + "type": "FLAG" + }, + "enabled": \(identity.flags.first?.enabled ?? true), + "feature_state_value": "\(extractStringValue(from: identity.flags.first?.value) ?? "test_value")" + } + ] + } + """ + let jsonData = jsonString.data(using: .utf8)! + let httpResponse = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Cache-Control": "max-age=300" + ] + )! + return CachedURLResponse(response: httpResponse, data: jsonData) + } + + // MARK: - Core API Error Cache Fallback Tests + + func testGetFeatureFlags_APIFailure_ReturnsCachedFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "API failure with cache fallback") + + // Create mock flags for cache + let cachedFlags = [ + Flag(featureName: "cached_feature_1", value: .string("cached_value_1"), enabled: true, featureType: "FLAG"), + Flag(featureName: "cached_feature_2", value: .string("cached_value_2"), enabled: false, featureType: "FLAG") + ] + + // Pre-populate cache with successful response + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Mock API failure by using invalid API key + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached flags + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return cached flags + XCTAssertEqual(flags.count, 2, "Should return cached flags") + XCTAssertEqual(flags.first?.feature.name, "cached_feature_1", "Should return first cached flag") + XCTAssertEqual(flags.last?.feature.name, "cached_feature_2", "Should return second cached flag") + case .failure(let error): + XCTFail("Should return cached flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testGetFeatureFlags_APIFailure_NoCache_ReturnsDefaultFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "API failure with no cache, default flags fallback") + + // Set up default flags + let defaultFlags = [ + Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") + ] + Flagsmith.shared.defaultFlags = defaultFlags + + // Ensure no cache exists + testCache.removeAllCachedResponses() + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call and return default flags + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return default flags + XCTAssertEqual(flags.count, 1, "Should return default flags") + XCTAssertEqual(flags.first?.feature.name, "default_feature", "Should return default flag") + case .failure(let error): + XCTFail("Should return default flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testGetFeatureFlags_APIFailure_NoCacheNoDefaults_ReturnsError() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "API failure with no cache and no defaults") + + // Ensure no cache and no defaults + testCache.removeAllCachedResponses() + Flagsmith.shared.defaultFlags = [] + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(_): + XCTFail("Should fail when no cache and no defaults") + case .failure(let error): + // Should return the original API error + XCTAssertTrue(error is FlagsmithError, "Should return FlagsmithError") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + // MARK: - Identity-Specific Cache Fallback Tests + + func testGetFeatureFlagsForIdentity_APIFailure_ReturnsCachedFlags() throws { + // Skip if no real API key since this test needs to make real network calls + guard TestConfig.hasRealApiKey else { + throw XCTSkip("Requires real API key for identity testing") + } + + let expectation = expectation(description: "API failure with identity, cache fallback") + + let testIdentity = TestConfig.testIdentity + let cachedFlags = [ + Flag(featureName: "user_feature", value: .string("user_value"), enabled: true, featureType: "FLAG") + ] + let cachedIdentity = Identity(flags: cachedFlags, traits: [], transient: false) + + // Pre-populate cache with successful identity response + let identityURL = TestConfig.baseURL.appendingPathComponent("identities/") + var components = URLComponents(url: identityURL, resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "identifier", value: testIdentity)] + var mockRequest = URLRequest(url: components.url!) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = createMockIdentityCachedResponse(for: mockRequest, with: cachedIdentity) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached flags + Flagsmith.shared.getFeatureFlags(forIdentity: testIdentity) { result in + switch result { + case .success(let flags): + // Should return cached flags + XCTAssertEqual(flags.count, 1, "Should return cached flags") + XCTAssertEqual(flags.first?.feature.name, "user_feature", "Should return cached user flag") + case .failure(let error): + XCTFail("Should return cached flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + // MARK: - Feature-Specific Method Cache Fallback Tests + + func testHasFeatureFlag_APIFailure_ReturnsCachedResult() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "hasFeatureFlag API failure with cache fallback") + + let testFeature = "test_feature" + let cachedFlags = [ + Flag(featureName: testFeature, value: .string("test_value"), enabled: true, featureType: "FLAG") + ] + + // Pre-populate cache + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached result + Flagsmith.shared.hasFeatureFlag(withID: testFeature) { result in + switch result { + case .success(let hasFlag): + // Should return cached result + XCTAssertTrue(hasFlag, "Should return cached enabled state") + case .failure(let error): + XCTFail("Should return cached result instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testGetValueForFeature_APIFailure_ReturnsCachedValue() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "getValueForFeature API failure with cache fallback") + + let testFeature = "test_feature" + let testValue = "cached_value" + let cachedFlags = [ + Flag(featureName: testFeature, value: .string(testValue), enabled: true, featureType: "FLAG") + ] + + // Pre-populate cache + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached value + Flagsmith.shared.getValueForFeature(withID: testFeature) { result in + switch result { + case .success(let value): + // Should return cached value + XCTAssertNotNil(value, "Should return cached value") + if case .string(let stringValue) = value { + XCTAssertEqual(stringValue, testValue, "Should return cached string value") + } else { + XCTFail("Expected string value") + } + case .failure(let error): + XCTFail("Should return cached value instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + // MARK: - Cache TTL and Expiration Tests + + func testCacheFallback_ExpiredCache_ReturnsDefaultFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "Expired cache with default flags fallback") + + // Create expired cache entry (simulate by using old timestamp) + let cachedFlags = [ + Flag(featureName: "expired_feature", value: .string("expired_value"), enabled: true, featureType: "FLAG") + ] + + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + + // Create expired response (simulate by setting old date) + let expiredDate = Date().addingTimeInterval(-400) // 400 seconds ago (beyond 300s TTL) + let httpResponse = HTTPURLResponse( + url: mockRequest.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Cache-Control": "max-age=300", + "Date": "Mon, 01 Jan 2024 00:00:00 GMT" + ] + )! + + let jsonData = try! JSONEncoder().encode(cachedFlags) + let expiredCachedResponse = CachedURLResponse(response: httpResponse, data: jsonData) + testCache.storeCachedResponse(expiredCachedResponse, for: mockRequest) + + // Set up default flags + let defaultFlags = [ + Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") + ] + Flagsmith.shared.defaultFlags = defaultFlags + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call and return default flags (not expired cache) + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return default flags, not expired cache + XCTAssertEqual(flags.count, 1, "Should return default flags") + XCTAssertEqual(flags.first?.feature.name, "default_feature", "Should return default flag, not expired cache") + case .failure(let error): + XCTFail("Should return default flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + // MARK: - Different Error Type Tests + + func testCacheFallback_NetworkError_ReturnsCachedFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "Network error with cache fallback") + + // Create cached flags + let cachedFlags = [ + Flag(featureName: "network_cached_feature", value: .string("network_cached_value"), enabled: true, featureType: "FLAG") + ] + + // Pre-populate cache + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Simulate network error by using invalid API key (this will cause API failure) + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached flags + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return cached flags + XCTAssertEqual(flags.count, 1, "Should return cached flags") + XCTAssertEqual(flags.first?.feature.name, "network_cached_feature", "Should return cached flag") + case .failure(let error): + XCTFail("Should return cached flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testCacheFallback_ServerError_ReturnsCachedFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "Server error with cache fallback") + + // Create cached flags + let cachedFlags = [ + Flag(featureName: "server_cached_feature", value: .string("server_cached_value"), enabled: true, featureType: "FLAG") + ] + + // Pre-populate cache + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Simulate server error by using invalid API key (this will cause API failure) + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached flags + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return cached flags + XCTAssertEqual(flags.count, 1, "Should return cached flags") + XCTAssertEqual(flags.first?.feature.name, "server_cached_feature", "Should return cached flag") + case .failure(let error): + XCTFail("Should return cached flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + // MARK: - Cache Priority Tests (cache > defaults > error) + + func testCacheFallback_Priority_CacheOverDefaults() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "Cache priority over defaults") + + // Set up both cache and defaults + let cachedFlags = [ + Flag(featureName: "cached_feature", value: .string("cached_value"), enabled: true, featureType: "FLAG") + ] + let defaultFlags = [ + Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") + ] + + // Pre-populate cache + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Set up defaults + Flagsmith.shared.defaultFlags = defaultFlags + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should return cached flags, not default flags + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return cached flags, not defaults + XCTAssertEqual(flags.count, 1, "Should return cached flags") + XCTAssertEqual(flags.first?.feature.name, "cached_feature", "Should return cached flag, not default") + case .failure(let error): + XCTFail("Should return cached flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + // MARK: - Edge Case Tests + + func testCacheFallback_CorruptedCache_ReturnsDefaultFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "Corrupted cache with default flags fallback") + + // Create corrupted cache entry + let corruptedData = "invalid json data".data(using: .utf8)! + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + + let httpResponse = HTTPURLResponse( + url: mockRequest.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Cache-Control": "max-age=300" + ] + )! + + let corruptedCachedResponse = CachedURLResponse(response: httpResponse, data: corruptedData) + testCache.storeCachedResponse(corruptedCachedResponse, for: mockRequest) + + // Set up default flags + let defaultFlags = [ + Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") + ] + Flagsmith.shared.defaultFlags = defaultFlags + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call and return default flags (not corrupted cache) + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return default flags, not corrupted cache + XCTAssertEqual(flags.count, 1, "Should return default flags") + XCTAssertEqual(flags.first?.feature.name, "default_feature", "Should return default flag, not corrupted cache") + case .failure(let error): + XCTFail("Should return default flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } +} diff --git a/Package.swift b/Package.swift index 2e4d979..ef3da52 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,7 @@ let package = Package( .enableUpcomingFeature("ExistentialAny"), // https://github.com/apple/swift-evolution/blob/main/proposals/0335-existential-any.md ]), .testTarget( - name: "FlagsmitClientTests", + name: "FlagsmithClientTests", dependencies: ["FlagsmithClient"], path: "FlagsmithClient/Tests", exclude: ["README_Testing.md"], From 9663216064fbdef4840fc9e0f21359ce7b76bbba Mon Sep 17 00:00:00 2001 From: Polat Olu Date: Thu, 16 Oct 2025 23:33:52 +0100 Subject: [PATCH 2/4] Linter and Test issue fixes --- FlagsmithClient/Classes/Flagsmith.swift | 17 + .../APIErrorCacheFallbackCoreTests.swift | 151 +++++ .../APIErrorCacheFallbackEdgeCaseTests.swift | 87 +++ .../APIErrorCacheFallbackErrorTests.swift | 124 ++++ .../APIErrorCacheFallbackFeatureTests.swift | 128 +++++ .../APIErrorCacheFallbackIdentityTests.swift | 135 +++++ .../APIErrorCacheFallbackPriorityTests.swift | 96 ++++ .../Tests/APIErrorCacheFallbackTTLTests.swift | 94 +++ .../Tests/APIErrorCacheFallbackTests.swift | 535 ------------------ 9 files changed, 832 insertions(+), 535 deletions(-) create mode 100644 FlagsmithClient/Tests/APIErrorCacheFallbackCoreTests.swift create mode 100644 FlagsmithClient/Tests/APIErrorCacheFallbackEdgeCaseTests.swift create mode 100644 FlagsmithClient/Tests/APIErrorCacheFallbackErrorTests.swift create mode 100644 FlagsmithClient/Tests/APIErrorCacheFallbackFeatureTests.swift create mode 100644 FlagsmithClient/Tests/APIErrorCacheFallbackIdentityTests.swift create mode 100644 FlagsmithClient/Tests/APIErrorCacheFallbackPriorityTests.swift create mode 100644 FlagsmithClient/Tests/APIErrorCacheFallbackTTLTests.swift delete mode 100644 FlagsmithClient/Tests/APIErrorCacheFallbackTests.swift diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index 4c1394f..ff5f354 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -270,6 +270,11 @@ public final class Flagsmith: @unchecked Sendable { // Check if we have a cache control header if let cacheControl = httpResponse.allHeaderFields["Cache-Control"] as? String { + // First check for no-cache and no-store directives (case-insensitive, token-aware) + if hasNoCacheDirective(in: cacheControl) { + return false + } + if let maxAge = extractMaxAge(from: cacheControl) { // Check if cache is still valid based on max-age if let dateString = httpResponse.allHeaderFields["Date"] as? String, @@ -295,6 +300,18 @@ public final class Flagsmith: @unchecked Sendable { } return nil } + + private func hasNoCacheDirective(in cacheControl: String) -> Bool { + let components = cacheControl.split(separator: ",") + for component in components { + let trimmed = component.trimmingCharacters(in: .whitespaces) + let directive = trimmed.lowercased() + if directive == "no-cache" || directive == "no-store" { + return true + } + } + return false + } } // MARK: - HTTPURLResponse Extensions diff --git a/FlagsmithClient/Tests/APIErrorCacheFallbackCoreTests.swift b/FlagsmithClient/Tests/APIErrorCacheFallbackCoreTests.swift new file mode 100644 index 0000000..1964fed --- /dev/null +++ b/FlagsmithClient/Tests/APIErrorCacheFallbackCoreTests.swift @@ -0,0 +1,151 @@ +// +// APIErrorCacheFallbackCoreTests.swift +// FlagsmithClientTests +// +// Core API error scenarios with cache fallback behavior +// Customer requirement: "When fetching flags and we run into an error and have a valid cache we should return the cached flags" +// + +@testable import FlagsmithClient +import XCTest + +final class APIErrorCacheFallbackCoreTests: FlagsmithClientTestCase { + var testCache: URLCache! + + override func setUp() { + super.setUp() + + // Create isolated cache for testing + testCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024, directory: nil) + + // Reset Flagsmith to known state using TestConfig + Flagsmith.shared.apiKey = TestConfig.hasRealApiKey ? TestConfig.apiKey : "mock-test-api-key" + Flagsmith.shared.baseURL = TestConfig.baseURL + Flagsmith.shared.enableRealtimeUpdates = false + Flagsmith.shared.cacheConfig.useCache = true + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.cacheConfig.cache = testCache + Flagsmith.shared.cacheConfig.cacheTTL = 300 + Flagsmith.shared.defaultFlags = [] + } + + override func tearDown() { + testCache.removeAllCachedResponses() + Flagsmith.shared.cacheConfig.useCache = false + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.apiKey = nil + super.tearDown() + } + + // MARK: - Test Helper Methods + + private func createMockCachedResponse(for request: URLRequest, with flags: [Flag]) throws -> CachedURLResponse { + let jsonData = try JSONEncoder().encode(flags) + let httpResponse = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Cache-Control": "max-age=300" + ] + )! + return CachedURLResponse(response: httpResponse, data: jsonData) + } + + // MARK: - Core API Error Cache Fallback Tests + + func testGetFeatureFlags_APIFailure_ReturnsCachedFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "API failure with cache fallback") + + // Create mock flags for cache + let cachedFlags = [ + Flag(featureName: "cached_feature_1", value: .string("cached_value_1"), enabled: true, featureType: "FLAG"), + Flag(featureName: "cached_feature_2", value: .string("cached_value_2"), enabled: false, featureType: "FLAG") + ] + + // Pre-populate cache with successful response + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = try createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Mock API failure by using invalid API key + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached flags + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return cached flags + XCTAssertEqual(flags.count, 2, "Should return cached flags") + XCTAssertEqual(flags.first?.feature.name, "cached_feature_1", "Should return first cached flag") + XCTAssertEqual(flags.last?.feature.name, "cached_feature_2", "Should return second cached flag") + case .failure(let error): + XCTFail("Should return cached flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testGetFeatureFlags_APIFailure_NoCache_ReturnsDefaultFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "API failure with no cache, default flags fallback") + + // Set up default flags + let defaultFlags = [ + Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") + ] + Flagsmith.shared.defaultFlags = defaultFlags + + // Ensure no cache exists + testCache.removeAllCachedResponses() + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call and return default flags + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return default flags + XCTAssertEqual(flags.count, 1, "Should return default flags") + XCTAssertEqual(flags.first?.feature.name, "default_feature", "Should return default flag") + case .failure(let error): + XCTFail("Should return default flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testGetFeatureFlags_APIFailure_NoCacheNoDefaults_ReturnsError() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "API failure with no cache and no defaults") + + // Ensure no cache and no defaults + testCache.removeAllCachedResponses() + Flagsmith.shared.defaultFlags = [] + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(_): + XCTFail("Should fail when no cache and no defaults") + case .failure(let error): + // Should return the original API error + XCTAssertTrue(error is FlagsmithError, "Should return FlagsmithError") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } +} diff --git a/FlagsmithClient/Tests/APIErrorCacheFallbackEdgeCaseTests.swift b/FlagsmithClient/Tests/APIErrorCacheFallbackEdgeCaseTests.swift new file mode 100644 index 0000000..d412e1e --- /dev/null +++ b/FlagsmithClient/Tests/APIErrorCacheFallbackEdgeCaseTests.swift @@ -0,0 +1,87 @@ +// +// APIErrorCacheFallbackEdgeCaseTests.swift +// FlagsmithClientTests +// +// Edge case API error scenarios with cache fallback behavior +// + +@testable import FlagsmithClient +import XCTest + +final class APIErrorCacheFallbackEdgeCaseTests: FlagsmithClientTestCase { + var testCache: URLCache! + + override func setUp() { + super.setUp() + + // Create isolated cache for testing + testCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024, directory: nil) + + // Reset Flagsmith to known state using TestConfig + Flagsmith.shared.apiKey = TestConfig.hasRealApiKey ? TestConfig.apiKey : "mock-test-api-key" + Flagsmith.shared.baseURL = TestConfig.baseURL + Flagsmith.shared.enableRealtimeUpdates = false + Flagsmith.shared.cacheConfig.useCache = true + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.cacheConfig.cache = testCache + Flagsmith.shared.cacheConfig.cacheTTL = 300 + Flagsmith.shared.defaultFlags = [] + } + + override func tearDown() { + testCache.removeAllCachedResponses() + Flagsmith.shared.cacheConfig.useCache = false + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.apiKey = nil + super.tearDown() + } + + // MARK: - Edge Case Tests + + func testCacheFallback_CorruptedCache_ReturnsDefaultFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "Corrupted cache with default flags fallback") + + // Create corrupted cache entry + let corruptedData = "invalid json data".data(using: .utf8)! + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + + let httpResponse = HTTPURLResponse( + url: mockRequest.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Cache-Control": "max-age=300" + ] + )! + + let corruptedCachedResponse = CachedURLResponse(response: httpResponse, data: corruptedData) + testCache.storeCachedResponse(corruptedCachedResponse, for: mockRequest) + + // Set up default flags + let defaultFlags = [ + Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") + ] + Flagsmith.shared.defaultFlags = defaultFlags + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call and return default flags (not corrupted cache) + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return default flags, not corrupted cache + XCTAssertEqual(flags.count, 1, "Should return default flags") + XCTAssertEqual(flags.first?.feature.name, "default_feature", "Should return default flag, not corrupted cache") + case .failure(let error): + XCTFail("Should return default flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } +} diff --git a/FlagsmithClient/Tests/APIErrorCacheFallbackErrorTests.swift b/FlagsmithClient/Tests/APIErrorCacheFallbackErrorTests.swift new file mode 100644 index 0000000..3216044 --- /dev/null +++ b/FlagsmithClient/Tests/APIErrorCacheFallbackErrorTests.swift @@ -0,0 +1,124 @@ +// +// APIErrorCacheFallbackErrorTests.swift +// FlagsmithClientTests +// +// Different error type API error scenarios with cache fallback behavior +// + +@testable import FlagsmithClient +import XCTest + +final class APIErrorCacheFallbackErrorTests: FlagsmithClientTestCase { + var testCache: URLCache! + + override func setUp() { + super.setUp() + + // Create isolated cache for testing + testCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024, directory: nil) + + // Reset Flagsmith to known state using TestConfig + Flagsmith.shared.apiKey = TestConfig.hasRealApiKey ? TestConfig.apiKey : "mock-test-api-key" + Flagsmith.shared.baseURL = TestConfig.baseURL + Flagsmith.shared.enableRealtimeUpdates = false + Flagsmith.shared.cacheConfig.useCache = true + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.cacheConfig.cache = testCache + Flagsmith.shared.cacheConfig.cacheTTL = 300 + Flagsmith.shared.defaultFlags = [] + } + + override func tearDown() { + testCache.removeAllCachedResponses() + Flagsmith.shared.cacheConfig.useCache = false + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.apiKey = nil + super.tearDown() + } + + // MARK: - Test Helper Methods + + private func createMockCachedResponse(for request: URLRequest, with flags: [Flag]) throws -> CachedURLResponse { + let jsonData = try JSONEncoder().encode(flags) + let httpResponse = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Cache-Control": "max-age=300" + ] + )! + return CachedURLResponse(response: httpResponse, data: jsonData) + } + + // MARK: - Different Error Type Tests + + func testCacheFallback_NetworkError_ReturnsCachedFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "Network error with cache fallback") + + // Create cached flags + let cachedFlags = [ + Flag(featureName: "network_cached_feature", value: .string("network_cached_value"), enabled: true, featureType: "FLAG") + ] + + // Pre-populate cache + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = try createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Simulate network error by using invalid API key (this will cause API failure) + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached flags + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return cached flags + XCTAssertEqual(flags.count, 1, "Should return cached flags") + XCTAssertEqual(flags.first?.feature.name, "network_cached_feature", "Should return cached flag") + case .failure(let error): + XCTFail("Should return cached flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testCacheFallback_ServerError_ReturnsCachedFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "Server error with cache fallback") + + // Create cached flags + let cachedFlags = [ + Flag(featureName: "server_cached_feature", value: .string("server_cached_value"), enabled: true, featureType: "FLAG") + ] + + // Pre-populate cache + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = try createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Simulate server error by using invalid API key (this will cause API failure) + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached flags + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return cached flags + XCTAssertEqual(flags.count, 1, "Should return cached flags") + XCTAssertEqual(flags.first?.feature.name, "server_cached_feature", "Should return cached flag") + case .failure(let error): + XCTFail("Should return cached flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } +} diff --git a/FlagsmithClient/Tests/APIErrorCacheFallbackFeatureTests.swift b/FlagsmithClient/Tests/APIErrorCacheFallbackFeatureTests.swift new file mode 100644 index 0000000..32cb7ae --- /dev/null +++ b/FlagsmithClient/Tests/APIErrorCacheFallbackFeatureTests.swift @@ -0,0 +1,128 @@ +// +// APIErrorCacheFallbackFeatureTests.swift +// FlagsmithClientTests +// +// Feature-specific method API error scenarios with cache fallback behavior +// + +@testable import FlagsmithClient +import XCTest + +final class APIErrorCacheFallbackFeatureTests: FlagsmithClientTestCase { + var testCache: URLCache! + + override func setUp() { + super.setUp() + + // Create isolated cache for testing + testCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024, directory: nil) + + // Reset Flagsmith to known state using TestConfig + Flagsmith.shared.apiKey = TestConfig.hasRealApiKey ? TestConfig.apiKey : "mock-test-api-key" + Flagsmith.shared.baseURL = TestConfig.baseURL + Flagsmith.shared.enableRealtimeUpdates = false + Flagsmith.shared.cacheConfig.useCache = true + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.cacheConfig.cache = testCache + Flagsmith.shared.cacheConfig.cacheTTL = 300 + Flagsmith.shared.defaultFlags = [] + } + + override func tearDown() { + testCache.removeAllCachedResponses() + Flagsmith.shared.cacheConfig.useCache = false + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.apiKey = nil + super.tearDown() + } + + // MARK: - Test Helper Methods + + private func createMockCachedResponse(for request: URLRequest, with flags: [Flag]) throws -> CachedURLResponse { + let jsonData = try JSONEncoder().encode(flags) + let httpResponse = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Cache-Control": "max-age=300" + ] + )! + return CachedURLResponse(response: httpResponse, data: jsonData) + } + + // MARK: - Feature-Specific Method Cache Fallback Tests + + func testHasFeatureFlag_APIFailure_ReturnsCachedResult() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "hasFeatureFlag API failure with cache fallback") + + let testFeature = "test_feature" + let cachedFlags = [ + Flag(featureName: testFeature, value: .string("test_value"), enabled: true, featureType: "FLAG") + ] + + // Pre-populate cache + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = try createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached result + Flagsmith.shared.hasFeatureFlag(withID: testFeature) { result in + switch result { + case .success(let hasFlag): + // Should return cached result + XCTAssertTrue(hasFlag, "Should return cached enabled state") + case .failure(let error): + XCTFail("Should return cached result instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } + + func testGetValueForFeature_APIFailure_ReturnsCachedValue() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "getValueForFeature API failure with cache fallback") + + let testFeature = "test_feature" + let testValue = "cached_value" + let cachedFlags = [ + Flag(featureName: testFeature, value: .string(testValue), enabled: true, featureType: "FLAG") + ] + + // Pre-populate cache + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = try createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached value + Flagsmith.shared.getValueForFeature(withID: testFeature) { result in + switch result { + case .success(let value): + // Should return cached value + XCTAssertNotNil(value, "Should return cached value") + if case .string(let stringValue) = value { + XCTAssertEqual(stringValue, testValue, "Should return cached string value") + } else { + XCTFail("Expected string value") + } + case .failure(let error): + XCTFail("Should return cached value instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } +} diff --git a/FlagsmithClient/Tests/APIErrorCacheFallbackIdentityTests.swift b/FlagsmithClient/Tests/APIErrorCacheFallbackIdentityTests.swift new file mode 100644 index 0000000..2d9e23a --- /dev/null +++ b/FlagsmithClient/Tests/APIErrorCacheFallbackIdentityTests.swift @@ -0,0 +1,135 @@ +// +// APIErrorCacheFallbackIdentityTests.swift +// FlagsmithClientTests +// +// Identity-specific API error scenarios with cache fallback behavior +// + +@testable import FlagsmithClient +import XCTest + +final class APIErrorCacheFallbackIdentityTests: FlagsmithClientTestCase { + var testCache: URLCache! + + override func setUp() { + super.setUp() + + // Create isolated cache for testing + testCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024, directory: nil) + + // Reset Flagsmith to known state using TestConfig + Flagsmith.shared.apiKey = TestConfig.hasRealApiKey ? TestConfig.apiKey : "mock-test-api-key" + Flagsmith.shared.baseURL = TestConfig.baseURL + Flagsmith.shared.enableRealtimeUpdates = false + Flagsmith.shared.cacheConfig.useCache = true + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.cacheConfig.cache = testCache + Flagsmith.shared.cacheConfig.cacheTTL = 300 + Flagsmith.shared.defaultFlags = [] + } + + override func tearDown() { + testCache.removeAllCachedResponses() + Flagsmith.shared.cacheConfig.useCache = false + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.apiKey = nil + super.tearDown() + } + + // MARK: - Test Helper Methods + + private func extractStringValue(from typedValue: TypedValue?) -> String? { + guard let typedValue = typedValue else { return nil } + switch typedValue { + case .string(let value): + return value + case .int(let value): + return String(value) + case .bool(let value): + return String(value) + case .float(let value): + return String(value) + case .null: + return nil + } + } + + private func createMockIdentityCachedResponse(for request: URLRequest, with identity: Identity) throws -> CachedURLResponse { + // Create JSON manually since Identity doesn't conform to Encodable + let jsonString = """ + { + "identifier": "test-user-123", + "traits": [], + "flags": [ + { + "id": 1, + "feature": { + "id": 1, + "name": "\(identity.flags.first?.feature.name ?? "test_feature")", + "type": "FLAG" + }, + "enabled": \(identity.flags.first?.enabled ?? true), + "feature_state_value": "\(extractStringValue(from: identity.flags.first?.value) ?? "test_value")" + } + ] + } + """ + guard let jsonData = jsonString.data(using: .utf8) else { + throw NSError(domain: "TestError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to create JSON data"]) + } + let httpResponse = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Cache-Control": "max-age=300" + ] + )! + return CachedURLResponse(response: httpResponse, data: jsonData) + } + + // MARK: - Identity-Specific Cache Fallback Tests + + func testGetFeatureFlagsForIdentity_APIFailure_ReturnsCachedFlags() throws { + // Skip if no real API key since this test needs to make real network calls + guard TestConfig.hasRealApiKey else { + throw XCTSkip("Requires real API key for identity testing") + } + + let expectation = expectation(description: "API failure with identity, cache fallback") + + let testIdentity = TestConfig.testIdentity + let cachedFlags = [ + Flag(featureName: "user_feature", value: .string("user_value"), enabled: true, featureType: "FLAG") + ] + let cachedIdentity = Identity(flags: cachedFlags, traits: [], transient: false) + + // Pre-populate cache with successful identity response + let identityURL = TestConfig.baseURL.appendingPathComponent("identities/") + var components = URLComponents(url: identityURL, resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "identifier", value: testIdentity)] + var mockRequest = URLRequest(url: components.url!) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = try createMockIdentityCachedResponse(for: mockRequest, with: cachedIdentity) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call but return cached flags + Flagsmith.shared.getFeatureFlags(forIdentity: testIdentity) { result in + switch result { + case .success(let flags): + // Should return cached flags + XCTAssertEqual(flags.count, 1, "Should return cached flags") + XCTAssertEqual(flags.first?.feature.name, "user_feature", "Should return cached user flag") + case .failure(let error): + XCTFail("Should return cached flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } +} diff --git a/FlagsmithClient/Tests/APIErrorCacheFallbackPriorityTests.swift b/FlagsmithClient/Tests/APIErrorCacheFallbackPriorityTests.swift new file mode 100644 index 0000000..648d570 --- /dev/null +++ b/FlagsmithClient/Tests/APIErrorCacheFallbackPriorityTests.swift @@ -0,0 +1,96 @@ +// +// APIErrorCacheFallbackPriorityTests.swift +// FlagsmithClientTests +// +// Cache priority API error scenarios with cache fallback behavior +// + +@testable import FlagsmithClient +import XCTest + +final class APIErrorCacheFallbackPriorityTests: FlagsmithClientTestCase { + var testCache: URLCache! + + override func setUp() { + super.setUp() + + // Create isolated cache for testing + testCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024, directory: nil) + + // Reset Flagsmith to known state using TestConfig + Flagsmith.shared.apiKey = TestConfig.hasRealApiKey ? TestConfig.apiKey : "mock-test-api-key" + Flagsmith.shared.baseURL = TestConfig.baseURL + Flagsmith.shared.enableRealtimeUpdates = false + Flagsmith.shared.cacheConfig.useCache = true + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.cacheConfig.cache = testCache + Flagsmith.shared.cacheConfig.cacheTTL = 300 + Flagsmith.shared.defaultFlags = [] + } + + override func tearDown() { + testCache.removeAllCachedResponses() + Flagsmith.shared.cacheConfig.useCache = false + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.apiKey = nil + super.tearDown() + } + + // MARK: - Test Helper Methods + + private func createMockCachedResponse(for request: URLRequest, with flags: [Flag]) throws -> CachedURLResponse { + let jsonData = try JSONEncoder().encode(flags) + let httpResponse = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Cache-Control": "max-age=300" + ] + )! + return CachedURLResponse(response: httpResponse, data: jsonData) + } + + // MARK: - Cache Priority Tests (cache > defaults > error) + + func testCacheFallback_Priority_CacheOverDefaults() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "Cache priority over defaults") + + // Set up both cache and defaults + let cachedFlags = [ + Flag(featureName: "cached_feature", value: .string("cached_value"), enabled: true, featureType: "FLAG") + ] + let defaultFlags = [ + Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") + ] + + // Pre-populate cache + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + let cachedResponse = try createMockCachedResponse(for: mockRequest, with: cachedFlags) + testCache.storeCachedResponse(cachedResponse, for: mockRequest) + + // Set up defaults + Flagsmith.shared.defaultFlags = defaultFlags + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should return cached flags, not default flags + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return cached flags, not defaults + XCTAssertEqual(flags.count, 1, "Should return cached flags") + XCTAssertEqual(flags.first?.feature.name, "cached_feature", "Should return cached flag, not default") + case .failure(let error): + XCTFail("Should return cached flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } +} diff --git a/FlagsmithClient/Tests/APIErrorCacheFallbackTTLTests.swift b/FlagsmithClient/Tests/APIErrorCacheFallbackTTLTests.swift new file mode 100644 index 0000000..f60fdd9 --- /dev/null +++ b/FlagsmithClient/Tests/APIErrorCacheFallbackTTLTests.swift @@ -0,0 +1,94 @@ +// +// APIErrorCacheFallbackTTLTests.swift +// FlagsmithClientTests +// +// Cache TTL and expiration API error scenarios with cache fallback behavior +// + +@testable import FlagsmithClient +import XCTest + +final class APIErrorCacheFallbackTTLTests: FlagsmithClientTestCase { + var testCache: URLCache! + + override func setUp() { + super.setUp() + + // Create isolated cache for testing + testCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024, directory: nil) + + // Reset Flagsmith to known state using TestConfig + Flagsmith.shared.apiKey = TestConfig.hasRealApiKey ? TestConfig.apiKey : "mock-test-api-key" + Flagsmith.shared.baseURL = TestConfig.baseURL + Flagsmith.shared.enableRealtimeUpdates = false + Flagsmith.shared.cacheConfig.useCache = true + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.cacheConfig.cache = testCache + Flagsmith.shared.cacheConfig.cacheTTL = 300 + Flagsmith.shared.defaultFlags = [] + } + + override func tearDown() { + testCache.removeAllCachedResponses() + Flagsmith.shared.cacheConfig.useCache = false + Flagsmith.shared.cacheConfig.skipAPI = false + Flagsmith.shared.apiKey = nil + super.tearDown() + } + + // MARK: - Cache TTL and Expiration Tests + + func testCacheFallback_ExpiredCache_ReturnsDefaultFlags() throws { + // This test works with mock data, no real API key needed + let expectation = expectation(description: "Expired cache with default flags fallback") + + // Create expired cache entry (simulate by using old timestamp) + let cachedFlags = [ + Flag(featureName: "expired_feature", value: .string("expired_value"), enabled: true, featureType: "FLAG") + ] + + var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) + mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") + + // Create expired response (simulate by setting old date) + let expiredDate = Date().addingTimeInterval(-400) // 400 seconds ago (beyond 300s TTL) + let httpResponse = HTTPURLResponse( + url: mockRequest.url!, + statusCode: 200, + httpVersion: "HTTP/1.1", + headerFields: [ + "Content-Type": "application/json", + "Cache-Control": "max-age=300", + "Date": "Mon, 01 Jan 2024 00:00:00 GMT" + ] + )! + + let jsonData = try JSONEncoder().encode(cachedFlags) + let expiredCachedResponse = CachedURLResponse(response: httpResponse, data: jsonData) + testCache.storeCachedResponse(expiredCachedResponse, for: mockRequest) + + // Set up default flags + let defaultFlags = [ + Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") + ] + Flagsmith.shared.defaultFlags = defaultFlags + + // Mock API failure + Flagsmith.shared.apiKey = "invalid-api-key" + + // Request should fail API call and return default flags (not expired cache) + Flagsmith.shared.getFeatureFlags { result in + switch result { + case .success(let flags): + // Should return default flags, not expired cache + XCTAssertEqual(flags.count, 1, "Should return default flags") + XCTAssertEqual(flags.first?.feature.name, "default_feature", "Should return default flag, not expired cache") + case .failure(let error): + XCTFail("Should return default flags instead of failing: \(error)") + } + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + } +} diff --git a/FlagsmithClient/Tests/APIErrorCacheFallbackTests.swift b/FlagsmithClient/Tests/APIErrorCacheFallbackTests.swift deleted file mode 100644 index 8e971d4..0000000 --- a/FlagsmithClient/Tests/APIErrorCacheFallbackTests.swift +++ /dev/null @@ -1,535 +0,0 @@ -// -// APIErrorCacheFallbackTests.swift -// FlagsmithClientTests -// -// Tests for API error scenarios with cache fallback behavior -// Customer requirement: "When fetching flags and we run into an error and have a valid cache we should return the cached flags" -// - -@testable import FlagsmithClient -import XCTest - -final class APIErrorCacheFallbackTests: FlagsmithClientTestCase { - var testCache: URLCache! - - override func setUp() { - super.setUp() - - // Create isolated cache for testing - testCache = URLCache(memoryCapacity: 8 * 1024 * 1024, diskCapacity: 64 * 1024 * 1024, directory: nil) - - // Reset Flagsmith to known state using TestConfig - Flagsmith.shared.apiKey = TestConfig.hasRealApiKey ? TestConfig.apiKey : "mock-test-api-key" - Flagsmith.shared.baseURL = TestConfig.baseURL - Flagsmith.shared.enableRealtimeUpdates = false - Flagsmith.shared.cacheConfig.useCache = true - Flagsmith.shared.cacheConfig.skipAPI = false - Flagsmith.shared.cacheConfig.cache = testCache - Flagsmith.shared.cacheConfig.cacheTTL = 300 - Flagsmith.shared.defaultFlags = [] - } - - override func tearDown() { - testCache.removeAllCachedResponses() - Flagsmith.shared.cacheConfig.useCache = false - Flagsmith.shared.cacheConfig.skipAPI = false - Flagsmith.shared.apiKey = nil - super.tearDown() - } - - // MARK: - Test Helper Methods - - private func extractStringValue(from typedValue: TypedValue?) -> String? { - guard let typedValue = typedValue else { return nil } - switch typedValue { - case .string(let value): - return value - case .int(let value): - return String(value) - case .bool(let value): - return String(value) - case .float(let value): - return String(value) - case .null: - return nil - } - } - - private func createMockCachedResponse(for request: URLRequest, with flags: [Flag]) -> CachedURLResponse { - let jsonData = try! JSONEncoder().encode(flags) - let httpResponse = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: [ - "Content-Type": "application/json", - "Cache-Control": "max-age=300" - ] - )! - return CachedURLResponse(response: httpResponse, data: jsonData) - } - - private func createMockIdentityCachedResponse(for request: URLRequest, with identity: Identity) -> CachedURLResponse { - // Create JSON manually since Identity doesn't conform to Encodable - let jsonString = """ - { - "identifier": "test-user-123", - "traits": [], - "flags": [ - { - "id": 1, - "feature": { - "id": 1, - "name": "\(identity.flags.first?.feature.name ?? "test_feature")", - "type": "FLAG" - }, - "enabled": \(identity.flags.first?.enabled ?? true), - "feature_state_value": "\(extractStringValue(from: identity.flags.first?.value) ?? "test_value")" - } - ] - } - """ - let jsonData = jsonString.data(using: .utf8)! - let httpResponse = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: [ - "Content-Type": "application/json", - "Cache-Control": "max-age=300" - ] - )! - return CachedURLResponse(response: httpResponse, data: jsonData) - } - - // MARK: - Core API Error Cache Fallback Tests - - func testGetFeatureFlags_APIFailure_ReturnsCachedFlags() throws { - // This test works with mock data, no real API key needed - let expectation = expectation(description: "API failure with cache fallback") - - // Create mock flags for cache - let cachedFlags = [ - Flag(featureName: "cached_feature_1", value: .string("cached_value_1"), enabled: true, featureType: "FLAG"), - Flag(featureName: "cached_feature_2", value: .string("cached_value_2"), enabled: false, featureType: "FLAG") - ] - - // Pre-populate cache with successful response - var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) - mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") - let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) - testCache.storeCachedResponse(cachedResponse, for: mockRequest) - - // Mock API failure by using invalid API key - Flagsmith.shared.apiKey = "invalid-api-key" - - // Request should fail API call but return cached flags - Flagsmith.shared.getFeatureFlags { result in - switch result { - case .success(let flags): - // Should return cached flags - XCTAssertEqual(flags.count, 2, "Should return cached flags") - XCTAssertEqual(flags.first?.feature.name, "cached_feature_1", "Should return first cached flag") - XCTAssertEqual(flags.last?.feature.name, "cached_feature_2", "Should return second cached flag") - case .failure(let error): - XCTFail("Should return cached flags instead of failing: \(error)") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - func testGetFeatureFlags_APIFailure_NoCache_ReturnsDefaultFlags() throws { - // This test works with mock data, no real API key needed - let expectation = expectation(description: "API failure with no cache, default flags fallback") - - // Set up default flags - let defaultFlags = [ - Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") - ] - Flagsmith.shared.defaultFlags = defaultFlags - - // Ensure no cache exists - testCache.removeAllCachedResponses() - - // Mock API failure - Flagsmith.shared.apiKey = "invalid-api-key" - - // Request should fail API call and return default flags - Flagsmith.shared.getFeatureFlags { result in - switch result { - case .success(let flags): - // Should return default flags - XCTAssertEqual(flags.count, 1, "Should return default flags") - XCTAssertEqual(flags.first?.feature.name, "default_feature", "Should return default flag") - case .failure(let error): - XCTFail("Should return default flags instead of failing: \(error)") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - func testGetFeatureFlags_APIFailure_NoCacheNoDefaults_ReturnsError() throws { - // This test works with mock data, no real API key needed - let expectation = expectation(description: "API failure with no cache and no defaults") - - // Ensure no cache and no defaults - testCache.removeAllCachedResponses() - Flagsmith.shared.defaultFlags = [] - - // Mock API failure - Flagsmith.shared.apiKey = "invalid-api-key" - - // Request should fail - Flagsmith.shared.getFeatureFlags { result in - switch result { - case .success(_): - XCTFail("Should fail when no cache and no defaults") - case .failure(let error): - // Should return the original API error - XCTAssertTrue(error is FlagsmithError, "Should return FlagsmithError") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - // MARK: - Identity-Specific Cache Fallback Tests - - func testGetFeatureFlagsForIdentity_APIFailure_ReturnsCachedFlags() throws { - // Skip if no real API key since this test needs to make real network calls - guard TestConfig.hasRealApiKey else { - throw XCTSkip("Requires real API key for identity testing") - } - - let expectation = expectation(description: "API failure with identity, cache fallback") - - let testIdentity = TestConfig.testIdentity - let cachedFlags = [ - Flag(featureName: "user_feature", value: .string("user_value"), enabled: true, featureType: "FLAG") - ] - let cachedIdentity = Identity(flags: cachedFlags, traits: [], transient: false) - - // Pre-populate cache with successful identity response - let identityURL = TestConfig.baseURL.appendingPathComponent("identities/") - var components = URLComponents(url: identityURL, resolvingAgainstBaseURL: false)! - components.queryItems = [URLQueryItem(name: "identifier", value: testIdentity)] - var mockRequest = URLRequest(url: components.url!) - mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") - let cachedResponse = createMockIdentityCachedResponse(for: mockRequest, with: cachedIdentity) - testCache.storeCachedResponse(cachedResponse, for: mockRequest) - - // Mock API failure - Flagsmith.shared.apiKey = "invalid-api-key" - - // Request should fail API call but return cached flags - Flagsmith.shared.getFeatureFlags(forIdentity: testIdentity) { result in - switch result { - case .success(let flags): - // Should return cached flags - XCTAssertEqual(flags.count, 1, "Should return cached flags") - XCTAssertEqual(flags.first?.feature.name, "user_feature", "Should return cached user flag") - case .failure(let error): - XCTFail("Should return cached flags instead of failing: \(error)") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - // MARK: - Feature-Specific Method Cache Fallback Tests - - func testHasFeatureFlag_APIFailure_ReturnsCachedResult() throws { - // This test works with mock data, no real API key needed - let expectation = expectation(description: "hasFeatureFlag API failure with cache fallback") - - let testFeature = "test_feature" - let cachedFlags = [ - Flag(featureName: testFeature, value: .string("test_value"), enabled: true, featureType: "FLAG") - ] - - // Pre-populate cache - var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) - mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") - let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) - testCache.storeCachedResponse(cachedResponse, for: mockRequest) - - // Mock API failure - Flagsmith.shared.apiKey = "invalid-api-key" - - // Request should fail API call but return cached result - Flagsmith.shared.hasFeatureFlag(withID: testFeature) { result in - switch result { - case .success(let hasFlag): - // Should return cached result - XCTAssertTrue(hasFlag, "Should return cached enabled state") - case .failure(let error): - XCTFail("Should return cached result instead of failing: \(error)") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - func testGetValueForFeature_APIFailure_ReturnsCachedValue() throws { - // This test works with mock data, no real API key needed - let expectation = expectation(description: "getValueForFeature API failure with cache fallback") - - let testFeature = "test_feature" - let testValue = "cached_value" - let cachedFlags = [ - Flag(featureName: testFeature, value: .string(testValue), enabled: true, featureType: "FLAG") - ] - - // Pre-populate cache - var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) - mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") - let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) - testCache.storeCachedResponse(cachedResponse, for: mockRequest) - - // Mock API failure - Flagsmith.shared.apiKey = "invalid-api-key" - - // Request should fail API call but return cached value - Flagsmith.shared.getValueForFeature(withID: testFeature) { result in - switch result { - case .success(let value): - // Should return cached value - XCTAssertNotNil(value, "Should return cached value") - if case .string(let stringValue) = value { - XCTAssertEqual(stringValue, testValue, "Should return cached string value") - } else { - XCTFail("Expected string value") - } - case .failure(let error): - XCTFail("Should return cached value instead of failing: \(error)") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - // MARK: - Cache TTL and Expiration Tests - - func testCacheFallback_ExpiredCache_ReturnsDefaultFlags() throws { - // This test works with mock data, no real API key needed - let expectation = expectation(description: "Expired cache with default flags fallback") - - // Create expired cache entry (simulate by using old timestamp) - let cachedFlags = [ - Flag(featureName: "expired_feature", value: .string("expired_value"), enabled: true, featureType: "FLAG") - ] - - var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) - mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") - - // Create expired response (simulate by setting old date) - let expiredDate = Date().addingTimeInterval(-400) // 400 seconds ago (beyond 300s TTL) - let httpResponse = HTTPURLResponse( - url: mockRequest.url!, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: [ - "Content-Type": "application/json", - "Cache-Control": "max-age=300", - "Date": "Mon, 01 Jan 2024 00:00:00 GMT" - ] - )! - - let jsonData = try! JSONEncoder().encode(cachedFlags) - let expiredCachedResponse = CachedURLResponse(response: httpResponse, data: jsonData) - testCache.storeCachedResponse(expiredCachedResponse, for: mockRequest) - - // Set up default flags - let defaultFlags = [ - Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") - ] - Flagsmith.shared.defaultFlags = defaultFlags - - // Mock API failure - Flagsmith.shared.apiKey = "invalid-api-key" - - // Request should fail API call and return default flags (not expired cache) - Flagsmith.shared.getFeatureFlags { result in - switch result { - case .success(let flags): - // Should return default flags, not expired cache - XCTAssertEqual(flags.count, 1, "Should return default flags") - XCTAssertEqual(flags.first?.feature.name, "default_feature", "Should return default flag, not expired cache") - case .failure(let error): - XCTFail("Should return default flags instead of failing: \(error)") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - // MARK: - Different Error Type Tests - - func testCacheFallback_NetworkError_ReturnsCachedFlags() throws { - // This test works with mock data, no real API key needed - let expectation = expectation(description: "Network error with cache fallback") - - // Create cached flags - let cachedFlags = [ - Flag(featureName: "network_cached_feature", value: .string("network_cached_value"), enabled: true, featureType: "FLAG") - ] - - // Pre-populate cache - var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) - mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") - let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) - testCache.storeCachedResponse(cachedResponse, for: mockRequest) - - // Simulate network error by using invalid API key (this will cause API failure) - Flagsmith.shared.apiKey = "invalid-api-key" - - // Request should fail API call but return cached flags - Flagsmith.shared.getFeatureFlags { result in - switch result { - case .success(let flags): - // Should return cached flags - XCTAssertEqual(flags.count, 1, "Should return cached flags") - XCTAssertEqual(flags.first?.feature.name, "network_cached_feature", "Should return cached flag") - case .failure(let error): - XCTFail("Should return cached flags instead of failing: \(error)") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - func testCacheFallback_ServerError_ReturnsCachedFlags() throws { - // This test works with mock data, no real API key needed - let expectation = expectation(description: "Server error with cache fallback") - - // Create cached flags - let cachedFlags = [ - Flag(featureName: "server_cached_feature", value: .string("server_cached_value"), enabled: true, featureType: "FLAG") - ] - - // Pre-populate cache - var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) - mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") - let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) - testCache.storeCachedResponse(cachedResponse, for: mockRequest) - - // Simulate server error by using invalid API key (this will cause API failure) - Flagsmith.shared.apiKey = "invalid-api-key" - - // Request should fail API call but return cached flags - Flagsmith.shared.getFeatureFlags { result in - switch result { - case .success(let flags): - // Should return cached flags - XCTAssertEqual(flags.count, 1, "Should return cached flags") - XCTAssertEqual(flags.first?.feature.name, "server_cached_feature", "Should return cached flag") - case .failure(let error): - XCTFail("Should return cached flags instead of failing: \(error)") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - // MARK: - Cache Priority Tests (cache > defaults > error) - - func testCacheFallback_Priority_CacheOverDefaults() throws { - // This test works with mock data, no real API key needed - let expectation = expectation(description: "Cache priority over defaults") - - // Set up both cache and defaults - let cachedFlags = [ - Flag(featureName: "cached_feature", value: .string("cached_value"), enabled: true, featureType: "FLAG") - ] - let defaultFlags = [ - Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") - ] - - // Pre-populate cache - var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) - mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") - let cachedResponse = createMockCachedResponse(for: mockRequest, with: cachedFlags) - testCache.storeCachedResponse(cachedResponse, for: mockRequest) - - // Set up defaults - Flagsmith.shared.defaultFlags = defaultFlags - - // Mock API failure - Flagsmith.shared.apiKey = "invalid-api-key" - - // Request should return cached flags, not default flags - Flagsmith.shared.getFeatureFlags { result in - switch result { - case .success(let flags): - // Should return cached flags, not defaults - XCTAssertEqual(flags.count, 1, "Should return cached flags") - XCTAssertEqual(flags.first?.feature.name, "cached_feature", "Should return cached flag, not default") - case .failure(let error): - XCTFail("Should return cached flags instead of failing: \(error)") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } - - // MARK: - Edge Case Tests - - func testCacheFallback_CorruptedCache_ReturnsDefaultFlags() throws { - // This test works with mock data, no real API key needed - let expectation = expectation(description: "Corrupted cache with default flags fallback") - - // Create corrupted cache entry - let corruptedData = "invalid json data".data(using: .utf8)! - var mockRequest = URLRequest(url: TestConfig.baseURL.appendingPathComponent("flags/")) - mockRequest.setValue(TestConfig.apiKey, forHTTPHeaderField: "X-Environment-Key") - - let httpResponse = HTTPURLResponse( - url: mockRequest.url!, - statusCode: 200, - httpVersion: "HTTP/1.1", - headerFields: [ - "Content-Type": "application/json", - "Cache-Control": "max-age=300" - ] - )! - - let corruptedCachedResponse = CachedURLResponse(response: httpResponse, data: corruptedData) - testCache.storeCachedResponse(corruptedCachedResponse, for: mockRequest) - - // Set up default flags - let defaultFlags = [ - Flag(featureName: "default_feature", value: .string("default_value"), enabled: true, featureType: "FLAG") - ] - Flagsmith.shared.defaultFlags = defaultFlags - - // Mock API failure - Flagsmith.shared.apiKey = "invalid-api-key" - - // Request should fail API call and return default flags (not corrupted cache) - Flagsmith.shared.getFeatureFlags { result in - switch result { - case .success(let flags): - // Should return default flags, not corrupted cache - XCTAssertEqual(flags.count, 1, "Should return default flags") - XCTAssertEqual(flags.first?.feature.name, "default_feature", "Should return default flag, not corrupted cache") - case .failure(let error): - XCTFail("Should return default flags instead of failing: \(error)") - } - expectation.fulfill() - } - - wait(for: [expectation], timeout: 5.0) - } -} From 0fbc9a243a916b3b06541c86de46e0fb48e3fcb4 Mon Sep 17 00:00:00 2001 From: Polat Olu Date: Fri, 17 Oct 2025 09:11:10 +0100 Subject: [PATCH 3/4] linter fixes --- FlagsmithClient/Classes/Flagsmith.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index ff5f354..b372a08 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -305,7 +305,10 @@ public final class Flagsmith: @unchecked Sendable { let components = cacheControl.split(separator: ",") for component in components { let trimmed = component.trimmingCharacters(in: .whitespaces) - let directive = trimmed.lowercased() + let directiveTokens = trimmed.split(separator: "=").first?.split(separator: ";").first + guard let directiveToken = directiveTokens else { continue } + + let directive = directiveToken.trimmingCharacters(in: .whitespaces).lowercased() if directive == "no-cache" || directive == "no-store" { return true } From 2d6958b6fb2f477d168678526cca5ffc0bdc34e0 Mon Sep 17 00:00:00 2001 From: Polat Olu Date: Fri, 17 Oct 2025 10:27:33 +0100 Subject: [PATCH 4/4] improvements in the implementation --- FlagsmithClient/Classes/Flagsmith.swift | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index b372a08..b6bd426 100644 --- a/FlagsmithClient/Classes/Flagsmith.swift +++ b/FlagsmithClient/Classes/Flagsmith.swift @@ -228,7 +228,7 @@ public final class Flagsmith: @unchecked Sendable { let flags = try JSONDecoder().decode([Flag].self, from: cachedResponse.data) return flags } catch { - // Cache data is corrupted, return nil + print("Flagsmith - Failed to decode cached flags: \(error.localizedDescription)") return nil } } @@ -242,7 +242,9 @@ public final class Flagsmith: @unchecked Sendable { // Create request for identity-specific flags let identityURL = baseURL.appendingPathComponent("identities/") - var components = URLComponents(url: identityURL, resolvingAgainstBaseURL: false)! + guard var components = URLComponents(url: identityURL, resolvingAgainstBaseURL: false) else { + return nil + } components.queryItems = [URLQueryItem(name: "identifier", value: identity)] guard let url = components.url else { return nil } @@ -256,7 +258,7 @@ public final class Flagsmith: @unchecked Sendable { let identity = try JSONDecoder().decode(Identity.self, from: cachedResponse.data) return identity.flags } catch { - // Cache data is corrupted, return nil + print("Flagsmith - Failed to decode cached identity flags: \(error.localizedDescription)") return nil } } @@ -285,8 +287,20 @@ public final class Flagsmith: @unchecked Sendable { } } - // If no cache control, assume valid for the configured TTL + // If no cache control, validate against configured TTL + if cacheConfig.cacheTTL > 0 { + if let dateString = httpResponse.allHeaderFields["Date"] as? String, + let date = HTTPURLResponse.dateFormatter.date(from: dateString) { + let age = Date().timeIntervalSince(date) + return age < cacheConfig.cacheTTL + } + // No Date header, be conservative + return false + } + // TTL of 0 means infinite + return true + } private func extractMaxAge(from cacheControl: String) -> TimeInterval? {