From c6e9803c753efc7a047f4eacccb8373b21bfc2d5 Mon Sep 17 00:00:00 2001 From: Daniel Arantes Loverde Date: Wed, 16 Sep 2026 13:21:10 -0300 Subject: [PATCH] [lcfeaturecontrol-review-fixes] Fix JWT log leak, drop macOS/tvOS, coalesce requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FeatureControlManager/NotificationsClient: pass debug: false at every api.request(...) call site — the API default (debug: true) was printing Authorization: Bearer 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. --- Documentation/Extensions.md | 3 +- Documentation/FeatureControl.md | 175 ++++++++++++++++++ Package.swift | 11 +- README.md | 1 + .../FeatureControlManager.swift | 55 ++++-- .../FeatureControlNotificationsClient.swift | 17 +- .../FeatureControlErrorTests.swift | 10 +- .../FeatureControlExposureTests.swift | 24 ++- .../FeatureControlManagerTests.swift | 45 ++++- .../FeatureControlModelsTests.swift | 42 ++++- ...atureControlNotificationsClientTests.swift | 47 ++++- .../Support/StubURLProtocol.swift | 31 +++- 12 files changed, 392 insertions(+), 69 deletions(-) create mode 100644 Documentation/FeatureControl.md 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 strategiesFeatureControlAuthorizing + +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)