[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:
Daniel Arantes Loverde
2026-09-16 09:23:57 -03:00
parent d067791930
commit 2ba487a6e8
47 changed files with 4264 additions and 113 deletions

View 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())
}
}