[sub-spm-optional-products] Add LCECryptoKit and LCFeatureControl as optional sub-SPM products

LCEssentials installs standalone; each sub target-depends on it so linking a
sub's product always pulls LCEssentials in too, without the consumer having to
declare it separately.

- LCECryptoKit: internalized from the remote LCECryptoKitBinary git dependency
  (embedded token URL removed) into a local binaryTarget vendoring
  Frameworks/LCECryptoKit.xcframework. LCECryptoKitManager moved out of
  LCEssentials core into its own LCECryptoKitManager target/product; the
  no-op fallback for when the binary wasn't linked is gone (breaking change
  for existing consumers, see decisions/2026-09-15-sub-spm-optional-products.md).
- LCFeatureControl: new product wrapping Atomenta's Feature Control API
  (flag evaluation with TTL cache + safe-degrade fallback to defaults,
  notifications inbox, batched exposure telemetry). 45 new tests.
This commit is contained in:
Daniel Arantes Loverde
2026-09-16 09:23:57 -03:00
parent d067791930
commit 2ba487a6e8
47 changed files with 4264 additions and 113 deletions

View File

@@ -0,0 +1,38 @@
import XCTest
@testable import LCFeatureControl
final class FeatureControlAuthTests: XCTestCase {
func testBearerAuthAddsAuthorizationHeader() async {
let auth = FeatureControlBearerAuth { "jwt-123" }
var headers: [String: String] = [:]
await auth.authorize(&headers)
XCTAssertEqual(headers["Authorization"], "Bearer jwt-123")
}
func testBearerAuthAddsNoHeaderWhenTokenIsNil() async {
let auth = FeatureControlBearerAuth { nil }
var headers: [String: String] = [:]
await auth.authorize(&headers)
XCTAssertNil(headers["Authorization"])
}
func testHeaderAuthAddsExactHeadersOnly() async {
let auth = FeatureControlHeaderAuth(headers: ["X-BFF-Session": "abc"])
var headers: [String: String] = [:]
await auth.authorize(&headers)
XCTAssertEqual(headers, ["X-BFF-Session": "abc"])
XCTAssertNil(headers["Authorization"])
}
func testConfigurationDefaults() {
let config = FeatureControlConfiguration(baseURL: "https://api.example.com",
environment: "production",
auth: FeatureControlHeaderAuth(headers: [:]))
XCTAssertEqual(config.cacheTTL, 45)
XCTAssertEqual(config.requestTimeout, 3)
XCTAssertEqual(config.evaluatePath, "/api/feature-control/evaluate")
XCTAssertEqual(config.notificationsPath, "/api/feature-control/notifications")
XCTAssertEqual(config.telemetryPath, "/api/feature-control/telemetry/exposure")
}
}

View File

