[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,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)")
}
}
}