[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

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