[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:
@@ -0,0 +1,69 @@
|
||||
import XCTest
|
||||
@testable import LCFeatureControl
|
||||
@testable import LCEssentials
|
||||
|
||||
final class FeatureControlExposureTests: 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() -> FeatureControlManager {
|
||||
let config = FeatureControlConfiguration(baseURL: "https://api.example.com",
|
||||
environment: "production",
|
||||
auth: FeatureControlHeaderAuth(headers: [:]))
|
||||
return FeatureControlManager(configuration: config, api: api)
|
||||
}
|
||||
|
||||
private func event(_ index: Int) -> FeatureControlExposureEvent {
|
||||
FeatureControlExposureEvent(featureKey: "fc.checkout_v2", variant: "on",
|
||||
subjectType: .customer, storeId: "store_\(index)")
|
||||
}
|
||||
|
||||
func testFlushSendsSingleBatchUnderLimit() async {
|
||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"error":false,"code":"OK"}"#.utf8)))
|
||||
let manager = makeManager()
|
||||
for i in 0..<10 { await manager.recordExposure(event(i)) }
|
||||
|
||||
await manager.flushExposures()
|
||||
|
||||
XCTAssertEqual(StubURLProtocol.requestCount, 1)
|
||||
}
|
||||
|
||||
func testFlushSplitsIntoMultipleBatchesOverLimit() async {
|
||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"error":false,"code":"OK"}"#.utf8)))
|
||||
let manager = makeManager()
|
||||
for i in 0..<130 { await manager.recordExposure(event(i)) }
|
||||
|
||||
await manager.flushExposures()
|
||||
|
||||
XCTAssertEqual(StubURLProtocol.requestCount, 2)
|
||||
}
|
||||
|
||||
func testFailedBatchIsDroppedNotRetriedIndefinitely() async {
|
||||
StubURLProtocol.setStub(.init(statusCode: 500, body: Data()))
|
||||
let manager = makeManager()
|
||||
for i in 0..<10 { await manager.recordExposure(event(i)) }
|
||||
|
||||
await manager.flushExposures() // must not throw, must not hang
|
||||
|
||||
XCTAssertEqual(StubURLProtocol.requestCount, 1)
|
||||
|
||||
// buffer was cleared even though the batch failed — a second flush sends nothing new
|
||||
StubURLProtocol.reset()
|
||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"error":false,"code":"OK"}"#.utf8)))
|
||||
await manager.flushExposures()
|
||||
XCTAssertEqual(StubURLProtocol.requestCount, 0)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user