[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

@@ -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)")
}
}
}