[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,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)
}
}