[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:
124
Tests/LCFeatureControlTests/FeatureControlModelsTests.swift
Normal file
124
Tests/LCFeatureControlTests/FeatureControlModelsTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user