diff --git a/FlagsmithClient/Classes/Flagsmith.swift b/FlagsmithClient/Classes/Flagsmith.swift index e8c0e88..b6bd426 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,181 @@ 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 { + print("Flagsmith - Failed to decode cached flags: \(error.localizedDescription)") + 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/") + 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 } + 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 { + print("Flagsmith - Failed to decode cached identity flags: \(error.localizedDescription)") + 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 { + // 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, + let date = HTTPURLResponse.dateFormatter.date(from: dateString) { + let age = Date().timeIntervalSince(date) + return age < maxAge + } + } + } + + // 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? { + 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 + } + + private func hasNoCacheDirective(in cacheControl: String) -> Bool { + let components = cacheControl.split(separator: ",") + for component in components { + let trimmed = component.trimmingCharacters(in: .whitespaces) + 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 + } + } + return false + } +} + +// 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/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/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"],