[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:
38
Tests/LCFeatureControlTests/FeatureControlAuthTests.swift
Normal file
38
Tests/LCFeatureControlTests/FeatureControlAuthTests.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
import XCTest
|
||||
@testable import LCFeatureControl
|
||||
|
||||
final class FeatureControlAuthTests: XCTestCase {
|
||||
|
||||
func testBearerAuthAddsAuthorizationHeader() async {
|
||||
let auth = FeatureControlBearerAuth { "jwt-123" }
|
||||
var headers: [String: String] = [:]
|
||||
await auth.authorize(&headers)
|
||||
XCTAssertEqual(headers["Authorization"], "Bearer jwt-123")
|
||||
}
|
||||
|
||||
func testBearerAuthAddsNoHeaderWhenTokenIsNil() async {
|
||||
let auth = FeatureControlBearerAuth { nil }
|
||||
var headers: [String: String] = [:]
|
||||
await auth.authorize(&headers)
|
||||
XCTAssertNil(headers["Authorization"])
|
||||
}
|
||||
|
||||
func testHeaderAuthAddsExactHeadersOnly() async {
|
||||
let auth = FeatureControlHeaderAuth(headers: ["X-BFF-Session": "abc"])
|
||||
var headers: [String: String] = [:]
|
||||
await auth.authorize(&headers)
|
||||
XCTAssertEqual(headers, ["X-BFF-Session": "abc"])
|
||||
XCTAssertNil(headers["Authorization"])
|
||||
}
|
||||
|
||||
func testConfigurationDefaults() {
|
||||
let config = FeatureControlConfiguration(baseURL: "https://api.example.com",
|
||||
environment: "production",
|
||||
auth: FeatureControlHeaderAuth(headers: [:]))
|
||||
XCTAssertEqual(config.cacheTTL, 45)
|
||||
XCTAssertEqual(config.requestTimeout, 3)
|
||||
XCTAssertEqual(config.evaluatePath, "/api/feature-control/evaluate")
|
||||
XCTAssertEqual(config.notificationsPath, "/api/feature-control/notifications")
|
||||
XCTAssertEqual(config.telemetryPath, "/api/feature-control/telemetry/exposure")
|
||||
}
|
||||
}
|
||||
70
Tests/LCFeatureControlTests/FeatureControlCacheTests.swift
Normal file
70
Tests/LCFeatureControlTests/FeatureControlCacheTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
84
Tests/LCFeatureControlTests/FeatureControlErrorTests.swift
Normal file
84
Tests/LCFeatureControlTests/FeatureControlErrorTests.swift
Normal file
@@ -0,0 +1,84 @@
|
||||
import XCTest
|
||||
@testable import LCFeatureControl
|
||||
@testable import LCEssentials
|
||||
|
||||
final class FeatureControlErrorTests: 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 context() -> FeatureControlContext {
|
||||
FeatureControlContext(subjectType: .customer, subjectId: "cust_1")
|
||||
}
|
||||
|
||||
private func expectMappedError(status: Int, bodyCode: String) async throws -> FeatureControlError {
|
||||
StubURLProtocol.setStub(.init(statusCode: status, body: Data(#"{"code":"\#(bodyCode)"}"#.utf8)))
|
||||
let manager = makeManager()
|
||||
do {
|
||||
_ = try await manager.evaluateOrThrow(keys: ["x"], context: context())
|
||||
XCTFail("expected FeatureControlError")
|
||||
throw FeatureControlError.transport(message: "unreachable")
|
||||
} catch let error as FeatureControlError {
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
func testMaps400ToInvalidContext() async throws {
|
||||
let error = try await expectMappedError(status: 400, bodyCode: "FEATURE_CONTROL_CONTEXT_INVALID")
|
||||
XCTAssertEqual(error, .invalidContext(code: "FEATURE_CONTROL_CONTEXT_INVALID"))
|
||||
}
|
||||
|
||||
func testMaps401ToUnauthorized() async throws {
|
||||
let error = try await expectMappedError(status: 401, bodyCode: "FEATURE_CONTROL_MISSING_AUTH")
|
||||
XCTAssertEqual(error, .unauthorized(code: "FEATURE_CONTROL_MISSING_AUTH"))
|
||||
}
|
||||
|
||||
func testMaps403ToForbidden() async throws {
|
||||
let error = try await expectMappedError(status: 403, bodyCode: "FEATURE_CONTROL_PANEL_ROLE_REQUIRED")
|
||||
XCTAssertEqual(error, .forbidden(code: "FEATURE_CONTROL_PANEL_ROLE_REQUIRED"))
|
||||
}
|
||||
|
||||
func testMaps429ToRateLimited() async throws {
|
||||
let error = try await expectMappedError(status: 429, bodyCode: "FEATURE_CONTROL_RATE_LIMIT")
|
||||
XCTAssertEqual(error, .rateLimited(code: "FEATURE_CONTROL_RATE_LIMIT"))
|
||||
}
|
||||
|
||||
func testMaps500ToServerWithStatusCode() async throws {
|
||||
let error = try await expectMappedError(status: 500, bodyCode: "FEATURE_CONTROL_ERROR")
|
||||
XCTAssertEqual(error, .server(code: "FEATURE_CONTROL_ERROR", status: 500))
|
||||
}
|
||||
|
||||
func testDecodingFailureMapsToTransport() async {
|
||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data("not json at all".utf8)))
|
||||
let manager = makeManager()
|
||||
do {
|
||||
_ = try await manager.evaluateOrThrow(keys: ["x"], context: context())
|
||||
XCTFail("expected throw")
|
||||
} catch let error as FeatureControlError {
|
||||
guard case .transport = error else {
|
||||
return XCTFail("expected .transport, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("expected FeatureControlError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
40
Tests/LCFeatureControlTests/FeatureControlJSONTests.swift
Normal file
40
Tests/LCFeatureControlTests/FeatureControlJSONTests.swift
Normal file
@@ -0,0 +1,40 @@
|
||||
import XCTest
|
||||
@testable import LCFeatureControl
|
||||
|
||||
final class FeatureControlJSONTests: XCTestCase {
|
||||
|
||||
private func decode(_ json: String) throws -> FeatureControlJSON {
|
||||
try JSONDecoder().decode(FeatureControlJSON.self, from: Data(json.utf8))
|
||||
}
|
||||
|
||||
func testDecodesNull() throws {
|
||||
XCTAssertEqual(try decode("null"), .null)
|
||||
}
|
||||
|
||||
func testDecodesBool() throws {
|
||||
XCTAssertEqual(try decode("true"), .bool(true))
|
||||
}
|
||||
|
||||
func testDecodesNumber() throws {
|
||||
XCTAssertEqual(try decode("5"), .number(5))
|
||||
}
|
||||
|
||||
func testDecodesString() throws {
|
||||
XCTAssertEqual(try decode("\"hello\""), .string("hello"))
|
||||
}
|
||||
|
||||
func testDecodesArray() throws {
|
||||
XCTAssertEqual(try decode("[\"a\",\"b\"]"), .array([.string("a"), .string("b")]))
|
||||
}
|
||||
|
||||
func testDecodesNestedObject() throws {
|
||||
let json = #"{"limit": 5, "tags": ["a","b"], "nested": {"x": true}}"#
|
||||
let value = try decode(json)
|
||||
guard case let .object(object) = value else {
|
||||
return XCTFail("expected .object, got \(value)")
|
||||
}
|
||||
XCTAssertEqual(object["limit"], .number(5))
|
||||
XCTAssertEqual(object["tags"], .array([.string("a"), .string("b")]))
|
||||
XCTAssertEqual(object["nested"], .object(["x": .bool(true)]))
|
||||
}
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
124
Tests/LCFeatureControlTests/FeatureControlModelsTests.swift
Normal file
124
Tests/LCFeatureControlTests/FeatureControlModelsTests.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import XCTest
|
||||
@testable import LCFeatureControl
|
||||
|
||||
/// Exact evaluate-response shape from Atomenta's `docs/feature-control/EXTERNAL_CLIENTS.md` §3.
|
||||
private let evaluateResponseFixture = """
|
||||
{
|
||||
"error": false,
|
||||
"code": "FEATURE_CONTROL_EVALUATED",
|
||||
"result": {
|
||||
"evaluatedAt": "2026-04-16T12:00:00.000Z",
|
||||
"configVersion": 7,
|
||||
"flags": {
|
||||
"fc.checkout_v2": {
|
||||
"enabled": true,
|
||||
"variant": "on",
|
||||
"payload": null,
|
||||
"reason": "rollout"
|
||||
},
|
||||
"fc.search_ranking_v3": {
|
||||
"enabled": false,
|
||||
"variant": "off",
|
||||
"payload": null,
|
||||
"reason": "default"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
final class FeatureControlModelsTests: XCTestCase {
|
||||
|
||||
func testFlagDecodesFromEvaluateResponseFixture() throws {
|
||||
let data = Data(evaluateResponseFixture.utf8)
|
||||
let envelope = try JSONDecoder().decode(FeatureControlEvaluateEnvelope.self, from: data)
|
||||
|
||||
XCTAssertFalse(envelope.error)
|
||||
XCTAssertEqual(envelope.result.configVersion, 7)
|
||||
|
||||
let checkout = try XCTUnwrap(envelope.result.flags["fc.checkout_v2"])
|
||||
XCTAssertTrue(checkout.enabled)
|
||||
XCTAssertEqual(checkout.variant, "on")
|
||||
XCTAssertNil(checkout.payload)
|
||||
XCTAssertEqual(checkout.reason, "rollout")
|
||||
|
||||
let ranking = try XCTUnwrap(envelope.result.flags["fc.search_ranking_v3"])
|
||||
XCTAssertFalse(ranking.enabled)
|
||||
}
|
||||
|
||||
func testEvaluatedAtParsesFractionalSecondsISO8601() throws {
|
||||
let data = Data(evaluateResponseFixture.utf8)
|
||||
let envelope = try JSONDecoder().decode(FeatureControlEvaluateEnvelope.self, from: data)
|
||||
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second],
|
||||
from: envelope.result.evaluatedAt)
|
||||
XCTAssertEqual(components.year, 2026)
|
||||
XCTAssertEqual(components.month, 4)
|
||||
XCTAssertEqual(components.day, 16)
|
||||
XCTAssertEqual(components.hour, 12)
|
||||
}
|
||||
|
||||
func testContextEncodesExactOpenAPIShape() throws {
|
||||
let context = FeatureControlContext(subjectType: .customer, subjectId: "cust_123",
|
||||
storeId: "store_001", platform: "ios",
|
||||
appVersion: "2.3.1",
|
||||
attributes: ["city": "Belo Horizonte"])
|
||||
let data = try JSONEncoder().encode(context)
|
||||
let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
|
||||
XCTAssertEqual(object["subjectType"] as? String, "customer")
|
||||
XCTAssertEqual(object["subjectId"] as? String, "cust_123")
|
||||
XCTAssertEqual(object["storeId"] as? String, "store_001")
|
||||
XCTAssertEqual(object["platform"] as? String, "ios")
|
||||
XCTAssertEqual(object["appVersion"] as? String, "2.3.1")
|
||||
let attributes = try XCTUnwrap(object["attributes"] as? [String: String])
|
||||
XCTAssertEqual(attributes["city"], "Belo Horizonte")
|
||||
}
|
||||
|
||||
func testNotificationDecodesFromOpenAPIShape() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": "n1",
|
||||
"title": "Manutenção",
|
||||
"body": "Janela de manutenção às 22h",
|
||||
"severity": "warning",
|
||||
"createdAt": "2026-04-16T12:00:00.000Z",
|
||||
"read": false,
|
||||
"ctaLabel": "Ver detalhes",
|
||||
"ctaUrl": "https://example.com"
|
||||
}
|
||||
"""
|
||||
let notification = try JSONDecoder().decode(FeatureControlNotification.self, from: Data(json.utf8))
|
||||
XCTAssertEqual(notification.id, "n1")
|
||||
XCTAssertEqual(notification.severity, "warning")
|
||||
XCTAssertFalse(notification.read)
|
||||
XCTAssertEqual(notification.ctaLabel, "Ver detalhes")
|
||||
}
|
||||
|
||||
func testNotificationDecodesWithoutOptionalCTAFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": "n2",
|
||||
"title": "Info",
|
||||
"body": "Just FYI",
|
||||
"severity": "info",
|
||||
"createdAt": "2026-04-16T12:00:00Z",
|
||||
"read": true
|
||||
}
|
||||
"""
|
||||
let notification = try JSONDecoder().decode(FeatureControlNotification.self, from: Data(json.utf8))
|
||||
XCTAssertNil(notification.ctaLabel)
|
||||
XCTAssertNil(notification.ctaUrl)
|
||||
}
|
||||
|
||||
func testExposureEventEncodesExpectedShape() throws {
|
||||
let event = FeatureControlExposureEvent(featureKey: "fc.checkout_v2", variant: "on",
|
||||
subjectType: .customer, storeId: "store_001")
|
||||
let data = try JSONEncoder().encode(event)
|
||||
let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
XCTAssertEqual(object["featureKey"] as? String, "fc.checkout_v2")
|
||||
XCTAssertEqual(object["subjectType"] as? String, "customer")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import XCTest
|
||||
@testable import LCFeatureControl
|
||||
@testable import LCEssentials
|
||||
|
||||
final class FeatureControlNotificationsClientTests: 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 makeClient() -> FeatureControlNotificationsClient {
|
||||
let config = FeatureControlConfiguration(baseURL: "https://api.example.com",
|
||||
environment: "production",
|
||||
auth: FeatureControlBearerAuth { "jwt-123" })
|
||||
return FeatureControlNotificationsClient(configuration: config, api: api)
|
||||
}
|
||||
|
||||
func testListDecodesItemsAndNextCursor() async throws {
|
||||
let body = """
|
||||
{"error": false, "result": {"items": [
|
||||
{"id": "n1", "title": "T", "body": "B", "severity": "info",
|
||||
"createdAt": "2026-04-16T12:00:00.000Z", "read": false}
|
||||
], "nextCursor": "cursor-2"}}
|
||||
"""
|
||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(body.utf8)))
|
||||
let client = makeClient()
|
||||
|
||||
let (items, nextCursor) = try await client.list(status: .unread, limit: 20)
|
||||
|
||||
XCTAssertEqual(items.count, 1)
|
||||
XCTAssertEqual(items.first?.id, "n1")
|
||||
XCTAssertEqual(nextCursor, "cursor-2")
|
||||
}
|
||||
|
||||
func testListPassesStatusAndLimitAsQueryParams() async throws {
|
||||
let body = #"{"error": false, "result": {"items": [], "nextCursor": null}}"#
|
||||
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(body.utf8)))
|
||||
let client = makeClient()
|
||||
|
||||
_ = try await client.list(status: .unread, limit: 5)
|
||||
|
||||
let url = StubURLProtocol.capturedRequests.first?.url?.absoluteString
|
||||
XCTAssertEqual(url, "https://api.example.com/api/feature-control/notifications?status=unread&limit=5")
|
||||
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.value(forHTTPHeaderField: "Authorization"),
|
||||
"Bearer jwt-123")
|
||||
}
|
||||
|
||||
func testMarkReadPostsToCorrectPathAndSucceedsOn200() async throws {
|
||||
StubURLProtocol.setStub(.init(statusCode: 200, headers: ["Content-Type": "text/plain"], body: Data("ok".utf8)))
|
||||
let client = makeClient()
|
||||
|
||||
try await client.markRead(id: "n1")
|
||||
|
||||
let sent = StubURLProtocol.capturedRequests.first
|
||||
XCTAssertEqual(sent?.httpMethod, "POST")
|
||||
XCTAssertEqual(sent?.url?.absoluteString, "https://api.example.com/api/feature-control/notifications/n1/read")
|
||||
}
|
||||
|
||||
func testMarkAllReadSucceedsOn200() async throws {
|
||||
StubURLProtocol.setStub(.init(statusCode: 200, headers: ["Content-Type": "text/plain"], body: Data("ok".utf8)))
|
||||
let client = makeClient()
|
||||
|
||||
try await client.markAllRead()
|
||||
|
||||
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.url?.absoluteString,
|
||||
"https://api.example.com/api/feature-control/notifications/read-all")
|
||||
}
|
||||
|
||||
func testUnauthorizedMapsToFeatureControlErrorNotCrash() async {
|
||||
StubURLProtocol.setStub(.init(statusCode: 401, body: Data(#"{"code":"FEATURE_CONTROL_MISSING_AUTH"}"#.utf8)))
|
||||
let client = makeClient()
|
||||
|
||||
do {
|
||||
try await client.markAllRead()
|
||||
XCTFail("expected throw")
|
||||
} catch let error as FeatureControlError {
|
||||
XCTAssertEqual(error, .unauthorized(code: "FEATURE_CONTROL_MISSING_AUTH"))
|
||||
} catch {
|
||||
XCTFail("expected FeatureControlError, got \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
116
Tests/LCFeatureControlTests/Support/StubURLProtocol.swift
Normal file
116
Tests/LCFeatureControlTests/Support/StubURLProtocol.swift
Normal file
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
|
||||
/// Test double for `URLProtocol`. Intercepts every request on a `URLSession`
|
||||
/// configured with it, records the outgoing `URLRequest`, and replays a canned
|
||||
/// response or error supplied by the test.
|
||||
///
|
||||
/// Register via:
|
||||
/// ```
|
||||
/// let cfg = URLSessionConfiguration.ephemeral
|
||||
/// cfg.protocolClasses = [StubURLProtocol.self]
|
||||
/// let session = URLSession(configuration: cfg)
|
||||
/// ```
|
||||
final class StubURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
|
||||
struct Stub {
|
||||
var statusCode: Int = 200
|
||||
var headers: [String: String] = ["Content-Type": "application/json"]
|
||||
var body: Data = Data()
|
||||
var error: Error?
|
||||
/// Bytes reported through `URLSession`'s upload progress, in order.
|
||||
var uploadProgressChunks: [Int] = []
|
||||
}
|
||||
|
||||
// MARK: - Test-facing state (guarded)
|
||||
|
||||
private static let lock = NSLock()
|
||||
// Access is serialised through `lock`; the unsafe opt-out is the documented
|
||||
// pattern for lock-guarded mutable statics under strict concurrency.
|
||||
nonisolated(unsafe) private static var _stub = Stub()
|
||||
nonisolated(unsafe) private static var _capturedRequests: [URLRequest] = []
|
||||
nonisolated(unsafe) private static var _capturedBodies: [Data] = []
|
||||
|
||||
static func setStub(_ stub: Stub) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
_stub = stub
|
||||
_capturedRequests = []
|
||||
_capturedBodies = []
|
||||
}
|
||||
|
||||
static func reset() { setStub(Stub()) }
|
||||
|
||||
static var capturedRequests: [URLRequest] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _capturedRequests
|
||||
}
|
||||
|
||||
/// Body of the last intercepted request. `URLProtocol` strips `httpBody` for
|
||||
/// stream bodies, so this reads `httpBodyStream` when needed.
|
||||
static var lastCapturedBody: Data? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _capturedBodies.last
|
||||
}
|
||||
|
||||
static var requestCount: Int {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _capturedRequests.count
|
||||
}
|
||||
|
||||
private static func currentStub() -> Stub {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return _stub
|
||||
}
|
||||
|
||||
private static func record(_ request: URLRequest, body: Data) {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
_capturedRequests.append(request)
|
||||
_capturedBodies.append(body)
|
||||
}
|
||||
|
||||
// MARK: - URLProtocol
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
|
||||
override func startLoading() {
|
||||
let stub = Self.currentStub()
|
||||
Self.record(request, body: Self.bodyData(from: request))
|
||||
|
||||
guard let client = client else { return }
|
||||
|
||||
if let error = stub.error {
|
||||
client.urlProtocol(self, didFailWithError: error)
|
||||
return
|
||||
}
|
||||
|
||||
let url = request.url ?? URL(string: "https://stub.invalid")!
|
||||
let response = HTTPURLResponse(url: url,
|
||||
statusCode: stub.statusCode,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: stub.headers)!
|
||||
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client.urlProtocol(self, didLoad: stub.body)
|
||||
client.urlProtocolDidFinishLoading(self)
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
|
||||
// MARK: - Body extraction
|
||||
|
||||
private static func bodyData(from request: URLRequest) -> Data {
|
||||
if let body = request.httpBody { return body }
|
||||
guard let stream = request.httpBodyStream else { return Data() }
|
||||
stream.open()
|
||||
defer { stream.close() }
|
||||
var data = Data()
|
||||
let bufferSize = 64 * 1024
|
||||
var buffer = [UInt8](repeating: 0, count: bufferSize)
|
||||
while stream.hasBytesAvailable {
|
||||
let read = stream.read(&buffer, maxLength: bufferSize)
|
||||
if read <= 0 { break }
|
||||
data.append(buffer, count: read)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user