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