@@ -0,0 +1,70 @@
import XCTest
@testable import LCFeatureControl
/// Test-only mutable clock box a `var` captured directly by the cache's
/// escaping `@Sendable` closure would trip strict-concurrency capture checks;
/// this mirrors the `@unchecked Sendable` pattern `StubURLProtocol` already uses.
private final class MutableClock: @unchecked Sendable {
var value: Date
init(_ value: Date) { self.value = value }
}
final class FeatureControlCacheTests: XCTestCase {
private func snapshot(configVersion: Int) -> FeatureControlSnapshot {
FeatureControlSnapshot(evaluatedAt: Date(), configVersion: configVersion, flags: [:])
}
private func key(_ keys: [String] = ["a", "b"]) -> FeatureControlCacheKey {
FeatureControlCacheKey(environment: "production", subjectId: "cust_1",
sortedKeys: keys.sorted(), platform: "ios", appVersion: "1.0")
}
func testHitWithinTTLReturnsSameSnapshot() async {
let cache = FeatureControlCache(now: { Date() })
await cache.set(key(), snapshot: snapshot(configVersion: 1), ttl: 45)
let hit = await cache.get(key(), allowStale: false)
XCTAssertEqual(hit?.configVersion, 1)
}
func testMissAfterTTLExpiryReturnsNilWhenStaleNotAllowed() async {
let clock = MutableClock(Date())
let cache = FeatureControlCache(now: { clock.value })
await cache.set(key(), snapshot: snapshot(configVersion: 1), ttl: 1)
clock.value = clock.value.addingTimeInterval(2)
let hit = await cache.get(key(), allowStale: false)
XCTAssertNil(hit)
}
func testStaleAllowedReturnsExpiredEntry() async {
let clock = MutableClock(Date())
let cache = FeatureControlCache(now: { clock.value })
await cache.set(key(), snapshot: snapshot(configVersion: 1), ttl: 1)
clock.value = clock.value.addingTimeInterval(2)
let hit = await cache.get(key(), allowStale: true)
XCTAssertEqual(hit?.configVersion, 1)
}
func testKeyDiffersBySortedKeysNotInputOrder() async {
let cache = FeatureControlCache(now: { Date() })
await cache.set(key(["a", "b"]), snapshot: snapshot(configVersion: 1), ttl: 45)
let hit = await cache.get(key(["b", "a"]), allowStale: false)
XCTAssertEqual(hit?.configVersion, 1)
}
func testSetOverwritesPriorConfigVersion() async {
let cache = FeatureControlCache(now: { Date() })
await cache.set(key(), snapshot: snapshot(configVersion: 7), ttl: 45)
await cache.set(key(), snapshot: snapshot(configVersion: 8), ttl: 45)
let hit = await cache.get(key(), allowStale: false)
XCTAssertEqual(hit?.configVersion, 8)
}
func testInvalidateAllClearsEntries() async {
let cache = FeatureControlCache(now: { Date() })
await cache.set(key(), snapshot: snapshot(configVersion: 1), ttl: 45)
await cache.invalidateAll()
let hit = await cache.get(key(), allowStale: true)
XCTAssertNil(hit)
}
}

View File

@@ -0,0 +1,84 @@
import XCTest
@testable import LCFeatureControl
@testable import LCEssentials
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)
}
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
private func makeManager() -> FeatureControlManager {
let config = FeatureControlConfiguration(baseURL: "https://api.example.com",
environment: "production",
auth: FeatureControlHeaderAuth(headers: [:]))
return FeatureControlManager(configuration: config, api: api)
}
private func context() -> FeatureControlContext {
FeatureControlContext(subjectType: .customer, subjectId: "cust_1")
}
private func expectMappedError(status: Int, bodyCode: String) async throws -> FeatureControlError {
StubURLProtocol.setStub(.init(statusCode: status, body: Data(#"{"code":"\#(bodyCode)"}"#.utf8)))
let manager = makeManager()
do {
_ = try await manager.evaluateOrThrow(keys: ["x"], context: context())
XCTFail("expected FeatureControlError")
throw FeatureControlError.transport(message: "unreachable")
} catch let error as FeatureControlError {
return error
}
}
func testMaps400ToInvalidContext() async throws {
let error = try await expectMappedError(status: 400, bodyCode: "FEATURE_CONTROL_CONTEXT_INVALID")
XCTAssertEqual(error, .invalidContext(code: "FEATURE_CONTROL_CONTEXT_INVALID"))
}
func testMaps401ToUnauthorized() async throws {
let error = try await expectMappedError(status: 401, bodyCode: "FEATURE_CONTROL_MISSING_AUTH")
XCTAssertEqual(error, .unauthorized(code: "FEATURE_CONTROL_MISSING_AUTH"))
}
func testMaps403ToForbidden() async throws {
let error = try await expectMappedError(status: 403, bodyCode: "FEATURE_CONTROL_PANEL_ROLE_REQUIRED")
XCTAssertEqual(error, .forbidden(code: "FEATURE_CONTROL_PANEL_ROLE_REQUIRED"))
}
func testMaps429ToRateLimited() async throws {
let error = try await expectMappedError(status: 429, bodyCode: "FEATURE_CONTROL_RATE_LIMIT")
XCTAssertEqual(error, .rateLimited(code: "FEATURE_CONTROL_RATE_LIMIT"))
}
func testMaps500ToServerWithStatusCode() async throws {
let error = try await expectMappedError(status: 500, bodyCode: "FEATURE_CONTROL_ERROR")
XCTAssertEqual(error, .server(code: "FEATURE_CONTROL_ERROR", status: 500))
}
func testDecodingFailureMapsToTransport() async {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data("not json at all".utf8)))
let manager = makeManager()
do {
_ = try await manager.evaluateOrThrow(keys: ["x"], context: context())
XCTFail("expected throw")
} catch let error as FeatureControlError {
guard case .transport = error else {
return XCTFail("expected .transport, got \(error)")
}
} catch {
XCTFail("expected FeatureControlError, got \(error)")
}
}
}

