[lcfeaturecontrol-review-fixes] Fix JWT log leak, drop macOS/tvOS, coalesce requests

- FeatureControlManager/NotificationsClient: pass debug: false at every
  api.request(...) call site — the API default (debug: true) was printing
  Authorization: Bearer <jwt> on every request, release builds included.
- Package.swift: drop macOS/tvOS from platforms — LCEssentials.API (used by
  LCFeatureControl) is iOS/watchOS-only and the vendored xcframework has no
  macOS/tvOS slice.
- FeatureControlManager: coalesce concurrent evaluate() calls on a cold cache
  into a single in-flight request per key instead of firing one per caller.
- FeatureControlManager/NotificationsClient: treat a 200 response carrying
  {"error": true} as a failure instead of caching/returning it as success.
- FeatureControlNotificationsClient: thread a cursor param through list() so
  the already-decoded nextCursor can actually be used to page.
- Tests: 9 new tests (coalescing, envelope-error-on-200, cursor param, a real
  object payload decoded through a full evaluate response, date-decode
  failures) and removed the 7 remaining force-unwraps in test scaffolding.
- Documentation/FeatureControl.md: new guide for the LCFeatureControl product,
  cross-linked from README.md and Extensions.md.
This commit is contained in:
Daniel Arantes Loverde
2026-09-16 13:21:10 -03:00
parent 2ba487a6e8
commit c6e9803c75
12 changed files with 392 additions and 69 deletions

View File

@@ -4,18 +4,10 @@ import XCTest
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)
}
private var api = API.lce_featureControlTestInstance()
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}

View File

@@ -4,18 +4,10 @@ import XCTest
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)
}
private var api = API.lce_featureControlTestInstance()
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
@@ -51,6 +43,20 @@ final class FeatureControlExposureTests: XCTestCase {
XCTAssertEqual(StubURLProtocol.requestCount, 2)
}
func testFlushSplitsExactlyAtTheServerBatchLimit() async {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"error":false,"code":"OK"}"#.utf8)))
let manager = makeManager()
for i in 0..<101 { await manager.recordExposure(event(i)) } // maxItems: 100, plus 1
await manager.flushExposures()
XCTAssertEqual(StubURLProtocol.requestCount, 2)
let eventCountsPerBatch = StubURLProtocol.capturedBodies.map { body in
String(decoding: body, as: UTF8.self).components(separatedBy: "\"featureKey\"").count - 1
}
XCTAssertEqual(eventCountsPerBatch, [100, 1])
}
func testFailedBatchIsDroppedNotRetriedIndefinitely() async {
StubURLProtocol.setStub(.init(statusCode: 500, body: Data()))
let manager = makeManager()

View File

@@ -9,18 +9,10 @@ private final class MutableClock: @unchecked Sendable {
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)
}
private var api = API.lce_featureControlTestInstance()
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
@@ -157,4 +149,39 @@ final class FeatureControlManagerTests: XCTestCase {
// No `try` above this line compiles only because `evaluate` truly never throws.
_ = await manager.evaluate(keys: ["x"], context: context())
}
// MARK: - Concurrency & envelope validation
func testConcurrentEvaluateOnColdCacheCoalescesIntoSingleRequest() async {
StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7)))
let manager = makeManager()
let ctx = context()
async let first = manager.evaluate(keys: ["fc.checkout_v2"], context: ctx)
async let second = manager.evaluate(keys: ["fc.checkout_v2"], context: ctx)
async let third = manager.evaluate(keys: ["fc.checkout_v2"], context: ctx)
let results = await [first, second, third]
XCTAssertEqual(Set(results.map(\.configVersion)), [7])
XCTAssertEqual(StubURLProtocol.requestCount, 1)
}
func testEvaluateOrThrowThrowsWhenEnvelopeErrorIsTrueDespite200() async {
let body = """
{"error": true, "code": "FEATURE_CONTROL_DEGRADED", "result": {
"evaluatedAt": "2026-04-16T12:00:00.000Z", "configVersion": 1, "flags": {}
}}
"""
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(body.utf8)))
let manager = makeManager()
do {
_ = try await manager.evaluateOrThrow(keys: ["x"], context: context())
XCTFail("expected throw")
} catch let error as FeatureControlError {
XCTAssertEqual(error, .server(code: "FEATURE_CONTROL_DEGRADED", status: 200))
} catch {
XCTFail("expected FeatureControlError, got \(error)")
}
}
}

View File

