[sub-spm-optional-products] Add LCECryptoKit and LCFeatureControl as optional sub-SPM products
LCEssentials installs standalone; each sub target-depends on it so linking a sub's product always pulls LCEssentials in too, without the consumer having to declare it separately. - LCECryptoKit: internalized from the remote LCECryptoKitBinary git dependency (embedded token URL removed) into a local binaryTarget vendoring Frameworks/LCECryptoKit.xcframework. LCECryptoKitManager moved out of LCEssentials core into its own LCECryptoKitManager target/product; the no-op fallback for when the binary wasn't linked is gone (breaking change for existing consumers, see decisions/2026-09-15-sub-spm-optional-products.md). - LCFeatureControl: new product wrapping Atomenta's Feature Control API (flag evaluation with TTL cache + safe-degrade fallback to defaults, notifications inbox, batched exposure telemetry). 45 new tests.
This commit is contained in:
160
Tests/LCFeatureControlTests/FeatureControlManagerTests.swift
Normal file
160
Tests/LCFeatureControlTests/FeatureControlManagerTests.swift
Normal file
@@ -0,0 +1,160 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user