View File

@@ -0,0 +1,69 @@
import XCTest
@testable import LCFeatureControl
@testable import LCEssentials
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)
}
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
private func makeManager() -> FeatureControlManager {
let config = FeatureControlConfiguration(baseURL: "https://api.example.com",
environment: "production",
auth: FeatureControlHeaderAuth(headers: [:]))
return FeatureControlManager(configuration: config, api: api)
}
private func event(_ index: Int) -> FeatureControlExposureEvent {
FeatureControlExposureEvent(featureKey: "fc.checkout_v2", variant: "on",
subjectType: .customer, storeId: "store_\(index)")
}
func testFlushSendsSingleBatchUnderLimit() async {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"error":false,"code":"OK"}"#.utf8)))
let manager = makeManager()
for i in 0..<10 { await manager.recordExposure(event(i)) }
await manager.flushExposures()
XCTAssertEqual(StubURLProtocol.requestCount, 1)
}
func testFlushSplitsIntoMultipleBatchesOverLimit() async {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"error":false,"code":"OK"}"#.utf8)))
let manager = makeManager()
for i in 0..<130 { await manager.recordExposure(event(i)) }
await manager.flushExposures()
XCTAssertEqual(StubURLProtocol.requestCount, 2)
}
func testFailedBatchIsDroppedNotRetriedIndefinitely() async {
StubURLProtocol.setStub(.init(statusCode: 500, body: Data()))
let manager = makeManager()
for i in 0..<10 { await manager.recordExposure(event(i)) }
await manager.flushExposures() // must not throw, must not hang
XCTAssertEqual(StubURLProtocol.requestCount, 1)
// buffer was cleared even though the batch failed a second flush sends nothing new
StubURLProtocol.reset()
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(#"{"error":false,"code":"OK"}"#.utf8)))
await manager.flushExposures()
XCTAssertEqual(StubURLProtocol.requestCount, 0)
}
}

View File

@@ -0,0 +1,40 @@
import XCTest
@testable import LCFeatureControl
final class FeatureControlJSONTests: XCTestCase {
private func decode(_ json: String) throws -> FeatureControlJSON {
try JSONDecoder().decode(FeatureControlJSON.self, from: Data(json.utf8))
}
func testDecodesNull() throws {
XCTAssertEqual(try decode("null"), .null)
}
func testDecodesBool() throws {
XCTAssertEqual(try decode("true"), .bool(true))
}
func testDecodesNumber() throws {
XCTAssertEqual(try decode("5"), .number(5))
}
func testDecodesString() throws {
XCTAssertEqual(try decode("\"hello\""), .string("hello"))
}
func testDecodesArray() throws {
XCTAssertEqual(try decode("[\"a\",\"b\"]"), .array([.string("a"), .string("b")]))
}
func testDecodesNestedObject() throws {
let json = #"{"limit": 5, "tags": ["a","b"], "nested": {"x": true}}"#
let value = try decode(json)
guard case let .object(object) = value else {
return XCTFail("expected .object, got \(value)")
}
XCTAssertEqual(object["limit"], .number(5))
XCTAssertEqual(object["tags"], .array([.string("a"), .string("b")]))
XCTAssertEqual(object["nested"], .object(["x": .bool(true)]))
}
}

View File

