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