@@ -51,7 +51,7 @@ final class FeatureControlModelsTests: XCTestCase {
let envelope = try JSONDecoder().decode(FeatureControlEvaluateEnvelope.self, from: data)
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
calendar.timeZone = try XCTUnwrap(TimeZone(identifier: "UTC"))
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second],
from: envelope.result.evaluatedAt)
XCTAssertEqual(components.year, 2026)
@@ -60,6 +60,34 @@ final class FeatureControlModelsTests: XCTestCase {
XCTAssertEqual(components.hour, 12)
}
func testEvaluatedAtDecodeFailsOnUnrecognizedDateFormat() {
let json = #"{"evaluatedAt": "not-a-date", "configVersion": 1, "flags": {}}"#
XCTAssertThrowsError(try JSONDecoder().decode(FeatureControlSnapshot.self, from: Data(json.utf8))) { error in
guard case DecodingError.dataCorrupted = error else {
return XCTFail("expected .dataCorrupted, got \(error)")
}
}
}
func testFlagDecodesObjectPayloadThroughEvaluateEnvelope() throws {
let json = """
{"error": false, "code": "FEATURE_CONTROL_EVALUATED", "result": {
"evaluatedAt": "2026-04-16T12:00:00.000Z",
"configVersion": 3,
"flags": {"fc.promo_banner": {"enabled": true, "variant": "on",
"payload": {"title": "Sale", "limit": 5, "tags": ["a", "b"]}, "reason": "rollout"}}
}}
"""
let envelope = try JSONDecoder().decode(FeatureControlEvaluateEnvelope.self, from: Data(json.utf8))
let flag = try XCTUnwrap(envelope.result.flags["fc.promo_banner"])
guard case let .object(payload) = flag.payload else {
return XCTFail("expected .object payload, got \(String(describing: flag.payload))")
}
XCTAssertEqual(payload["title"], .string("Sale"))
XCTAssertEqual(payload["limit"], .number(5))
XCTAssertEqual(payload["tags"], .array([.string("a"), .string("b")]))
}
func testContextEncodesExactOpenAPIShape() throws {
let context = FeatureControlContext(subjectType: .customer, subjectId: "cust_123",
storeId: "store_001", platform: "ios",
@@ -113,6 +141,18 @@ final class FeatureControlModelsTests: XCTestCase {
XCTAssertNil(notification.ctaUrl)
}
func testNotificationCreatedAtDecodeFailsOnUnrecognizedDateFormat() {
let json = """
{"id": "n3", "title": "T", "body": "B", "severity": "info",
"createdAt": "not-a-date", "read": false}
"""
XCTAssertThrowsError(try JSONDecoder().decode(FeatureControlNotification.self, from: Data(json.utf8))) { error in
guard case DecodingError.dataCorrupted = error else {
return XCTFail("expected .dataCorrupted, got \(error)")
}
}
}
func testExposureEventEncodesExpectedShape() throws {
let event = FeatureControlExposureEvent(featureKey: "fc.checkout_v2", variant: "on",
subjectType: .customer, storeId: "store_001")

View File

@@ -4,18 +4,10 @@ import XCTest
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)
}
private var api = API.lce_featureControlTestInstance()
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
@@ -77,6 +69,43 @@ final class FeatureControlNotificationsClientTests: XCTestCase {
"https://api.example.com/api/feature-control/notifications/read-all")
}
func testListIncludesEncodedCursorWhenProvided() 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: .all, limit: 20, cursor: "cursor abc")
let url = StubURLProtocol.capturedRequests.first?.url?.absoluteString
XCTAssertEqual(url, "https://api.example.com/api/feature-control/notifications?status=all&limit=20&cursor=cursor%20abc")
}
func testListOmitsCursorWhenNil() 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: .all, limit: 20)
let url = StubURLProtocol.capturedRequests.first?.url?.absoluteString
XCTAssertEqual(url, "https://api.example.com/api/feature-control/notifications?status=all&limit=20")
}
func testListThrowsWhenEnvelopeErrorIsTrueDespite200() async {
let body = #"{"error": true, "result": {"items": [], "nextCursor": null}}"#
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(body.utf8)))
let client = makeClient()
do {
_ = try await client.list(status: .all, limit: 20)
XCTFail("expected throw")
} catch let error as FeatureControlError {
XCTAssertEqual(error, .server(code: "UNKNOWN", status: 200))
} catch {
XCTFail("expected FeatureControlError, got \(error)")
}
}
func testUnauthorizedMapsToFeatureControlErrorNotCrash() async {
StubURLProtocol.setStub(.init(statusCode: 401, body: Data(#"{"code":"FEATURE_CONTROL_MISSING_AUTH"}"#.utf8)))
let client = makeClient()

View File

@@ -1,4 +1,15 @@
import Foundation
@testable import LCEssentials
/// Shared factory so test classes don't each declare a force-unwrapped `API!`
/// var for the XCTest setUp/tearDown lifecycle.
extension API {
static func lce_featureControlTestInstance() -> API {
let cfg = URLSessionConfiguration.ephemeral
cfg.protocolClasses = [StubURLProtocol.self]
return API(testConfiguration: cfg)
}
}
/// Test double for `URLProtocol`. Intercepts every request on a `URLSession`
/// configured with it, records the outgoing `URLRequest`, and replays a canned
@@ -56,6 +67,13 @@ final class StubURLProtocol: URLProtocol, @unchecked Sendable {
return _capturedRequests.count
}
/// Bodies of every intercepted request, in order for tests asserting batch
/// boundaries across multiple requests (`lastCapturedBody` only sees the last one).
static var capturedBodies: [Data] {
lock.lock(); defer { lock.unlock() }
return _capturedBodies
}
private static func currentStub() -> Stub {
lock.lock(); defer { lock.unlock() }
return _stub
@@ -84,11 +102,14 @@ final class StubURLProtocol: URLProtocol, @unchecked Sendable {
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)!
guard let url = request.url ?? URL(string: "https://stub.invalid"),
let response = HTTPURLResponse(url: url,
statusCode: stub.statusCode,
httpVersion: "HTTP/1.1",
headerFields: stub.headers) else {
client.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
return
}
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client.urlProtocol(self, didLoad: stub.body)
client.urlProtocolDidFinishLoading(self)