@@ -0,0 +1,160 @@
import XCTest
@testable import LCFeatureControl
@testable import LCEssentials
private final class MutableClock: @unchecked Sendable {
var value: Date
init(_ value: Date) { self.value = value }
}
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)
}
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
private func makeManager(defaults: [String: FeatureControlFlag] = [:],
now: @escaping @Sendable () -> Date = { Date() }) -> FeatureControlManager {
let config = FeatureControlConfiguration(baseURL: "https://api.example.com",
environment: "production",
auth: FeatureControlHeaderAuth(headers: [:]))
return FeatureControlManager(configuration: config, api: api, defaults: defaults, now: now)
}
private func evaluateResponseBody(configVersion: Int) -> Data {
Data("""
{"error": false, "code": "FEATURE_CONTROL_EVALUATED", "result": {
"evaluatedAt": "2026-04-16T12:00:00.000Z",
"configVersion": \(configVersion),
"flags": {"fc.checkout_v2": {"enabled": true, "variant": "on", "payload": null, "reason": "rollout"}}
}}
""".utf8)
}
private func context() -> FeatureControlContext {
FeatureControlContext(subjectType: .customer, subjectId: "cust_1")
}
// MARK: - Happy path (T4)
func testEvaluateFetchesAndCachesOnMiss() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7)))
let manager = makeManager()
let snapshot = try await manager.evaluateOrThrow(keys: ["fc.checkout_v2"], context: context())
XCTAssertEqual(snapshot.configVersion, 7)
XCTAssertEqual(StubURLProtocol.requestCount, 1)
let sent = StubURLProtocol.capturedRequests.first
XCTAssertEqual(sent?.httpMethod, "POST")
XCTAssertEqual(sent?.url?.absoluteString, "https://api.example.com/api/feature-control/evaluate")
}
func testEvaluateReturnsCacheWithoutNetworkCallOnHit() async {
StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7)))
let manager = makeManager()
_ = await manager.evaluate(keys: ["fc.checkout_v2"], context: context())
_ = await manager.evaluate(keys: ["fc.checkout_v2"], context: context())
XCTAssertEqual(StubURLProtocol.requestCount, 1)
}
func testConfigVersionBumpReplacesCacheAfterExpiry() async {
let clock = MutableClock(Date())
StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7)))
let manager = makeManager(now: { clock.value })
let first = await manager.evaluate(keys: ["fc.checkout_v2"], context: context())
XCTAssertEqual(first.configVersion, 7)
clock.value = clock.value.addingTimeInterval(1000) // past default 45s TTL
StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 8)))
let second = await manager.evaluate(keys: ["fc.checkout_v2"], context: context())
XCTAssertEqual(second.configVersion, 8)
XCTAssertEqual(StubURLProtocol.requestCount, 1)
}
func testInvalidateCacheForcesFreshFetch() async {
StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7)))
let manager = makeManager()
_ = await manager.evaluate(keys: ["fc.checkout_v2"], context: context())
await manager.invalidateCache()
StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 9)))
let second = await manager.evaluate(keys: ["fc.checkout_v2"], context: context())
XCTAssertEqual(second.configVersion, 9)
XCTAssertEqual(StubURLProtocol.requestCount, 1)
}
func testEvaluateOrThrowPropagatesDecodingFailure() async {
StubURLProtocol.setStub(.init(statusCode: 200, body: Data("not json".utf8)))
let manager = makeManager()
do {
_ = try await manager.evaluateOrThrow(keys: ["x"], context: context())
XCTFail("expected throw")
} catch {
// any throw is correct evaluateOrThrow must not silently degrade
}
}
// MARK: - Safe-degrade fallback chain (T5)
func testEvaluate429FallsBackToStaleCache() async {
let clock = MutableClock(Date())
StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7)))
let manager = makeManager(now: { clock.value })
_ = await manager.evaluate(keys: ["fc.checkout_v2"], context: context())
clock.value = clock.value.addingTimeInterval(1000)
StubURLProtocol.setStub(.init(statusCode: 429, body: Data(#"{"code":"FEATURE_CONTROL_RATE_LIMIT"}"#.utf8)))
let result = await manager.evaluate(keys: ["fc.checkout_v2"], context: context())
XCTAssertEqual(result.configVersion, 7)
}
func testEvaluate500FallsBackToStaleCache() async {
let clock = MutableClock(Date())
StubURLProtocol.setStub(.init(statusCode: 200, body: evaluateResponseBody(configVersion: 7)))
let manager = makeManager(now: { clock.value })
_ = await manager.evaluate(keys: ["fc.checkout_v2"], context: context())
clock.value = clock.value.addingTimeInterval(1000)
StubURLProtocol.setStub(.init(statusCode: 500, body: Data()))
let result = await manager.evaluate(keys: ["fc.checkout_v2"], context: context())
XCTAssertEqual(result.configVersion, 7)
}
func testEvaluateNoCacheAndServerDownReturnsDefaults() async {
StubURLProtocol.setStub(.init(statusCode: 500, body: Data()))
let defaultFlag = FeatureControlFlag(enabled: false, variant: nil, payload: nil, reason: "default")
let manager = makeManager(defaults: ["fc.checkout_v2": defaultFlag])
let result = await manager.evaluate(keys: ["fc.checkout_v2"], context: context())
XCTAssertEqual(result.configVersion, FeatureControlManager.unresolvedConfigVersion)
XCTAssertEqual(result.flags["fc.checkout_v2"]?.enabled, false)
}
func testEvaluateNeverThrowsEvenWhenServerAlwaysErrors() async {
StubURLProtocol.setStub(.init(statusCode: 500, body: Data()))
let manager = makeManager()
// No `try` above this line compiles only because `evaluate` truly never throws.
_ = await manager.evaluate(keys: ["x"], context: context())
}
}

View File

@@ -0,0 +1,124 @@
import XCTest
@testable import LCFeatureControl
/// Exact evaluate-response shape from Atomenta's `docs/feature-control/EXTERNAL_CLIENTS.md` §3.
private let evaluateResponseFixture = """
{
"error": false,
"code": "FEATURE_CONTROL_EVALUATED",
"result": {
"evaluatedAt": "2026-04-16T12:00:00.000Z",
"configVersion": 7,
"flags": {
"fc.checkout_v2": {
"enabled": true,
"variant": "on",
"payload": null,
"reason": "rollout"
},
"fc.search_ranking_v3": {
"enabled": false,
"variant": "off",
"payload": null,
"reason": "default"
}
}
}
}
"""
final class FeatureControlModelsTests: XCTestCase {
func testFlagDecodesFromEvaluateResponseFixture() throws {
let data = Data(evaluateResponseFixture.utf8)
let envelope = try JSONDecoder().decode(FeatureControlEvaluateEnvelope.self, from: data)
XCTAssertFalse(envelope.error)
XCTAssertEqual(envelope.result.configVersion, 7)
let checkout = try XCTUnwrap(envelope.result.flags["fc.checkout_v2"])
XCTAssertTrue(checkout.enabled)
XCTAssertEqual(checkout.variant, "on")
XCTAssertNil(checkout.payload)
XCTAssertEqual(checkout.reason, "rollout")
let ranking = try XCTUnwrap(envelope.result.flags["fc.search_ranking_v3"])
XCTAssertFalse(ranking.enabled)
}
func testEvaluatedAtParsesFractionalSecondsISO8601() throws {
let data = Data(evaluateResponseFixture.utf8)
let envelope = try JSONDecoder().decode(FeatureControlEvaluateEnvelope.self, from: data)
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second],
from: envelope.result.evaluatedAt)
XCTAssertEqual(components.year, 2026)
XCTAssertEqual(components.month, 4)
XCTAssertEqual(components.day, 16)
XCTAssertEqual(components.hour, 12)
}
func testContextEncodesExactOpenAPIShape() throws {
let context = FeatureControlContext(subjectType: .customer, subjectId: "cust_123",
storeId: "store_001", platform: "ios",
appVersion: "2.3.1",
attributes: ["city": "Belo Horizonte"])
let data = try JSONEncoder().encode(context)
let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
XCTAssertEqual(object["subjectType"] as? String, "customer")
XCTAssertEqual(object["subjectId"] as? String, "cust_123")
XCTAssertEqual(object["storeId"] as? String, "store_001")
XCTAssertEqual(object["platform"] as? String, "ios")
XCTAssertEqual(object["appVersion"] as? String, "2.3.1")
let attributes = try XCTUnwrap(object["attributes"] as? [String: String])
XCTAssertEqual(attributes["city"], "Belo Horizonte")
}
func testNotificationDecodesFromOpenAPIShape() throws {
let json = """
{
"id": "n1",
"title": "Manutenção",
"body": "Janela de manutenção às 22h",
"severity": "warning",
"createdAt": "2026-04-16T12:00:00.000Z",
"read": false,
"ctaLabel": "Ver detalhes",
"ctaUrl": "https://example.com"
}
"""
let notification = try JSONDecoder().decode(FeatureControlNotification.self, from: Data(json.utf8))
XCTAssertEqual(notification.id, "n1")
XCTAssertEqual(notification.severity, "warning")
XCTAssertFalse(notification.read)
XCTAssertEqual(notification.ctaLabel, "Ver detalhes")
}
func testNotificationDecodesWithoutOptionalCTAFields() throws {
let json = """
{
"id": "n2",
"title": "Info",
"body": "Just FYI",
"severity": "info",
"createdAt": "2026-04-16T12:00:00Z",
"read": true
}
"""
let notification = try JSONDecoder().decode(FeatureControlNotification.self, from: Data(json.utf8))
XCTAssertNil(notification.ctaLabel)
XCTAssertNil(notification.ctaUrl)
}
func testExposureEventEncodesExpectedShape() throws {
let event = FeatureControlExposureEvent(featureKey: "fc.checkout_v2", variant: "on",
subjectType: .customer, storeId: "store_001")
let data = try JSONEncoder().encode(event)
let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Any])
XCTAssertEqual(object["featureKey"] as? String, "fc.checkout_v2")
XCTAssertEqual(object["subjectType"] as? String, "customer")
}
}

