[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:
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
Foundation, value-type, string, collection, numeric, date, and crypto helpers, plus
|
Foundation, value-type, string, collection, numeric, date, and crypto helpers, plus
|
||||||
the `LCEssentials` namespace itself. UIKit extensions live in
|
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.
|
Every section is a collapsible block — click a heading to expand it.
|
||||||
|
|
||||||
|
|||||||
175
Documentation/FeatureControl.md
Normal file
175
Documentation/FeatureControl.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>FeatureControlConfiguration</b> — wiring for one module deployment</summary>
|
||||||
|
|
||||||
|
```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 |
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Auth strategies</b> — <code>FeatureControlAuthorizing</code></summary>
|
||||||
|
|
||||||
|
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])
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Evaluating flags
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary><b>FeatureControlManager</b> — cache, safe-degrade, invalidation</summary>
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Notifications inbox
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>FeatureControlNotificationsClient</b> — JWT-only</summary>
|
||||||
|
|
||||||
|
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()
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Exposure telemetry
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>recordExposure(_:)</b> / <b>flushExposures()</b> — best-effort, batched</summary>
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>FeatureControlError</b> — from <code>evaluateOrThrow</code> and the notifications client</summary>
|
||||||
|
|
||||||
|
```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).
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
See [Extensions.md](Extensions.md) for the rest of `LCEssentials`' Foundation
|
||||||
|
helpers, including the sibling `LCECryptoKit` sub-product.
|
||||||
@@ -3,18 +3,19 @@ import PackageDescription
|
|||||||
|
|
||||||
// LCECryptoKit ships as a prebuilt .xcframework (`Frameworks/LCECryptoKit.xcframework`) —
|
// LCECryptoKit ships as a prebuilt .xcframework (`Frameworks/LCECryptoKit.xcframework`) —
|
||||||
// vendored locally in this repo, no remote package dependency. iOS device + simulator slices
|
// 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
|
// Sub-SPM rule: `LCEssentials` installs standalone (no sub required). Every sub
|
||||||
// (`LCECryptoKit` here) target-depends on `LCEssentials`, so linking the sub's product always
|
// (`LCECryptoKit`, `LCFeatureControl`) target-depends on `LCEssentials`, so linking the sub's
|
||||||
// pulls `LCEssentials` in too — a consumer never has to declare it separately.
|
// 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(
|
let package = Package(
|
||||||
name: "LCEssentials",
|
name: "LCEssentials",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v15),
|
.iOS(.v15),
|
||||||
.macOS(.v10_15),
|
|
||||||
.tvOS(.v13),
|
|
||||||
.watchOS(.v8)
|
.watchOS(.v8)
|
||||||
],
|
],
|
||||||
products: [
|
products: [
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import LCEssentials
|
|||||||
| **[Extensions.md](Documentation/Extensions.md)** | Foundation / value-type / string / collection / numeric / date / crypto extensions and the `LCEssentials` namespace |
|
| **[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 |
|
| **[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) |
|
| **[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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public actor FeatureControlManager: FeatureControlEvaluating {
|
|||||||
private let defaults: [String: FeatureControlFlag]
|
private let defaults: [String: FeatureControlFlag]
|
||||||
private let cache: FeatureControlCache
|
private let cache: FeatureControlCache
|
||||||
private var exposureBuffer: [FeatureControlExposureEvent] = []
|
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<FeatureControlSnapshot, Error>] = [:]
|
||||||
|
|
||||||
/// `configVersion` sentinel returned when no network response and no cache exist —
|
/// `configVersion` sentinel returned when no network response and no cache exist —
|
||||||
/// distinguishes "never evaluated" from any real server value (server versions are ≥ 0).
|
/// distinguishes "never evaluated" from any real server value (server versions are ≥ 0).
|
||||||
@@ -52,26 +55,41 @@ public actor FeatureControlManager: FeatureControlEvaluating {
|
|||||||
let key = cacheKey(keys: keys, context: context)
|
let key = cacheKey(keys: keys, context: context)
|
||||||
if let fresh = await cache.get(key, allowStale: false) { return fresh }
|
if let fresh = await cache.get(key, allowStale: false) { return fresh }
|
||||||
|
|
||||||
|
if let inFlightTask = inFlight[key] {
|
||||||
|
return try await inFlightTask.value
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = Task<FeatureControlSnapshot, Error> {
|
||||||
var headers: [String: String] = [:]
|
var headers: [String: String] = [:]
|
||||||
await configuration.auth.authorize(&headers)
|
await self.configuration.auth.authorize(&headers)
|
||||||
|
|
||||||
let body = jsonBody(FeatureControlEvaluateRequestBody(
|
let body = jsonBody(FeatureControlEvaluateRequestBody(
|
||||||
environment: configuration.environment, keys: keys, context: context))
|
environment: self.configuration.environment, keys: keys, context: context))
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let envelope: FeatureControlEvaluateEnvelope = try await api.request(
|
let envelope: FeatureControlEvaluateEnvelope = try await self.api.request(
|
||||||
url: configuration.baseURL + configuration.evaluatePath,
|
url: self.configuration.baseURL + self.configuration.evaluatePath,
|
||||||
method: .post,
|
method: .post,
|
||||||
body: body,
|
body: body,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
timeoutInterval: configuration.requestTimeout
|
debug: false,
|
||||||
|
timeoutInterval: self.configuration.requestTimeout
|
||||||
)
|
)
|
||||||
await cache.set(key, snapshot: envelope.result, ttl: configuration.cacheTTL)
|
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
|
return envelope.result
|
||||||
|
} catch let error as FeatureControlError {
|
||||||
|
throw error
|
||||||
} catch {
|
} catch {
|
||||||
throw FeatureControlErrorMapper.map(error)
|
throw FeatureControlErrorMapper.map(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
inFlight[key] = task
|
||||||
|
defer { inFlight[key] = nil }
|
||||||
|
return try await task.value
|
||||||
|
}
|
||||||
|
|
||||||
public func invalidateCache() async {
|
public func invalidateCache() async {
|
||||||
await cache.invalidateAll()
|
await cache.invalidateAll()
|
||||||
@@ -110,6 +128,7 @@ extension FeatureControlManager {
|
|||||||
method: .post,
|
method: .post,
|
||||||
body: body,
|
body: body,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
|
debug: false,
|
||||||
timeoutInterval: configuration.requestTimeout
|
timeoutInterval: configuration.requestTimeout
|
||||||
) as FeatureControlExposureBatchEnvelope
|
) as FeatureControlExposureBatchEnvelope
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import LCEssentials
|
|||||||
|
|
||||||
/// Protocol seam for DI, mirroring `FeatureControlEvaluating`.
|
/// Protocol seam for DI, mirroring `FeatureControlEvaluating`.
|
||||||
public protocol FeatureControlNotifying: Sendable {
|
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?)
|
-> (items: [FeatureControlNotification], nextCursor: String?)
|
||||||
func markRead(id: String) async throws
|
func markRead(id: String) async throws
|
||||||
func markAllRead() async throws
|
func markAllRead() async throws
|
||||||
@@ -22,20 +22,29 @@ public actor FeatureControlNotificationsClient: FeatureControlNotifying {
|
|||||||
self.api = api
|
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?) {
|
-> (items: [FeatureControlNotification], nextCursor: String?) {
|
||||||
var headers: [String: String] = [:]
|
var headers: [String: String] = [:]
|
||||||
await configuration.auth.authorize(&headers)
|
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 {
|
do {
|
||||||
let envelope: FeatureControlNotificationListEnvelope = try await api.request(
|
let envelope: FeatureControlNotificationListEnvelope = try await api.request(
|
||||||
url: configuration.baseURL + configuration.notificationsPath + query,
|
url: configuration.baseURL + configuration.notificationsPath + query,
|
||||||
method: .get,
|
method: .get,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
|
debug: false,
|
||||||
timeoutInterval: configuration.requestTimeout
|
timeoutInterval: configuration.requestTimeout
|
||||||
)
|
)
|
||||||
|
guard !envelope.error else {
|
||||||
|
throw FeatureControlError.server(code: "UNKNOWN", status: 200)
|
||||||
|
}
|
||||||
return (envelope.result.items, envelope.result.nextCursor)
|
return (envelope.result.items, envelope.result.nextCursor)
|
||||||
|
} catch let error as FeatureControlError {
|
||||||
|
throw error
|
||||||
} catch {
|
} catch {
|
||||||
throw FeatureControlErrorMapper.map(error)
|
throw FeatureControlErrorMapper.map(error)
|
||||||
}
|
}
|
||||||
@@ -51,6 +60,7 @@ public actor FeatureControlNotificationsClient: FeatureControlNotifying {
|
|||||||
url: configuration.baseURL + configuration.notificationsPath + "/\(encodedId)/read",
|
url: configuration.baseURL + configuration.notificationsPath + "/\(encodedId)/read",
|
||||||
method: .post,
|
method: .post,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
|
debug: false,
|
||||||
timeoutInterval: configuration.requestTimeout
|
timeoutInterval: configuration.requestTimeout
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -67,6 +77,7 @@ public actor FeatureControlNotificationsClient: FeatureControlNotifying {
|
|||||||
url: configuration.baseURL + configuration.notificationsPath + "/read-all",
|
url: configuration.baseURL + configuration.notificationsPath + "/read-all",
|
||||||
method: .post,
|
method: .post,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
|
debug: false,
|
||||||
timeoutInterval: configuration.requestTimeout
|
timeoutInterval: configuration.requestTimeout
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -4,18 +4,10 @@ import XCTest
|
|||||||
|
|
||||||
final class FeatureControlErrorTests: XCTestCase {
|
final class FeatureControlErrorTests: XCTestCase {
|
||||||
|
|
||||||
private var api: API!
|
private var api = API.lce_featureControlTestInstance()
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
|
||||||
cfg.protocolClasses = [StubURLProtocol.self]
|
|
||||||
api = API(testConfiguration: cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
StubURLProtocol.reset()
|
StubURLProtocol.reset()
|
||||||
api = nil
|
|
||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,18 +4,10 @@ import XCTest
|
|||||||
|
|
||||||
final class FeatureControlExposureTests: XCTestCase {
|
final class FeatureControlExposureTests: XCTestCase {
|
||||||
|
|
||||||
private var api: API!
|
private var api = API.lce_featureControlTestInstance()
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
|
||||||
cfg.protocolClasses = [StubURLProtocol.self]
|
|
||||||
api = API(testConfiguration: cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
StubURLProtocol.reset()
|
StubURLProtocol.reset()
|
||||||
api = nil
|
|
||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +43,20 @@ final class FeatureControlExposureTests: XCTestCase {
|
|||||||
XCTAssertEqual(StubURLProtocol.requestCount, 2)
|
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 {
|
func testFailedBatchIsDroppedNotRetriedIndefinitely() async {
|
||||||
StubURLProtocol.setStub(.init(statusCode: 500, body: Data()))
|
StubURLProtocol.setStub(.init(statusCode: 500, body: Data()))
|
||||||
let manager = makeManager()
|
let manager = makeManager()
|
||||||
|
|||||||
@@ -9,18 +9,10 @@ private final class MutableClock: @unchecked Sendable {
|
|||||||
|
|
||||||
final class FeatureControlManagerTests: XCTestCase {
|
final class FeatureControlManagerTests: XCTestCase {
|
||||||
|
|
||||||
private var api: API!
|
private var api = API.lce_featureControlTestInstance()
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
|
||||||
cfg.protocolClasses = [StubURLProtocol.self]
|
|
||||||
api = API(testConfiguration: cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
StubURLProtocol.reset()
|
StubURLProtocol.reset()
|
||||||
api = nil
|
|
||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,4 +149,39 @@ final class FeatureControlManagerTests: XCTestCase {
|
|||||||
// No `try` above this line compiles only because `evaluate` truly never throws.
|
// No `try` above this line compiles only because `evaluate` truly never throws.
|
||||||
_ = await manager.evaluate(keys: ["x"], context: context())
|
_ = 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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ final class FeatureControlModelsTests: XCTestCase {
|
|||||||
let envelope = try JSONDecoder().decode(FeatureControlEvaluateEnvelope.self, from: data)
|
let envelope = try JSONDecoder().decode(FeatureControlEvaluateEnvelope.self, from: data)
|
||||||
|
|
||||||
var calendar = Calendar(identifier: .gregorian)
|
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],
|
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second],
|
||||||
from: envelope.result.evaluatedAt)
|
from: envelope.result.evaluatedAt)
|
||||||
XCTAssertEqual(components.year, 2026)
|
XCTAssertEqual(components.year, 2026)
|
||||||
@@ -60,6 +60,34 @@ final class FeatureControlModelsTests: XCTestCase {
|
|||||||
XCTAssertEqual(components.hour, 12)
|
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 {
|
func testContextEncodesExactOpenAPIShape() throws {
|
||||||
let context = FeatureControlContext(subjectType: .customer, subjectId: "cust_123",
|
let context = FeatureControlContext(subjectType: .customer, subjectId: "cust_123",
|
||||||
storeId: "store_001", platform: "ios",
|
storeId: "store_001", platform: "ios",
|
||||||
@@ -113,6 +141,18 @@ final class FeatureControlModelsTests: XCTestCase {
|
|||||||
XCTAssertNil(notification.ctaUrl)
|
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 {
|
func testExposureEventEncodesExpectedShape() throws {
|
||||||
let event = FeatureControlExposureEvent(featureKey: "fc.checkout_v2", variant: "on",
|
let event = FeatureControlExposureEvent(featureKey: "fc.checkout_v2", variant: "on",
|
||||||
subjectType: .customer, storeId: "store_001")
|
subjectType: .customer, storeId: "store_001")
|
||||||
|
|||||||
@@ -4,18 +4,10 @@ import XCTest
|
|||||||
|
|
||||||
final class FeatureControlNotificationsClientTests: XCTestCase {
|
final class FeatureControlNotificationsClientTests: XCTestCase {
|
||||||
|
|
||||||
private var api: API!
|
private var api = API.lce_featureControlTestInstance()
|
||||||
|
|
||||||
override func setUp() {
|
|
||||||
super.setUp()
|
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
|
||||||
cfg.protocolClasses = [StubURLProtocol.self]
|
|
||||||
api = API(testConfiguration: cfg)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
StubURLProtocol.reset()
|
StubURLProtocol.reset()
|
||||||
api = nil
|
|
||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +69,43 @@ final class FeatureControlNotificationsClientTests: XCTestCase {
|
|||||||
"https://api.example.com/api/feature-control/notifications/read-all")
|
"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 {
|
func testUnauthorizedMapsToFeatureControlErrorNotCrash() async {
|
||||||
StubURLProtocol.setStub(.init(statusCode: 401, body: Data(#"{"code":"FEATURE_CONTROL_MISSING_AUTH"}"#.utf8)))
|
StubURLProtocol.setStub(.init(statusCode: 401, body: Data(#"{"code":"FEATURE_CONTROL_MISSING_AUTH"}"#.utf8)))
|
||||||
let client = makeClient()
|
let client = makeClient()
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import Foundation
|
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`
|
/// Test double for `URLProtocol`. Intercepts every request on a `URLSession`
|
||||||
/// configured with it, records the outgoing `URLRequest`, and replays a canned
|
/// configured with it, records the outgoing `URLRequest`, and replays a canned
|
||||||
@@ -56,6 +67,13 @@ final class StubURLProtocol: URLProtocol, @unchecked Sendable {
|
|||||||
return _capturedRequests.count
|
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 {
|
private static func currentStub() -> Stub {
|
||||||
lock.lock(); defer { lock.unlock() }
|
lock.lock(); defer { lock.unlock() }
|
||||||
return _stub
|
return _stub
|
||||||
@@ -84,11 +102,14 @@ final class StubURLProtocol: URLProtocol, @unchecked Sendable {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = request.url ?? URL(string: "https://stub.invalid")!
|
guard let url = request.url ?? URL(string: "https://stub.invalid"),
|
||||||
let response = HTTPURLResponse(url: url,
|
let response = HTTPURLResponse(url: url,
|
||||||
statusCode: stub.statusCode,
|
statusCode: stub.statusCode,
|
||||||
httpVersion: "HTTP/1.1",
|
httpVersion: "HTTP/1.1",
|
||||||
headerFields: stub.headers)!
|
headerFields: stub.headers) else {
|
||||||
|
client.urlProtocol(self, didFailWithError: URLError(.badServerResponse))
|
||||||
|
return
|
||||||
|
}
|
||||||
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||||
client.urlProtocol(self, didLoad: stub.body)
|
client.urlProtocol(self, didLoad: stub.body)
|
||||||
client.urlProtocolDidFinishLoading(self)
|
client.urlProtocolDidFinishLoading(self)
|
||||||
|
|||||||
Reference in New Issue
Block a user