diff --git a/Documentation/Extensions.md b/Documentation/Extensions.md
index 5a23e7f..a5bdcd8 100644
--- a/Documentation/Extensions.md
+++ b/Documentation/Extensions.md
@@ -2,7 +2,8 @@
Foundation, value-type, string, collection, numeric, date, and crypto helpers, plus
the `LCEssentials` namespace itself. UIKit extensions live in
-[UIKit.md](UIKit.md); SwiftUI helpers in [SwiftUI.md](SwiftUI.md).
+[UIKit.md](UIKit.md); SwiftUI helpers in [SwiftUI.md](SwiftUI.md); the
+`LCFeatureControl` sub-product in [FeatureControl.md](FeatureControl.md).
Every section is a collapsible block — click a heading to expand it.
diff --git a/Documentation/FeatureControl.md b/Documentation/FeatureControl.md
new file mode 100644
index 0000000..c0223ea
--- /dev/null
+++ b/Documentation/FeatureControl.md
@@ -0,0 +1,175 @@
+# LCEssentials — Feature Control
+
+A typed Swift client for Atomenta's Feature Control API: flag evaluation with a
+TTL cache and safe-degrade fallback, an in-app notifications inbox, and batched
+exposure telemetry. Lives in its own SPM product, `LCFeatureControl`, not in
+`LCEssentials` itself — an optional sub-package that target-depends on
+`LCEssentials` (same shape as [`LCECryptoKit`](Extensions.md), see its section
+there for the sub-SPM rationale).
+
+Every section is a collapsible block — click a heading to expand it.
+
+## Contents
+
+- [Setup](#setup)
+- [Evaluating flags](#evaluating-flags)
+- [Notifications inbox](#notifications-inbox)
+- [Exposure telemetry](#exposure-telemetry)
+- [Errors](#errors)
+
+---
+
+## Setup
+
+Add the product explicitly alongside `LCEssentials` to a consumer's `Package.swift`:
+
+```swift
+.product(name: "LCEssentials", package: "LCEssentials"),
+.product(name: "LCFeatureControl", package: "LCEssentials"),
+```
+
+```swift
+import LCFeatureControl
+```
+
+
+FeatureControlConfiguration — wiring for one module deployment
+
+```swift
+let configuration = FeatureControlConfiguration(
+ baseURL: "https://api.example.com",
+ environment: "production",
+ auth: FeatureControlBearerAuth { await session.currentJWT() }
+)
+```
+
+| Property | Default | Notes |
+| --- | --- | --- |
+| `cacheTTL` | `45` seconds | In-memory client-side cache window |
+| `requestTimeout` | `3` seconds | Per-request timeout budget |
+| `evaluatePath` / `notificationsPath` / `telemetryPath` | `/api/feature-control/...` | Override only for a non-default BFF mount |
+
+
+
+
+Auth strategies — FeatureControlAuthorizing
+
+No `Atomenta-Token` (module token) type exists in this package on purpose — that
+token must never be embedded in a customer-facing app. Pick one:
+
+```swift
+// Internal/admin app hitting Atomenta directly with a panel-role JWT.
+let auth = FeatureControlBearerAuth { await session.currentJWT() }
+
+// Customer app calling its own BFF, which enforces its own auth —
+// adds exactly the given headers, never synthesizes Authorization.
+let auth = FeatureControlHeaderAuth(headers: ["X-BFF-Session": sessionToken])
+```
+
+
+
+## Evaluating flags
+
+
+FeatureControlManager — cache, safe-degrade, invalidation
+
+```swift
+let manager = FeatureControlManager(
+ configuration: configuration,
+ defaults: ["fc.checkout_v2": FeatureControlFlag(enabled: false, variant: nil,
+ payload: nil, reason: "default")]
+)
+
+let context = FeatureControlContext(subjectType: .customer, subjectId: user.id,
+ storeId: store.id, platform: "ios",
+ appVersion: appVersion)
+
+let snapshot = await manager.evaluate(keys: ["fc.checkout_v2"], context: context)
+snapshot.flags["fc.checkout_v2"]?.enabled // Bool
+```
+
+`evaluate(keys:context:)` **never throws** — fresh fetch → stale cache → the
+`defaults` passed at init, in that order. Use it everywhere a flag gates real
+product behaviour. `evaluateOrThrow(keys:context:)` surfaces the real failure
+instead, for admin/debug tooling only.
+
+Concurrent calls for the same key on a cold cache are coalesced onto a single
+in-flight request — three callers evaluating the same key at once produce one
+network call, not three.
+
+```swift
+await manager.invalidateCache() // call on user/store change
+```
+
+The cache key is `environment + subjectId + sortedKeys + platform + appVersion`
+— it does **not** include `storeId` or `attributes`. If a subject's flags can
+legitimately differ by store, call `invalidateCache()` on store switch.
+
+
+
+## Notifications inbox
+
+
+FeatureControlNotificationsClient — JWT-only
+
+The OpenAPI fragment declares `security: [bearerAuth]` for every notifications
+route, no module-token alternative — configure with `FeatureControlBearerAuth`.
+
+```swift
+let client = FeatureControlNotificationsClient(configuration: configuration)
+
+let (items, nextCursor) = try await client.list(status: .unread, limit: 20)
+let (more, _) = try await client.list(status: .unread, limit: 20, cursor: nextCursor)
+
+try await client.markRead(id: notification.id)
+try await client.markAllRead()
+```
+
+
+
+## Exposure telemetry
+
+
+recordExposure(_:) / flushExposures() — best-effort, batched
+
+```swift
+await manager.recordExposure(FeatureControlExposureEvent(
+ featureKey: "fc.checkout_v2", variant: "on",
+ subjectType: .customer, storeId: store.id
+))
+// ... later, e.g. on app background or a periodic timer:
+await manager.flushExposures()
+```
+
+Buffers locally — nothing is sent until `flushExposures()` is called. Splits
+into batches of ≤100 (server `maxItems: 100`). Not a critical-path operation: a
+failed batch is dropped, never retried indefinitely.
+
+
+
+## Errors
+
+
+FeatureControlError — from evaluateOrThrow and the notifications client
+
+```swift
+public enum FeatureControlError: Error, Sendable, Equatable {
+ case invalidContext(code: String) // 400
+ case unauthorized(code: String) // 401
+ case forbidden(code: String) // 403
+ case rateLimited(code: String) // 429
+ case server(code: String, status: Int)
+ case transport(message: String) // decode failure / non-HTTP error
+}
+```
+
+The safe `evaluate(...)` path never surfaces this — it degrades to cache/defaults
+instead. `evaluateOrThrow` also throws `.server(code:status: 200)` if the server
+responds `200` with an `{"error": true}` envelope (a degraded-but-200 response).
+
+
+
+---
+
+See [Extensions.md](Extensions.md) for the rest of `LCEssentials`' Foundation
+helpers, including the sibling `LCECryptoKit` sub-product.
diff --git a/Package.swift b/Package.swift
index ac9ab4f..02e19d1 100644
--- a/Package.swift
+++ b/Package.swift
@@ -3,18 +3,19 @@ import PackageDescription
// LCECryptoKit ships as a prebuilt .xcframework (`Frameworks/LCECryptoKit.xcframework`) —
// vendored locally in this repo, no remote package dependency. iOS device + simulator slices
-// only; consumers who need macOS/tvOS/watchOS simply don't link the `LCECryptoKit` product.
+// only.
//
// Sub-SPM rule: `LCEssentials` installs standalone (no sub required). Every sub
-// (`LCECryptoKit` here) target-depends on `LCEssentials`, so linking the sub's product always
-// pulls `LCEssentials` in too — a consumer never has to declare it separately.
+// (`LCECryptoKit`, `LCFeatureControl`) target-depends on `LCEssentials`, so linking the sub's
+// product always pulls `LCEssentials` in too — a consumer never has to declare it separately.
+//
+// Platforms are iOS + watchOS only: `LCEssentials.API` (used by `LCFeatureControl`) is
+// `#if os(iOS) || os(watchOS)`, and the vendored xcframework has no macOS/tvOS slice.
let package = Package(
name: "LCEssentials",
platforms: [
.iOS(.v15),
- .macOS(.v10_15),
- .tvOS(.v13),
.watchOS(.v8)
],
products: [
diff --git a/README.md b/README.md
index 0900419..d7599e2 100644
--- a/README.md
+++ b/README.md
@@ -35,6 +35,7 @@ import LCEssentials
| **[Extensions.md](Documentation/Extensions.md)** | Foundation / value-type / string / collection / numeric / date / crypto extensions and the `LCEssentials` namespace |
| **[SwiftUI.md](Documentation/SwiftUI.md)** | SwiftUI components (`LCENavigationView`) and `View` helpers |
| **[UIKit.md](Documentation/UIKit.md)** | Programmatic layout & constraints, view/control extensions, navigation, tables, and drop-in components (`LCSnackBarView`, image picker/zoom, GIF loading) |
+| **[FeatureControl.md](Documentation/FeatureControl.md)** | `LCFeatureControl` — Atomenta Feature Control client: flag evaluation, cache/safe-degrade, notifications inbox, exposure telemetry |
---
diff --git a/Sources/LCFeatureControl/FeatureControlManager.swift b/Sources/LCFeatureControl/FeatureControlManager.swift
index bdd0fa7..b4adc36 100644
--- a/Sources/LCFeatureControl/FeatureControlManager.swift
+++ b/Sources/LCFeatureControl/FeatureControlManager.swift
@@ -21,6 +21,9 @@ public actor FeatureControlManager: FeatureControlEvaluating {
private let defaults: [String: FeatureControlFlag]
private let cache: FeatureControlCache
private var exposureBuffer: [FeatureControlExposureEvent] = []
+ /// Coalesces concurrent cold-cache callers onto one in-flight request per key,
+ /// instead of firing one POST per caller (which was hammering the 429 limit).
+ private var inFlight: [FeatureControlCacheKey: Task] = [:]
/// `configVersion` sentinel returned when no network response and no cache exist —
/// distinguishes "never evaluated" from any real server value (server versions are ≥ 0).
@@ -52,25 +55,40 @@ public actor FeatureControlManager: FeatureControlEvaluating {
let key = cacheKey(keys: keys, context: context)
if let fresh = await cache.get(key, allowStale: false) { return fresh }
- var headers: [String: String] = [:]
- await configuration.auth.authorize(&headers)
-
- let body = jsonBody(FeatureControlEvaluateRequestBody(
- environment: configuration.environment, keys: keys, context: context))
-
- do {
- let envelope: FeatureControlEvaluateEnvelope = try await api.request(
- url: configuration.baseURL + configuration.evaluatePath,
- method: .post,
- body: body,
- headers: headers,
- timeoutInterval: configuration.requestTimeout
- )
- await cache.set(key, snapshot: envelope.result, ttl: configuration.cacheTTL)
- return envelope.result
- } catch {
- throw FeatureControlErrorMapper.map(error)
+ if let inFlightTask = inFlight[key] {
+ return try await inFlightTask.value
}
+
+ let task = Task {
+ var headers: [String: String] = [:]
+ await self.configuration.auth.authorize(&headers)
+
+ let body = jsonBody(FeatureControlEvaluateRequestBody(
+ environment: self.configuration.environment, keys: keys, context: context))
+
+ do {
+ let envelope: FeatureControlEvaluateEnvelope = try await self.api.request(
+ url: self.configuration.baseURL + self.configuration.evaluatePath,
+ method: .post,
+ body: body,
+ headers: headers,
+ debug: false,
+ timeoutInterval: self.configuration.requestTimeout
+ )
+ guard !envelope.error else {
+ throw FeatureControlError.server(code: envelope.code ?? "UNKNOWN", status: 200)
+ }
+ await self.cache.set(key, snapshot: envelope.result, ttl: self.configuration.cacheTTL)
+ return envelope.result
+ } catch let error as FeatureControlError {
+ throw error
+ } catch {
+ throw FeatureControlErrorMapper.map(error)
+ }
+ }
+ inFlight[key] = task
+ defer { inFlight[key] = nil }
+ return try await task.value
}
public func invalidateCache() async {
@@ -110,6 +128,7 @@ extension FeatureControlManager {
method: .post,
body: body,
headers: headers,
+ debug: false,
timeoutInterval: configuration.requestTimeout
) as FeatureControlExposureBatchEnvelope
}
diff --git a/Sources/LCFeatureControl/FeatureControlNotificationsClient.swift b/Sources/LCFeatureControl/FeatureControlNotificationsClient.swift
index 134bdf0..d224cb6 100644
--- a/Sources/LCFeatureControl/FeatureControlNotificationsClient.swift
+++ b/Sources/LCFeatureControl/FeatureControlNotificationsClient.swift
@@ -3,7 +3,7 @@ import LCEssentials
/// Protocol seam for DI, mirroring `FeatureControlEvaluating`.
public protocol FeatureControlNotifying: Sendable {
- func list(status: FeatureControlNotificationStatus, limit: Int) async throws
+ func list(status: FeatureControlNotificationStatus, limit: Int, cursor: String?) async throws
-> (items: [FeatureControlNotification], nextCursor: String?)
func markRead(id: String) async throws
func markAllRead() async throws
@@ -22,20 +22,29 @@ public actor FeatureControlNotificationsClient: FeatureControlNotifying {
self.api = api
}
- public func list(status: FeatureControlNotificationStatus = .all, limit: Int = 20) async throws
+ public func list(status: FeatureControlNotificationStatus = .all, limit: Int = 20, cursor: String? = nil) async throws
-> (items: [FeatureControlNotification], nextCursor: String?) {
var headers: [String: String] = [:]
await configuration.auth.authorize(&headers)
- let query = "?status=\(status.rawValue)&limit=\(limit)"
+ var query = "?status=\(status.rawValue)&limit=\(limit)"
+ if let cursor, let encodedCursor = cursor.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
+ query += "&cursor=\(encodedCursor)"
+ }
do {
let envelope: FeatureControlNotificationListEnvelope = try await api.request(
url: configuration.baseURL + configuration.notificationsPath + query,
method: .get,
headers: headers,
+ debug: false,
timeoutInterval: configuration.requestTimeout
)
+ guard !envelope.error else {
+ throw FeatureControlError.server(code: "UNKNOWN", status: 200)
+ }
return (envelope.result.items, envelope.result.nextCursor)
+ } catch let error as FeatureControlError {
+ throw error
} catch {
throw FeatureControlErrorMapper.map(error)
}
@@ -51,6 +60,7 @@ public actor FeatureControlNotificationsClient: FeatureControlNotifying {
url: configuration.baseURL + configuration.notificationsPath + "/\(encodedId)/read",
method: .post,
headers: headers,
+ debug: false,
timeoutInterval: configuration.requestTimeout
)
} catch {
@@ -67,6 +77,7 @@ public actor FeatureControlNotificationsClient: FeatureControlNotifying {
url: configuration.baseURL + configuration.notificationsPath + "/read-all",
method: .post,
headers: headers,
+ debug: false,
timeoutInterval: configuration.requestTimeout
)
} catch {
diff --git a/Tests/LCFeatureControlTests/FeatureControlErrorTests.swift b/Tests/LCFeatureControlTests/FeatureControlErrorTests.swift
index 95864ac..a53fec2 100644
--- a/Tests/LCFeatureControlTests/FeatureControlErrorTests.swift
+++ b/Tests/LCFeatureControlTests/FeatureControlErrorTests.swift
@@ -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()
}
diff --git a/Tests/LCFeatureControlTests/FeatureControlExposureTests.swift b/Tests/LCFeatureControlTests/FeatureControlExposureTests.swift
index eef011f..8a6b8f6 100644
--- a/Tests/LCFeatureControlTests/FeatureControlExposureTests.swift
+++ b/Tests/LCFeatureControlTests/FeatureControlExposureTests.swift
@@ -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()
diff --git a/Tests/LCFeatureControlTests/FeatureControlManagerTests.swift b/Tests/LCFeatureControlTests/FeatureControlManagerTests.swift
index ca8d547..b6d6157 100644
--- a/Tests/LCFeatureControlTests/FeatureControlManagerTests.swift
+++ b/Tests/LCFeatureControlTests/FeatureControlManagerTests.swift
@@ -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)")
+ }
+ }
}
diff --git a/Tests/LCFeatureControlTests/FeatureControlModelsTests.swift b/Tests/LCFeatureControlTests/FeatureControlModelsTests.swift
index 2f15fa1..42c8c3b 100644
--- a/Tests/LCFeatureControlTests/FeatureControlModelsTests.swift
+++ b/Tests/LCFeatureControlTests/FeatureControlModelsTests.swift
@@ -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")
diff --git a/Tests/LCFeatureControlTests/FeatureControlNotificationsClientTests.swift b/Tests/LCFeatureControlTests/FeatureControlNotificationsClientTests.swift
index 217eb02..02d4cd9 100644
--- a/Tests/LCFeatureControlTests/FeatureControlNotificationsClientTests.swift
+++ b/Tests/LCFeatureControlTests/FeatureControlNotificationsClientTests.swift
@@ -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()
diff --git a/Tests/LCFeatureControlTests/Support/StubURLProtocol.swift b/Tests/LCFeatureControlTests/Support/StubURLProtocol.swift
index bd3c94e..8babdaa 100644
--- a/Tests/LCFeatureControlTests/Support/StubURLProtocol.swift
+++ b/Tests/LCFeatureControlTests/Support/StubURLProtocol.swift
@@ -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)