View File

@@ -0,0 +1,93 @@
import XCTest
@testable import LCFeatureControl
@testable import LCEssentials
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)
}
override func tearDown() {
StubURLProtocol.reset()
api = nil
super.tearDown()
}
private func makeClient() -> FeatureControlNotificationsClient {
let config = FeatureControlConfiguration(baseURL: "https://api.example.com",
environment: "production",
auth: FeatureControlBearerAuth { "jwt-123" })
return FeatureControlNotificationsClient(configuration: config, api: api)
}
func testListDecodesItemsAndNextCursor() async throws {
let body = """
{"error": false, "result": {"items": [
{"id": "n1", "title": "T", "body": "B", "severity": "info",
"createdAt": "2026-04-16T12:00:00.000Z", "read": false}
], "nextCursor": "cursor-2"}}
"""
StubURLProtocol.setStub(.init(statusCode: 200, body: Data(body.utf8)))
let client = makeClient()
let (items, nextCursor) = try await client.list(status: .unread, limit: 20)
XCTAssertEqual(items.count, 1)
XCTAssertEqual(items.first?.id, "n1")
XCTAssertEqual(nextCursor, "cursor-2")
}
func testListPassesStatusAndLimitAsQueryParams() 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: .unread, limit: 5)
let url = StubURLProtocol.capturedRequests.first?.url?.absoluteString
XCTAssertEqual(url, "https://api.example.com/api/feature-control/notifications?status=unread&limit=5")
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.value(forHTTPHeaderField: "Authorization"),
"Bearer jwt-123")
}
func testMarkReadPostsToCorrectPathAndSucceedsOn200() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, headers: ["Content-Type": "text/plain"], body: Data("ok".utf8)))
let client = makeClient()
try await client.markRead(id: "n1")
let sent = StubURLProtocol.capturedRequests.first
XCTAssertEqual(sent?.httpMethod, "POST")
XCTAssertEqual(sent?.url?.absoluteString, "https://api.example.com/api/feature-control/notifications/n1/read")
}
func testMarkAllReadSucceedsOn200() async throws {
StubURLProtocol.setStub(.init(statusCode: 200, headers: ["Content-Type": "text/plain"], body: Data("ok".utf8)))
let client = makeClient()
try await client.markAllRead()
XCTAssertEqual(StubURLProtocol.capturedRequests.first?.url?.absoluteString,
"https://api.example.com/api/feature-control/notifications/read-all")
}
func testUnauthorizedMapsToFeatureControlErrorNotCrash() async {
StubURLProtocol.setStub(.init(statusCode: 401, body: Data(#"{"code":"FEATURE_CONTROL_MISSING_AUTH"}"#.utf8)))
let client = makeClient()
do {
try await client.markAllRead()
XCTFail("expected throw")
} catch let error as FeatureControlError {
XCTAssertEqual(error, .unauthorized(code: "FEATURE_CONTROL_MISSING_AUTH"))
} catch {
XCTFail("expected FeatureControlError, got \(error)")
}
}
}

View File

@@ -0,0 +1,116 @@
import Foundation
/// Test double for `URLProtocol`. Intercepts every request on a `URLSession`
/// configured with it, records the outgoing `URLRequest`, and replays a canned
/// response or error supplied by the test.
///
/// Register via:
/// ```
/// let cfg = URLSessionConfiguration.ephemeral
/// cfg.protocolClasses = [StubURLProtocol.self]
/// let session = URLSession(configuration: cfg)
/// ```
final class StubURLProtocol: URLProtocol, @unchecked Sendable {
struct Stub {
var statusCode: Int = 200
var headers: [String: String] = ["Content-Type": "application/json"]
var body: Data = Data()
var error: Error?
/// Bytes reported through `URLSession`'s upload progress, in order.
var uploadProgressChunks: [Int] = []
}
// MARK: - Test-facing state (guarded)
private static let lock = NSLock()
// Access is serialised through `lock`; the unsafe opt-out is the documented
// pattern for lock-guarded mutable statics under strict concurrency.
nonisolated(unsafe) private static var _stub = Stub()
nonisolated(unsafe) private static var _capturedRequests: [URLRequest] = []
nonisolated(unsafe) private static var _capturedBodies: [Data] = []
static func setStub(_ stub: Stub) {
lock.lock(); defer { lock.unlock() }
_stub = stub
_capturedRequests = []
_capturedBodies = []
}
static func reset() { setStub(Stub()) }
static var capturedRequests: [URLRequest] {
lock.lock(); defer { lock.unlock() }
return _capturedRequests
}
/// Body of the last intercepted request. `URLProtocol` strips `httpBody` for
/// stream bodies, so this reads `httpBodyStream` when needed.
static var lastCapturedBody: Data? {
lock.lock(); defer { lock.unlock() }
return _capturedBodies.last
}
static var requestCount: Int {
lock.lock(); defer { lock.unlock() }
return _capturedRequests.count
}
private static func currentStub() -> Stub {
lock.lock(); defer { lock.unlock() }
return _stub
}
private static func record(_ request: URLRequest, body: Data) {
lock.lock(); defer { lock.unlock() }
_capturedRequests.append(request)
_capturedBodies.append(body)
}
// MARK: - URLProtocol
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let stub = Self.currentStub()
Self.record(request, body: Self.bodyData(from: request))
guard let client = client else { return }
if let error = stub.error {
client.urlProtocol(self, didFailWithError: error)
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)!
client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client.urlProtocol(self, didLoad: stub.body)
client.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
// MARK: - Body extraction
private static func bodyData(from request: URLRequest) -> Data {
if let body = request.httpBody { return body }
guard let stream = request.httpBodyStream else { return Data() }
stream.open()
defer { stream.close() }
var data = Data()
let bufferSize = 64 * 1024
var buffer = [UInt8](repeating: 0, count: bufferSize)
while stream.hasBytesAvailable {
let read = stream.read(&buffer, maxLength: bufferSize)
if read <= 0 { break }
data.append(buffer, count: read)
}
return data
}
}