[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,70 @@
import XCTest
@testable import LCFeatureControl
/// Test-only mutable clock box a `var` captured directly by the cache's
/// escaping `@Sendable` closure would trip strict-concurrency capture checks;
/// this mirrors the `@unchecked Sendable` pattern `StubURLProtocol` already uses.
private final class MutableClock: @unchecked Sendable {
var value: Date
init(_ value: Date) { self.value = value }
}
final class FeatureControlCacheTests: XCTestCase {
private func snapshot(configVersion: Int) -> FeatureControlSnapshot {
FeatureControlSnapshot(evaluatedAt: Date(), configVersion: configVersion, flags: [:])
}
private func key(_ keys: [String] = ["a", "b"]) -> FeatureControlCacheKey {
FeatureControlCacheKey(environment: "production", subjectId: "cust_1",
sortedKeys: keys.sorted(), platform: "ios", appVersion: "1.0")
}
func testHitWithinTTLReturnsSameSnapshot() async {
let cache = FeatureControlCache(now: { Date() })
await cache.set(key(), snapshot: snapshot(configVersion: 1), ttl: 45)
let hit = await cache.get(key(), allowStale: false)
XCTAssertEqual(hit?.configVersion, 1)
}
func testMissAfterTTLExpiryReturnsNilWhenStaleNotAllowed() async {
let clock = MutableClock(Date())
let cache = FeatureControlCache(now: { clock.value })
await cache.set(key(), snapshot: snapshot(configVersion: 1), ttl: 1)
clock.value = clock.value.addingTimeInterval(2)
let hit = await cache.get(key(), allowStale: false)
XCTAssertNil(hit)
}
func testStaleAllowedReturnsExpiredEntry() async {
let clock = MutableClock(Date())
let cache = FeatureControlCache(now: { clock.value })
await cache.set(key(), snapshot: snapshot(configVersion: 1), ttl: 1)
clock.value = clock.value.addingTimeInterval(2)
let hit = await cache.get(key(), allowStale: true)
XCTAssertEqual(hit?.configVersion, 1)
}
func testKeyDiffersBySortedKeysNotInputOrder() async {
let cache = FeatureControlCache(now: { Date() })
await cache.set(key(["a", "b"]), snapshot: snapshot(configVersion: 1), ttl: 45)
let hit = await cache.get(key(["b", "a"]), allowStale: false)
XCTAssertEqual(hit?.configVersion, 1)
}
func testSetOverwritesPriorConfigVersion() async {
let cache = FeatureControlCache(now: { Date() })
await cache.set(key(), snapshot: snapshot(configVersion: 7), ttl: 45)
await cache.set(key(), snapshot: snapshot(configVersion: 8), ttl: 45)
let hit = await cache.get(key(), allowStale: false)
XCTAssertEqual(hit?.configVersion, 8)
}
func testInvalidateAllClearsEntries() async {
let cache = FeatureControlCache(now: { Date() })
await cache.set(key(), snapshot: snapshot(configVersion: 1), ttl: 45)
await cache.invalidateAll()
let hit = await cache.get(key(), allowStale: true)
XCTAssertNil(hit)
}
}