diff --git a/Sources/FeaturevisorSDK/Instance.swift b/Sources/FeaturevisorSDK/Instance.swift index 7410657..c28ce56 100644 --- a/Sources/FeaturevisorSDK/Instance.swift +++ b/Sources/FeaturevisorSDK/Instance.swift @@ -78,7 +78,8 @@ public struct Evaluation: Codable { variationValue: VariationValue? = nil, variableKey: VariableKey? = nil, variableValue: VariableValue? = nil, - variableSchema: VariableSchema? = nil) { + variableSchema: VariableSchema? = nil + ) { self.featureKey = featureKey self.reason = reason @@ -117,7 +118,8 @@ public struct Evaluation: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) featureKey = try container.decode(FeatureKey.self, forKey: .featureKey) - reason = try EvaluationReason(rawValue: container.decode(String.self, forKey: .reason)) ?? .error + reason = + try EvaluationReason(rawValue: container.decode(String.self, forKey: .reason)) ?? .error bucketValue = try container.decodeIfPresent(BucketValue.self, forKey: .bucketValue) ruleKey = try? container.decodeIfPresent(RuleKey.self, forKey: .ruleKey) enabled = try? container.decodeIfPresent(Bool.self, forKey: .enabled) @@ -125,15 +127,22 @@ public struct Evaluation: Codable { sticky = try? container.decodeIfPresent(OverrideFeature.self, forKey: .sticky) initial = try? container.decodeIfPresent(OverrideFeature.self, forKey: .initial) variation = try? container.decodeIfPresent(Variation.self, forKey: .variation) - variationValue = try? container.decodeIfPresent(VariationValue.self, forKey: .variationValue) + variationValue = try? container.decodeIfPresent( + VariationValue.self, + forKey: .variationValue + ) variableKey = try? container.decodeIfPresent(VariableKey.self, forKey: .variableKey) variableValue = try? container.decodeIfPresent(VariableValue.self, forKey: .variableValue) - variableSchema = try? container.decodeIfPresent(VariableSchema.self, forKey: .variableSchema) + variableSchema = try? container.decodeIfPresent( + VariableSchema.self, + forKey: .variableSchema + ) } func toDictionary() -> [String: Any] { guard let data = try? JSONEncoder().encode(self) else { return [:] } - return (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)).flatMap { $0 as? [String: Any]} ?? [:] + return (try? JSONSerialization.jsonObject(with: data, options: .allowFragments)) + .flatMap { $0 as? [String: Any] } ?? [:] } } diff --git a/Tests/FeaturevisorSDKTests/InstanceTests.swift b/Tests/FeaturevisorSDKTests/InstanceTests.swift index 752d486..3d3f760 100644 --- a/Tests/FeaturevisorSDKTests/InstanceTests.swift +++ b/Tests/FeaturevisorSDKTests/InstanceTests.swift @@ -4,55 +4,62 @@ import XCTest @testable import FeaturevisorTypes class FeaturevisorInstanceTests: XCTestCase { - + func testEncodingEvaluationWithNilValuesReturnsValidDatafileContent() throws { - + //GIVEN let evaluation = Evaluation( featureKey: "feature123", reason: .allocated ) - + let expectedDictionary: [String: Any] = [ "featureKey": "feature123", - "reason": "allocated" + "reason": "allocated", ] - + let encoder = JSONEncoder() let jsonData = try encoder.encode(evaluation) - + //WHEN - guard let decodedDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + guard + let decodedDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) + as? [String: Any] + else { XCTFail("Failed to decode JSON to a dictionary.") return } //THEN XCTAssertEqual(decodedDictionary as NSDictionary, expectedDictionary as NSDictionary) - + } - + func testEncodeEvaluationReturnsValidDatafileContent() throws { - + //GIVEN let traffic = Traffic( key: "key", segments: .plain("segment"), percentage: 13, - allocation: []) + allocation: [] + ) let overrideFeature = OverrideFeature( enabled: false, variation: "variation", - variables: [:]) + variables: [:] + ) let variation = Variation( description: "description", value: "value", weight: 3, - variables: []) + variables: [] + ) let variableSchema = VariableSchema( key: "key", type: .object, - defaultValue: .object(["": .boolean(false)])) - + defaultValue: .object(["": .boolean(false)]) + ) + let evaluation = Evaluation( featureKey: "feature123", reason: .allocated, @@ -68,7 +75,7 @@ class FeaturevisorInstanceTests: XCTestCase { variableValue: .string(""), variableSchema: variableSchema ) - + let mockedTraffic: [String: Any] = [ "allocation": [Any](), "key": "key", @@ -77,28 +84,28 @@ class FeaturevisorInstanceTests: XCTestCase { "plain": [ "_0": "segment" ] - ] + ], ] - + let mockedOverrideFeature: [String: Any] = [ "enabled": false, "variables": [String: Any](), - "variation": "variation" + "variation": "variation", ] - + let mockedVariation: [String: Any] = [ "description": "description", "value": "value", "variables": [Any](), - "weight": 3 + "weight": 3, ] - + let mockedVariableSchema: [String: Any] = [ "key": "key", "defaultValue": ["": 0], - "type": "object" + "type": "object", ] - + let expectedDictionary: [String: Any] = [ "featureKey": "feature123", "reason": "allocated", @@ -112,18 +119,21 @@ class FeaturevisorInstanceTests: XCTestCase { "variationValue": "value", "variableKey": "color", "variableValue": "", - "variableSchema": mockedVariableSchema + "variableSchema": mockedVariableSchema, ] - + let encoder = JSONEncoder() let jsonData = try encoder.encode(evaluation) - + //WHEN - guard let decodedDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] else { + guard + let decodedDictionary = try JSONSerialization.jsonObject(with: jsonData, options: []) + as? [String: Any] + else { XCTFail("Failed to decode JSON to a dictionary.") return } - + } func testCreateInstanceThrowsInvalidURLError() { @@ -137,7 +147,7 @@ class FeaturevisorInstanceTests: XCTestCase { MockURLProtocol.requestHandler = { request in let jsonString = - "{\"schemaVersion\":\"1\",\"revision\":\"0.0.666\",\"attributes\":[],\"segments\":[],\"features\":[]}" + "{\"schemaVersion\":\"1\",\"revision\":\"0.0.666\",\"attributes\":[],\"segments\":[],\"features\":[]}" let response = HTTPURLResponse( url: request.url!, statusCode: 200, @@ -161,22 +171,28 @@ class FeaturevisorInstanceTests: XCTestCase { XCTAssertEqual(error as! FeaturevisorError, FeaturevisorError.missingDatafileOptions) } } - + func testInitializationSuccessDatafileContentFetching() { - + // GIVEN MockURLProtocol.requestHandler = { request in - let jsonString = "{\"schemaVersion\":\"1\",\"revision\":\"0.0.666\",\"attributes\":[],\"segments\":[],\"features\":[]}" - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + let jsonString = + "{\"schemaVersion\":\"1\",\"revision\":\"0.0.666\",\"attributes\":[],\"segments\":[],\"features\":[]}" + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! return (response, jsonString.data(using: .utf8)) } - + // GIVEN let featureKey = "test" let context: Context = ["userId": .string("123")] var capturedBucketKey = "" - + var options: InstanceOptions = .default options.datafile = DatafileContent( schemaVersion: "1", @@ -216,893 +232,905 @@ class FeaturevisorInstanceTests: XCTestCase { ) ] ) - + options.configureBucketKey = - ({ feature, context, bucketKey in - capturedBucketKey = bucketKey - return bucketKey - }) - + ({ feature, context, bucketKey in + capturedBucketKey = bucketKey + return bucketKey + }) + let sdk = try! createInstance(options: options) - + // WHEN let isEnabled = sdk.isEnabled(featureKey: featureKey, context: context) let variation = sdk.getVariation(featureKey: featureKey, context: context)! - + // THEN XCTAssertTrue(isEnabled) XCTAssertEqual(variation, "control") XCTAssertEqual(capturedBucketKey, "123.test") } - - func testShouldConfigureAndBucketBy() { - - // GIVEN - let featureKey = "test" - let context: Context = ["userId": .string("123"), "organizationId": .string("456")] - var capturedBucketKey = "" - - var options: InstanceOptions = .default - options.datafile = DatafileContent( - schemaVersion: "1", - revision: "1.0", - attributes: [], - segments: [], - features: [ - Feature( - key: "test", - bucketBy: .and(["userId", "organizationId"]), - variations: [ - Variation(description: nil, value: "control", weight: nil, variables: nil), - Variation( - description: nil, - value: "treatment", - weight: nil, - variables: nil - ), - ], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [ - Allocation( - variation: "control", - range: FeaturevisorTypes.Range(start: 0, end: 100000) - ), - Allocation( - variation: "treatment", - range: FeaturevisorTypes.Range(start: 0, end: 0) - ), - ] - ) - ] - ) - ] - ) - - options.configureBucketKey = - ({ feature, context, bucketKey in - capturedBucketKey = bucketKey - return bucketKey - }) - - let sdk = try! createInstance(options: options) - - // WHEN - let variation = sdk.getVariation(featureKey: featureKey, context: context)! - - // THEN - XCTAssertEqual(variation, "control") - XCTAssertEqual(capturedBucketKey, "123.456.test") - } - - func testShouldConfigureOrBucketBy() { - - // GIVEN - var capturedBucketKey = "" - - var options: InstanceOptions = .default - options.datafile = DatafileContent( - schemaVersion: "1", - revision: "1.0", - attributes: [], - segments: [], - features: [ - Feature( - key: "test", - bucketBy: .or(.init(or: ["userId", "deviceId"])), - variations: [ - Variation(description: nil, value: "control", weight: nil, variables: nil), - Variation( - description: nil, - value: "treatment", - weight: nil, - variables: nil - ), - ], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [ - Allocation( - variation: "control", - range: FeaturevisorTypes.Range(start: 0, end: 100000) - ), - Allocation( - variation: "treatment", - range: FeaturevisorTypes.Range(start: 0, end: 0) - ), - ] - ) - ] - ) - ] - ) - - options.configureBucketKey = - ({ feature, context, bucketKey in - capturedBucketKey = bucketKey - return bucketKey - }) - - // WHEN - let sdk = try! createInstance(options: options) - - // THEN - XCTAssertTrue( - sdk.isEnabled( - featureKey: "test", - context: ["userId": .string("123"), "deviceId": .string("456")] - ) - ) - - XCTAssertEqual( - sdk.getVariation( - featureKey: "test", - context: ["userId": .string("123"), "deviceId": .string("456")] - ), - "control" - ) - - XCTAssertEqual(capturedBucketKey, "123.test") - - XCTAssertEqual( - sdk.getVariation( - featureKey: "test", - context: ["deviceId": .string("456")] + + func testShouldConfigureAndBucketBy() { + + // GIVEN + let featureKey = "test" + let context: Context = ["userId": .string("123"), "organizationId": .string("456")] + var capturedBucketKey = "" + + var options: InstanceOptions = .default + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "1.0", + attributes: [], + segments: [], + features: [ + Feature( + key: "test", + bucketBy: .and(["userId", "organizationId"]), + variations: [ + Variation(description: nil, value: "control", weight: nil, variables: nil), + Variation( + description: nil, + value: "treatment", + weight: nil, + variables: nil ), - "control" - ) - - XCTAssertEqual(capturedBucketKey, "456.test") - } - - func testShouldInterceptContext() { - - // GIVEN - var intercepted = false - - var options: InstanceOptions = .default - options.datafile = DatafileContent( - schemaVersion: "1", - revision: "1.0", - attributes: [], - segments: [], - features: [ - Feature( - key: "test", - bucketBy: .single("userId"), - variations: [ - Variation(description: nil, value: "control", weight: nil, variables: nil), - Variation( - description: nil, - value: "treatment", - weight: nil, - variables: nil - ), - ], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [ - Allocation( - variation: "control", - range: FeaturevisorTypes.Range(start: 0, end: 100000) - ), - Allocation( - variation: "treatment", - range: FeaturevisorTypes.Range(start: 0, end: 0) - ), - ] - ) - ] - ) - ] - ) - options.interceptContext = - ({ context in - intercepted = true - return context - }) - - // WHEN - let sdk = try! createInstance(options: options) - let variation = sdk.getVariation( - featureKey: "test", - context: [ - "userId": .string("123") - ] - ) - - // THEN - XCTAssertEqual(variation, "control") - XCTAssertTrue(intercepted) - } - - func testShouldActivateFeature() { - - // GIVEN - var activated = false - - var options: InstanceOptions = .default - options.datafile = DatafileContent( - schemaVersion: "1", - revision: "1.0", - attributes: [], - segments: [], - features: [ - Feature( - key: "test", - bucketBy: .single("userId"), - variations: [ - Variation(description: nil, value: "control", weight: nil, variables: nil), - Variation( - description: nil, - value: "treatment", - weight: nil, - variables: nil - ), - ], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [ - Allocation( - variation: "control", - range: FeaturevisorTypes.Range(start: 0, end: 100000) - ), - Allocation( - variation: "treatment", - range: FeaturevisorTypes.Range(start: 0, end: 0) - ), - ] - ) - ] - ) - ] - ) - options.onActivation = - ({ closure in - activated = true - }) - - // WHEN - let sdk = try! createInstance(options: options) - - // THEN - let variation = sdk.getVariation( - featureKey: "test", - context: [ - "userId": .string("123") - ] - ) - - XCTAssertFalse(activated) - XCTAssertEqual(variation, "control") - - let activatedVariation = sdk.activate( - featureKey: "test", - context: [ - "userId": .string("123") - ] - ) - - XCTAssertTrue(activated) - XCTAssertEqual(activatedVariation, "control") - } - - func testShouldHonourSimpleRequiredFeaturesDisabled() { - - // GIVEN - var options: InstanceOptions = .default - options.datafile = DatafileContent( - schemaVersion: "1", - revision: "1.0", - attributes: [], - segments: [], - features: [ - Feature( - key: "requiredKey", - bucketBy: .single("userId"), - variations: [], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 0, - allocation: [] - ) - ] - ), - Feature( - key: "myKey", - bucketBy: .single("userId"), - variations: [], - required: [.featureKey("requiredKey")], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [] - ) - ] - ), - ] - ) - - // WHEN - let sdk = try! createInstance(options: options) - - // THEN - // should be disabled because required is disabled - XCTAssertFalse(sdk.isEnabled(featureKey: "myKey")) - } - - func testShouldHonourSimpleRequiredFeaturesEnabled() { - - // GIVEN - var options: InstanceOptions = .default - options.datafile = DatafileContent( - schemaVersion: "1", - revision: "1.0", - attributes: [], - segments: [], - features: [ - Feature( - key: "requiredKey", - bucketBy: .single("userId"), - variations: [], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, // enabled - allocation: [] - ) - ] - ), - Feature( - key: "myKey", - bucketBy: .single("userId"), - variations: [], - required: [.featureKey("requiredKey")], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [] - ) - ] - ), - ] - ) - - // WHEN - let sdk = try! createInstance(options: options) - - // THEN - // enabling required should enable the feature too - XCTAssertTrue(sdk.isEnabled(featureKey: "myKey")) - } - - // should be disabled because required has different variation - func testShouldHonourRequiredFeaturesWithVariationDisabled() { - - // GIVEN - var options: InstanceOptions = .default - options.datafile = DatafileContent( - schemaVersion: "1", - revision: "1.0", - attributes: [], - segments: [], - features: [ - Feature( - key: "requiredKey", - bucketBy: .single("userId"), - variations: [ - Variation(description: nil, value: "control", weight: nil, variables: nil), - Variation( - description: nil, - value: "treatment", - weight: nil, - variables: nil - ), - ], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [ - Allocation( - variation: "control", - range: FeaturevisorTypes.Range(start: 0, end: 0) - ), - Allocation( - variation: "treatment", - range: FeaturevisorTypes.Range(start: 0, end: 100000) - ), - ] - ) - ] - ), - Feature( - key: "myKey", - bucketBy: .single("userId"), - variations: [], - required: [.withVariation(.init(key: "requiredKey", variation: "control"))], // different variation - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [] - ) - ] - ), - ] - ) - - // WHEN - let sdk = try! createInstance(options: options) - - // THEN - XCTAssertFalse(sdk.isEnabled(featureKey: "myKey")) - } - - // child should be enabled because required has desired variation - func testShouldHonourRequiredFeaturesWithVariationEnabled() { - - // GIVEN - var options: InstanceOptions = .default - options.datafile = DatafileContent( - schemaVersion: "1", - revision: "1.0", - attributes: [], - segments: [], - features: [ - Feature( - key: "requiredKey", - bucketBy: .single("userId"), - variations: [ - Variation(description: nil, value: "control", weight: nil, variables: nil), - Variation( - description: nil, - value: "treatment", - weight: nil, - variables: nil - ), - ], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [ - Allocation( - variation: "control", - range: FeaturevisorTypes.Range(start: 0, end: 0) - ), - Allocation( - variation: "treatment", - range: FeaturevisorTypes.Range(start: 0, end: 100000) - ), - ] - ) - ] - ), - Feature( - key: "myKey", - bucketBy: .single("userId"), - variations: [], - required: [.withVariation(.init(key: "requiredKey", variation: "treatment"))], // desired variation - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [] - ) - ] - ), - ] - ) - - // WHEN - let sdk = try! createInstance(options: options) - - // THEN - XCTAssertTrue(sdk.isEnabled(featureKey: "myKey")) - } - - func testShouldEmitWarningsForDeprecatedFeature() { - - // GIVEN - var deprecatedCount = 0 - var options: InstanceOptions = .default - options.datafile = DatafileContent( - schemaVersion: "1", - revision: "1.0", - attributes: [], - segments: [], - features: [ - Feature( - key: "test", - bucketBy: .single("userId"), - variations: [ - Variation(description: nil, value: "control", weight: nil, variables: nil), - Variation( - description: nil, - value: "treatment", - weight: nil, - variables: nil - ), - ], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [ - Allocation( - variation: "control", - range: FeaturevisorTypes.Range(start: 0, end: 100000) - ), - Allocation( - variation: "treatment", - range: FeaturevisorTypes.Range(start: 0, end: 0) - ), - ] - ) - ] - ), - Feature( - key: "deprecatedTest", - bucketBy: .single("userId"), - deprecated: true, - variations: [ - Variation(description: nil, value: "control", weight: nil, variables: nil), - Variation( - description: nil, - value: "treatment", - weight: nil, - variables: nil - ), - ], - required: [], - traffic: [ - Traffic( - key: "1", - segments: .plain("*"), - percentage: 100000, - allocation: [ - Allocation( - variation: "control", - range: FeaturevisorTypes.Range(start: 0, end: 100000) - ), - Allocation( - variation: "treatment", - range: FeaturevisorTypes.Range(start: 0, end: 0) - ), - ] - ) - ] - ), - ] - ) - - options.logger = createLogger { level, message, details in - guard case .warn = level else { - return - } - - if message.contains("is deprecated") { - deprecatedCount += 1 - } - } - - // WHEN - let sdk = try! createInstance(options: options) - let testVariation = sdk.getVariation( - featureKey: "test", - context: ["userId": .string("123")] - ) - let deprecatedTestVariation = sdk.getVariation( - featureKey: "deprecatedTest", - context: ["userId": .string("123")] - ) - - // THEN - XCTAssertEqual(testVariation, "control") - XCTAssertEqual(deprecatedTestVariation, "control") - XCTAssertEqual(deprecatedCount, 1) - } - - func testShouldRefreshDatafile() { - - // GIVEN - var revision = 1 - var refreshed = false - var updatedViaOption = false - - let expectation: XCTestExpectation = expectation(description: "Expectation") - - MockURLProtocol.requestHandler = { request in - let jsonString = - "{\"schemaVersion\":\"1\",\"revision\":\"\(revision)\",\"attributes\":[],\"segments\":[],\"features\":[]}" - let response = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! - revision += 1 - return (response, jsonString.data(using: .utf8)) - } - - var options: InstanceOptions = .default - options.sessionConfiguration.protocolClasses = [MockURLProtocol.self] - options.datafileUrl = "https://featurevisor-awesome-url.com/tags.json" - options.onRefresh = - ({ _ in - refreshed = true - }) - options.onUpdate = - ({ _ in - updatedViaOption = true - expectation.fulfill() - }) - - // WHEN - let sdk = try! createInstance(options: options) - sdk.refresh() - wait(for: [expectation], timeout: 0.1) - - // THEN - XCTAssertEqual(sdk.getRevision(), "2") - XCTAssertTrue(refreshed) - XCTAssertTrue(updatedViaOption) - } - -func testShouldStartRefreshing() { - - // GIVEN - var revision = 1 - var refreshedCount = 0 - let refreshInterval = 1.0 - let expectedRefreshCount = 3 - - MockURLProtocol.requestHandler = { request in - let jsonString = "{\"schemaVersion\":\"1\",\"revision\":\"\(revision)\",\"attributes\":[],\"segments\":[],\"features\":[]}" - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - revision += 1 - return (response, jsonString.data(using: .utf8)) - } - - var options: InstanceOptions = .default - options.sessionConfiguration.protocolClasses = [MockURLProtocol.self] - options.refreshInterval = refreshInterval - options.datafileUrl = "https://featurevisor-awesome-url.com/tags.json" - options.onRefresh = ({ _ in - refreshedCount += 1 - }) - - // WHEN - let sdk = try! createInstance(options: options) - - while refreshedCount < expectedRefreshCount { - Thread.sleep(forTimeInterval: 0.1) - } - - MockURLProtocol.requestHandler = { request in - let jsonString = - "{\"schemaVersion\":\"1\",\"revision\":\"\(revision)\",\"attributes\":[],\"segments\":[],\"features\":[]}" - let response = HTTPURLResponse( - url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil - )! - revision += 1 - return (response, jsonString.data(using: .utf8)) - } -} - - func testShouldStopRefreshing() { - - // GIVEN - var isRefreshingStopped = false - var revision = 1 - let refreshInterval = 1.0 - - MockURLProtocol.requestHandler = { request in - let jsonString = "{\"schemaVersion\":\"1\",\"revision\":\"\(revision)\",\"attributes\":[],\"segments\":[],\"features\":[]}" - let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! - revision += 1 - return (response, jsonString.data(using: .utf8)) - } - - var options: InstanceOptions = .default - options.sessionConfiguration.protocolClasses = [MockURLProtocol.self] - options.refreshInterval = refreshInterval - options.datafileUrl = "https://featurevisor-awesome-url.com/tags.json" - options.logger = createLogger { level, message, details in - guard case .warn = level else { - return - } - - if message.contains("refreshing has stopped") { - isRefreshingStopped = true - } - } - - // WHEN - let sdk = try! createInstance(options: options) - - XCTAssertEqual(isRefreshingStopped, false) - - sdk.stopRefreshing() - - // THEN - XCTAssertEqual(isRefreshingStopped, true) - } - - - func testSetDatafileByDatafileContent() { - - // GIVEN - let datafileContent = DatafileContent( - schemaVersion: "1", - revision: "0.0.66", - attributes: [], - segments: [], - features: [] - ) - - var options = InstanceOptions.default - options.datafile = DatafileContent( - schemaVersion: "", - revision: "", - attributes: [], - segments: [], - features: [] - ) - let sdk = try! createInstance(options: options) - - // WHEN - sdk.setDatafile(datafileContent) - - // THEN - XCTAssertEqual(sdk.getRevision(), "0.0.66") - } - - func testSetDatafileByInvalidJSONReturnsError() { - - // GIVEN - var errorCount = 0 - var options = InstanceOptions.default - options.logger = createLogger { level, message, details in - guard case .error = level else { - return - } - - if message.contains("could not parse datafile") { - errorCount += 1 - } - } - - options.datafile = DatafileContent( - schemaVersion: "", - revision: "", - attributes: [], - segments: [], - features: [] + ], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [ + Allocation( + variation: "control", + range: FeaturevisorTypes.Range(start: 0, end: 100000) + ), + Allocation( + variation: "treatment", + range: FeaturevisorTypes.Range(start: 0, end: 0) + ), + ] ) - - let sdk = try! createInstance(options: options) - - // WHEN - sdk.setDatafile( - "{\"schemaVersion\":1,\"revision\":\"0.0.66\", attributes:[],\"segments\":[],\"features\":[]}" + ] + ) + ] + ) + + options.configureBucketKey = + ({ feature, context, bucketKey in + capturedBucketKey = bucketKey + return bucketKey + }) + + let sdk = try! createInstance(options: options) + + // WHEN + let variation = sdk.getVariation(featureKey: featureKey, context: context)! + + // THEN + XCTAssertEqual(variation, "control") + XCTAssertEqual(capturedBucketKey, "123.456.test") + } + + func testShouldConfigureOrBucketBy() { + + // GIVEN + var capturedBucketKey = "" + + var options: InstanceOptions = .default + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "1.0", + attributes: [], + segments: [], + features: [ + Feature( + key: "test", + bucketBy: .or(.init(or: ["userId", "deviceId"])), + variations: [ + Variation(description: nil, value: "control", weight: nil, variables: nil), + Variation( + description: nil, + value: "treatment", + weight: nil, + variables: nil + ), + ], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [ + Allocation( + variation: "control", + range: FeaturevisorTypes.Range(start: 0, end: 100000) + ), + Allocation( + variation: "treatment", + range: FeaturevisorTypes.Range(start: 0, end: 0) + ), + ] ) - - // THEN - XCTAssertEqual(errorCount, 1) - XCTAssertEqual(sdk.getRevision(), "") - } - - func testHandleDatafileFetchReturnsValidResponse() { - - // GIVEN - var options = InstanceOptions.default - options.datafileUrl = "https://dazn.featurevisor.datafilecontent.com" - options.handleDatafileFetch = { _ in - let datafileContent = DatafileContent( - schemaVersion: "2", - revision: "6.6.6", - attributes: [], - segments: [], - features: [] - ) - - return .success(datafileContent) - } - options.datafile = DatafileContent( - schemaVersion: "1", - revision: "0.0.1", - attributes: [], - segments: [], - features: [] + ] + ) + ] + ) + + options.configureBucketKey = + ({ feature, context, bucketKey in + capturedBucketKey = bucketKey + return bucketKey + }) + + // WHEN + let sdk = try! createInstance(options: options) + + // THEN + XCTAssertTrue( + sdk.isEnabled( + featureKey: "test", + context: ["userId": .string("123"), "deviceId": .string("456")] + ) + ) + + XCTAssertEqual( + sdk.getVariation( + featureKey: "test", + context: ["userId": .string("123"), "deviceId": .string("456")] + ), + "control" + ) + + XCTAssertEqual(capturedBucketKey, "123.test") + + XCTAssertEqual( + sdk.getVariation( + featureKey: "test", + context: ["deviceId": .string("456")] + ), + "control" + ) + + XCTAssertEqual(capturedBucketKey, "456.test") + } + + func testShouldInterceptContext() { + + // GIVEN + var intercepted = false + + var options: InstanceOptions = .default + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "1.0", + attributes: [], + segments: [], + features: [ + Feature( + key: "test", + bucketBy: .single("userId"), + variations: [ + Variation(description: nil, value: "control", weight: nil, variables: nil), + Variation( + description: nil, + value: "treatment", + weight: nil, + variables: nil + ), + ], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [ + Allocation( + variation: "control", + range: FeaturevisorTypes.Range(start: 0, end: 100000) + ), + Allocation( + variation: "treatment", + range: FeaturevisorTypes.Range(start: 0, end: 0) + ), + ] ) - - // WHEN - let sdk = try! createInstance(options: options) - - // THEN - XCTAssertEqual(sdk.getRevision(), "6.6.6") - } - - func testHandleDatafileFetchReturnErrorResponse() { - - // GIVEN - var wasDatafileContentFetchErrorThrown = false - var options = InstanceOptions.default - options.datafileUrl = "https://dazn.featurevisor.datafilecontent.com" - options.handleDatafileFetch = { _ in - return .failure(FeaturevisorError.unparseableJSON(data: nil, errorMessage: "Error :(")) - } - options.logger = createLogger { level, message, details in - guard case .error = level else { - return - } - - if message.contains("Failed to fetch datafile") { - wasDatafileContentFetchErrorThrown = true - } - } - - options.datafile = DatafileContent( - schemaVersion: "1", - revision: "0.0.1", - attributes: [], - segments: [], - features: [] + ] + ) + ] + ) + options.interceptContext = + ({ context in + intercepted = true + return context + }) + + // WHEN + let sdk = try! createInstance(options: options) + let variation = sdk.getVariation( + featureKey: "test", + context: [ + "userId": .string("123") + ] + ) + + // THEN + XCTAssertEqual(variation, "control") + XCTAssertTrue(intercepted) + } + + func testShouldActivateFeature() { + + // GIVEN + var activated = false + + var options: InstanceOptions = .default + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "1.0", + attributes: [], + segments: [], + features: [ + Feature( + key: "test", + bucketBy: .single("userId"), + variations: [ + Variation(description: nil, value: "control", weight: nil, variables: nil), + Variation( + description: nil, + value: "treatment", + weight: nil, + variables: nil + ), + ], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [ + Allocation( + variation: "control", + range: FeaturevisorTypes.Range(start: 0, end: 100000) + ), + Allocation( + variation: "treatment", + range: FeaturevisorTypes.Range(start: 0, end: 0) + ), + ] ) - - // WHEN - _ = try! createInstance(options: options) - - // THEN - XCTAssertTrue(wasDatafileContentFetchErrorThrown) - } - } + ] + ) + ] + ) + options.onActivation = + ({ closure in + activated = true + }) + + // WHEN + let sdk = try! createInstance(options: options) + + // THEN + let variation = sdk.getVariation( + featureKey: "test", + context: [ + "userId": .string("123") + ] + ) + + XCTAssertFalse(activated) + XCTAssertEqual(variation, "control") + + let activatedVariation = sdk.activate( + featureKey: "test", + context: [ + "userId": .string("123") + ] + ) + + XCTAssertTrue(activated) + XCTAssertEqual(activatedVariation, "control") + } + + func testShouldHonourSimpleRequiredFeaturesDisabled() { + + // GIVEN + var options: InstanceOptions = .default + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "1.0", + attributes: [], + segments: [], + features: [ + Feature( + key: "requiredKey", + bucketBy: .single("userId"), + variations: [], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 0, + allocation: [] + ) + ] + ), + Feature( + key: "myKey", + bucketBy: .single("userId"), + variations: [], + required: [.featureKey("requiredKey")], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [] + ) + ] + ), + ] + ) + + // WHEN + let sdk = try! createInstance(options: options) + + // THEN + // should be disabled because required is disabled + XCTAssertFalse(sdk.isEnabled(featureKey: "myKey")) + } + + func testShouldHonourSimpleRequiredFeaturesEnabled() { + + // GIVEN + var options: InstanceOptions = .default + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "1.0", + attributes: [], + segments: [], + features: [ + Feature( + key: "requiredKey", + bucketBy: .single("userId"), + variations: [], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, // enabled + allocation: [] + ) + ] + ), + Feature( + key: "myKey", + bucketBy: .single("userId"), + variations: [], + required: [.featureKey("requiredKey")], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [] + ) + ] + ), + ] + ) + + // WHEN + let sdk = try! createInstance(options: options) + + // THEN + // enabling required should enable the feature too + XCTAssertTrue(sdk.isEnabled(featureKey: "myKey")) + } + + // should be disabled because required has different variation + func testShouldHonourRequiredFeaturesWithVariationDisabled() { + + // GIVEN + var options: InstanceOptions = .default + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "1.0", + attributes: [], + segments: [], + features: [ + Feature( + key: "requiredKey", + bucketBy: .single("userId"), + variations: [ + Variation(description: nil, value: "control", weight: nil, variables: nil), + Variation( + description: nil, + value: "treatment", + weight: nil, + variables: nil + ), + ], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [ + Allocation( + variation: "control", + range: FeaturevisorTypes.Range(start: 0, end: 0) + ), + Allocation( + variation: "treatment", + range: FeaturevisorTypes.Range(start: 0, end: 100000) + ), + ] + ) + ] + ), + Feature( + key: "myKey", + bucketBy: .single("userId"), + variations: [], + required: [.withVariation(.init(key: "requiredKey", variation: "control"))], // different variation + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [] + ) + ] + ), + ] + ) + + // WHEN + let sdk = try! createInstance(options: options) + + // THEN + XCTAssertFalse(sdk.isEnabled(featureKey: "myKey")) + } + + // child should be enabled because required has desired variation + func testShouldHonourRequiredFeaturesWithVariationEnabled() { + + // GIVEN + var options: InstanceOptions = .default + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "1.0", + attributes: [], + segments: [], + features: [ + Feature( + key: "requiredKey", + bucketBy: .single("userId"), + variations: [ + Variation(description: nil, value: "control", weight: nil, variables: nil), + Variation( + description: nil, + value: "treatment", + weight: nil, + variables: nil + ), + ], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [ + Allocation( + variation: "control", + range: FeaturevisorTypes.Range(start: 0, end: 0) + ), + Allocation( + variation: "treatment", + range: FeaturevisorTypes.Range(start: 0, end: 100000) + ), + ] + ) + ] + ), + Feature( + key: "myKey", + bucketBy: .single("userId"), + variations: [], + required: [.withVariation(.init(key: "requiredKey", variation: "treatment"))], // desired variation + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [] + ) + ] + ), + ] + ) + + // WHEN + let sdk = try! createInstance(options: options) + + // THEN + XCTAssertTrue(sdk.isEnabled(featureKey: "myKey")) + } + + func testShouldEmitWarningsForDeprecatedFeature() { + + // GIVEN + var deprecatedCount = 0 + var options: InstanceOptions = .default + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "1.0", + attributes: [], + segments: [], + features: [ + Feature( + key: "test", + bucketBy: .single("userId"), + variations: [ + Variation(description: nil, value: "control", weight: nil, variables: nil), + Variation( + description: nil, + value: "treatment", + weight: nil, + variables: nil + ), + ], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [ + Allocation( + variation: "control", + range: FeaturevisorTypes.Range(start: 0, end: 100000) + ), + Allocation( + variation: "treatment", + range: FeaturevisorTypes.Range(start: 0, end: 0) + ), + ] + ) + ] + ), + Feature( + key: "deprecatedTest", + bucketBy: .single("userId"), + deprecated: true, + variations: [ + Variation(description: nil, value: "control", weight: nil, variables: nil), + Variation( + description: nil, + value: "treatment", + weight: nil, + variables: nil + ), + ], + required: [], + traffic: [ + Traffic( + key: "1", + segments: .plain("*"), + percentage: 100000, + allocation: [ + Allocation( + variation: "control", + range: FeaturevisorTypes.Range(start: 0, end: 100000) + ), + Allocation( + variation: "treatment", + range: FeaturevisorTypes.Range(start: 0, end: 0) + ), + ] + ) + ] + ), + ] + ) + + options.logger = createLogger { level, message, details in + guard case .warn = level else { + return + } + + if message.contains("is deprecated") { + deprecatedCount += 1 + } + } + + // WHEN + let sdk = try! createInstance(options: options) + let testVariation = sdk.getVariation( + featureKey: "test", + context: ["userId": .string("123")] + ) + let deprecatedTestVariation = sdk.getVariation( + featureKey: "deprecatedTest", + context: ["userId": .string("123")] + ) + + // THEN + XCTAssertEqual(testVariation, "control") + XCTAssertEqual(deprecatedTestVariation, "control") + XCTAssertEqual(deprecatedCount, 1) + } + + func testShouldRefreshDatafile() { + + // GIVEN + var revision = 1 + var refreshed = false + var updatedViaOption = false + + let expectation: XCTestExpectation = expectation(description: "Expectation") + + MockURLProtocol.requestHandler = { request in + let jsonString = + "{\"schemaVersion\":\"1\",\"revision\":\"\(revision)\",\"attributes\":[],\"segments\":[],\"features\":[]}" + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + revision += 1 + return (response, jsonString.data(using: .utf8)) + } + + var options: InstanceOptions = .default + options.sessionConfiguration.protocolClasses = [MockURLProtocol.self] + options.datafileUrl = "https://featurevisor-awesome-url.com/tags.json" + options.onRefresh = + ({ _ in + refreshed = true + }) + options.onUpdate = + ({ _ in + updatedViaOption = true + expectation.fulfill() + }) + + // WHEN + let sdk = try! createInstance(options: options) + sdk.refresh() + wait(for: [expectation], timeout: 0.1) + + // THEN + XCTAssertEqual(sdk.getRevision(), "2") + XCTAssertTrue(refreshed) + XCTAssertTrue(updatedViaOption) + } + + func testShouldStartRefreshing() { + + // GIVEN + var revision = 1 + var refreshedCount = 0 + let refreshInterval = 1.0 + let expectedRefreshCount = 3 + + MockURLProtocol.requestHandler = { request in + let jsonString = + "{\"schemaVersion\":\"1\",\"revision\":\"\(revision)\",\"attributes\":[],\"segments\":[],\"features\":[]}" + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + revision += 1 + return (response, jsonString.data(using: .utf8)) + } + + var options: InstanceOptions = .default + options.sessionConfiguration.protocolClasses = [MockURLProtocol.self] + options.refreshInterval = refreshInterval + options.datafileUrl = "https://featurevisor-awesome-url.com/tags.json" + options.onRefresh = + ({ _ in + refreshedCount += 1 + }) + + // WHEN + let sdk = try! createInstance(options: options) + + while refreshedCount < expectedRefreshCount { + Thread.sleep(forTimeInterval: 0.1) + } + + MockURLProtocol.requestHandler = { request in + let jsonString = + "{\"schemaVersion\":\"1\",\"revision\":\"\(revision)\",\"attributes\":[],\"segments\":[],\"features\":[]}" + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + revision += 1 + return (response, jsonString.data(using: .utf8)) + } + } + + func testShouldStopRefreshing() { + + // GIVEN + var isRefreshingStopped = false + var revision = 1 + let refreshInterval = 1.0 + + MockURLProtocol.requestHandler = { request in + let jsonString = + "{\"schemaVersion\":\"1\",\"revision\":\"\(revision)\",\"attributes\":[],\"segments\":[],\"features\":[]}" + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + revision += 1 + return (response, jsonString.data(using: .utf8)) + } + + var options: InstanceOptions = .default + options.sessionConfiguration.protocolClasses = [MockURLProtocol.self] + options.refreshInterval = refreshInterval + options.datafileUrl = "https://featurevisor-awesome-url.com/tags.json" + options.logger = createLogger { level, message, details in + guard case .warn = level else { + return + } + + if message.contains("refreshing has stopped") { + isRefreshingStopped = true + } + } + + // WHEN + let sdk = try! createInstance(options: options) + + XCTAssertEqual(isRefreshingStopped, false) + + sdk.stopRefreshing() + + // THEN + XCTAssertEqual(isRefreshingStopped, true) + } + + func testSetDatafileByDatafileContent() { + + // GIVEN + let datafileContent = DatafileContent( + schemaVersion: "1", + revision: "0.0.66", + attributes: [], + segments: [], + features: [] + ) + + var options = InstanceOptions.default + options.datafile = DatafileContent( + schemaVersion: "", + revision: "", + attributes: [], + segments: [], + features: [] + ) + let sdk = try! createInstance(options: options) + + // WHEN + sdk.setDatafile(datafileContent) + + // THEN + XCTAssertEqual(sdk.getRevision(), "0.0.66") + } + + func testSetDatafileByInvalidJSONReturnsError() { + + // GIVEN + var errorCount = 0 + var options = InstanceOptions.default + options.logger = createLogger { level, message, details in + guard case .error = level else { + return + } + + if message.contains("could not parse datafile") { + errorCount += 1 + } + } + + options.datafile = DatafileContent( + schemaVersion: "", + revision: "", + attributes: [], + segments: [], + features: [] + ) + + let sdk = try! createInstance(options: options) + + // WHEN + sdk.setDatafile( + "{\"schemaVersion\":1,\"revision\":\"0.0.66\", attributes:[],\"segments\":[],\"features\":[]}" + ) + + // THEN + XCTAssertEqual(errorCount, 1) + XCTAssertEqual(sdk.getRevision(), "") + } + + func testHandleDatafileFetchReturnsValidResponse() { + + // GIVEN + var options = InstanceOptions.default + options.datafileUrl = "https://dazn.featurevisor.datafilecontent.com" + options.handleDatafileFetch = { _ in + let datafileContent = DatafileContent( + schemaVersion: "2", + revision: "6.6.6", + attributes: [], + segments: [], + features: [] + ) + + return .success(datafileContent) + } + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "0.0.1", + attributes: [], + segments: [], + features: [] + ) + + // WHEN + let sdk = try! createInstance(options: options) + + // THEN + XCTAssertEqual(sdk.getRevision(), "6.6.6") + } + + func testHandleDatafileFetchReturnErrorResponse() { + + // GIVEN + var wasDatafileContentFetchErrorThrown = false + var options = InstanceOptions.default + options.datafileUrl = "https://dazn.featurevisor.datafilecontent.com" + options.handleDatafileFetch = { _ in + return .failure(FeaturevisorError.unparseableJSON(data: nil, errorMessage: "Error :(")) + } + options.logger = createLogger { level, message, details in + guard case .error = level else { + return + } + + if message.contains("Failed to fetch datafile") { + wasDatafileContentFetchErrorThrown = true + } + } + + options.datafile = DatafileContent( + schemaVersion: "1", + revision: "0.0.1", + attributes: [], + segments: [], + features: [] + ) + + // WHEN + _ = try! createInstance(options: options) + + // THEN + XCTAssertTrue(wasDatafileContentFetchErrorThrown) + } +}