import XCTest @testable import LCFeatureControl @testable import LCEssentials private final class MutableClock: @unchecked Sendable { var value: Date init(_ value: Date) { self.value = value } } final class FeatureControlManagerTests: XCTestCase { private var api: API! override func setUp() { super.setUp() let cfg = URLSessionConfiguration.ephemeral cfg.protocolClasses = [StubURLProtocol.self] api = API(testConfiguration: cfg) } override func tearDown() { StubURLProtocol.reset() api = nil super.tearDown() } private func makeManager(defaults: [String: FeatureControlFlag] = [:], now: @escaping @Sendable () -> Date = { Date() }) -> FeatureControlManager { let config = FeatureControlConfiguration(baseURL: "https://api.example.com", environment: "production", auth: FeatureControlHeaderAuth(headers: [:])) return FeatureControlManager(configuration: config, api: api, defaults: defaults, now: now) } private func evaluateResponseBody(configVersion: Int) -> Data { Data(""" {"error": false, "code": "FEATURE_CONTROL_EVALUATED", "result": { "evaluatedAt": "2026-04-16T12:00:00.000Z", "configVersion": \(configVersion), "flags": {"fc.checkout_v2": {"enabled": true, "variant": "on", "payload": null, "reason": "rollout"}} }} """.utf8) } private func context() -> FeatureControlContext { FeatureControlContext(subjectType: .customer, subjectId: "cust_1") } // MARK: - Happy path (T4) func testEvaluateFetchesAndCachesOnMiss() async throws { StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7))) let manager = makeManager() let snapshot = try await manager.evaluateOrThrow(keys: ["fc.checkout_v2"], context: context()) XCTAssertEqual(snapshot.configVersion, 7) XCTAssertEqual(StubURLProtocol.requestCount, 1) let sent = StubURLProtocol.capturedRequests.first XCTAssertEqual(sent?.httpMethod, "POST") XCTAssertEqual(sent?.url?.absoluteString, "https://api.example.com/api/feature-control/evaluate") } func testEvaluateReturnsCacheWithoutNetworkCallOnHit() async { StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7))) let manager = makeManager() _ = await manager.evaluate(keys: ["fc.checkout_v2"], context: context()) _ = await manager.evaluate(keys: ["fc.checkout_v2"], context: context()) XCTAssertEqual(StubURLProtocol.requestCount, 1) } func testConfigVersionBumpReplacesCacheAfterExpiry() async { let clock = MutableClock(Date()) StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7))) let manager = makeManager(now: { clock.value }) let first = await manager.evaluate(keys: ["fc.checkout_v2"], context: context()) XCTAssertEqual(first.configVersion, 7) clock.value = clock.value.addingTimeInterval(1000) // past default 45s TTL StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 8))) let second = await manager.evaluate(keys: ["fc.checkout_v2"], context: context()) XCTAssertEqual(second.configVersion, 8) XCTAssertEqual(StubURLProtocol.requestCount, 1) } func testInvalidateCacheForcesFreshFetch() async { StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7))) let manager = makeManager() _ = await manager.evaluate(keys: ["fc.checkout_v2"], context: context()) await manager.invalidateCache() StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 9))) let second = await manager.evaluate(keys: ["fc.checkout_v2"], context: context()) XCTAssertEqual(second.configVersion, 9) XCTAssertEqual(StubURLProtocol.requestCount, 1) } func testEvaluateOrThrowPropagatesDecodingFailure() async { StubURLProtocol.setStub(.init(statusCode: 200, body: Data("not json".utf8))) let manager = makeManager() do { _ = try await manager.evaluateOrThrow(keys: ["x"], context: context()) XCTFail("expected throw") } catch { // any throw is correct — evaluateOrThrow must not silently degrade } } // MARK: - Safe-degrade fallback chain (T5) func testEvaluate429FallsBackToStaleCache() async { let clock = MutableClock(Date()) StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7))) let manager = makeManager(now: { clock.value }) _ = await manager.evaluate(keys: ["fc.checkout_v2"], context: context()) clock.value = clock.value.addingTimeInterval(1000) StubURLProtocol.setStub(.init(statusCode: 429, body: Data(#"{"code":"FEATURE_CONTROL_RATE_LIMIT"}"#.utf8))) let result = await manager.evaluate(keys: ["fc.checkout_v2"], context: context()) XCTAssertEqual(result.configVersion, 7) } func testEvaluate500FallsBackToStaleCache() async { let clock = MutableClock(Date()) StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7))) let manager = makeManager(now: { clock.value }) _ = await manager.evaluate(keys: ["fc.checkout_v2"], context: context()) clock.value = clock.value.addingTimeInterval(1000) StubURLProtocol.setStub(.init(statusCode: 500, body: Data())) let result = await manager.evaluate(keys: ["fc.checkout_v2"], context: context()) XCTAssertEqual(result.configVersion, 7) } func testEvaluateNoCacheAndServerDownReturnsDefaults() async { StubURLProtocol.setStub(.init(statusCode: 500, body: Data())) let defaultFlag = FeatureControlFlag(enabled: false, variant: nil, payload: nil, reason: "default") let manager = makeManager(defaults: ["fc.checkout_v2": defaultFlag]) let result = await manager.evaluate(keys: ["fc.checkout_v2"], context: context()) XCTAssertEqual(result.configVersion, FeatureControlManager.unresolvedConfigVersion) XCTAssertEqual(result.flags["fc.checkout_v2"]?.enabled, false) } func testEvaluateNeverThrowsEvenWhenServerAlwaysErrors() async { StubURLProtocol.setStub(.init(statusCode: 500, body: Data())) let manager = makeManager() // No `try` above this line compiles only because `evaluate` truly never throws. _ = await manager.evaluate(keys: ["x"], context: context()) } }