diff --git a/Mixpanel-swift.podspec b/Mixpanel-swift.podspec index 303f69d2..4d1dcc85 100644 --- a/Mixpanel-swift.podspec +++ b/Mixpanel-swift.podspec @@ -10,6 +10,7 @@ Pod::Spec.new do |s| s.source = { :git => 'https://github.com/mixpanel/mixpanel-swift.git', :tag => "v#{s.version}" } s.resource_bundles = {'Mixpanel' => ['Sources/Mixpanel/PrivacyInfo.xcprivacy']} + s.dependency 'jsonlogic', '~> 1.2.0' s.ios.deployment_target = '12.0' s.ios.frameworks = 'UIKit', 'Foundation', 'CoreTelephony' s.ios.pod_target_xcconfig = { diff --git a/Mixpanel.xcodeproj/project.pbxproj b/Mixpanel.xcodeproj/project.pbxproj index 4221c6e5..5d99c8d0 100644 --- a/Mixpanel.xcodeproj/project.pbxproj +++ b/Mixpanel.xcodeproj/project.pbxproj @@ -343,6 +343,9 @@ dependencies = ( ); name = Mixpanel_watchOS; + packageProductDependencies = ( + 17JSONLOGIC00000005 /* jsonlogic */, + ); productName = Mixpanel_watchOS; productReference = 86F86E81224404BD00B69832 /* Mixpanel.framework */; productType = "com.apple.product-type.framework"; @@ -361,6 +364,9 @@ dependencies = ( ); name = Mixpanel; + packageProductDependencies = ( + 17JSONLOGIC00000002 /* jsonlogic */, + ); productName = Mixpanel; productReference = E115947D1CFF1491007F8B4F /* Mixpanel.framework */; productType = "com.apple.product-type.framework"; @@ -379,6 +385,9 @@ dependencies = ( ); name = Mixpanel_tvOS; + packageProductDependencies = ( + 17JSONLOGIC00000003 /* jsonlogic */, + ); productName = Mixpanel_tvOS; productReference = E12782B31D4AB4B30025FB05 /* Mixpanel.framework */; productType = "com.apple.product-type.framework"; @@ -397,6 +406,9 @@ dependencies = ( ); name = Mixpanel_macOS; + packageProductDependencies = ( + 17JSONLOGIC00000004 /* jsonlogic */, + ); productName = Mixpanel_OSX; productReference = E1F15FC91E64A10700391AE3 /* Mixpanel.framework */; productType = "com.apple.product-type.framework"; @@ -443,6 +455,9 @@ ); mainGroup = E11594731CFF1491007F8B4F; productRefGroup = E115947E1CFF1491007F8B4F /* Products */; + packageReferences = ( + 17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */, + ); projectDirPath = ""; projectRoot = ""; targets = ( @@ -1063,6 +1078,40 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/advantagefse/json-logic-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 17JSONLOGIC00000002 /* jsonlogic */ = { + isa = XCSwiftPackageProductDependency; + package = 17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */; + productName = jsonlogic; + }; + 17JSONLOGIC00000003 /* jsonlogic */ = { + isa = XCSwiftPackageProductDependency; + package = 17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */; + productName = jsonlogic; + }; + 17JSONLOGIC00000004 /* jsonlogic */ = { + isa = XCSwiftPackageProductDependency; + package = 17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */; + productName = jsonlogic; + }; + 17JSONLOGIC00000005 /* jsonlogic */ = { + isa = XCSwiftPackageProductDependency; + package = 17JSONLOGIC00000001 /* XCRemoteSwiftPackageReference "json-logic-swift" */; + productName = jsonlogic; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = E11594741CFF1491007F8B4F /* Project object */; } diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift index faf0b5ba..dd40c588 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelDemoTests.swift @@ -250,6 +250,8 @@ class MixpanelDemoTests: MixpanelBaseTests { func isEnabled(_ flagName: String, fallbackValue: Bool, completion: @escaping (Bool) -> Void) { completion(fallbackValue) } + + func checkFirstTimeEvents(eventName: String, properties: [String : Any]) {} } func testIdentify() { diff --git a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift index 0e142e29..c656ea14 100644 --- a/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift +++ b/MixpanelDemo/MixpanelDemoTests/MixpanelFeatureFlagTests.swift @@ -124,6 +124,12 @@ class MockFeatureFlagManager: FeatureFlagManager { var lastQueryItems: [URLQueryItem]? var requestValidationError: String? + // First-time event recording tracking + var recordFirstTimeEventCallCount = 0 + var lastRecordedFlagId: String? + var lastRecordedProjectId: Int? + var lastRecordedCohortHash: String? + // Override the now-internal method to prevent real network calls override func _performFetchRequest() { fetchRequestCount += 1 @@ -222,8 +228,13 @@ class MockFeatureFlagManager: FeatureFlagManager { self.accessQueue.async { [weak self] in guard let self = self else { return } - // Mimic the real implementation's behavior - self.flags = flags ?? [:] + // Mimic the real implementation's behavior - use mergeFlags like the real impl + let (mergedFlags, mergedPendingEvents) = self.mergeFlags( + responseFlags: flags, + responsePendingEvents: nil + ) + self.flags = mergedFlags + self.pendingFirstTimeEvents = mergedPendingEvents // Calculate timing metrics like the real implementation let latencyMs = Int(fetchEndTime.timeIntervalSince(startTime) * 1000) @@ -240,6 +251,18 @@ class MockFeatureFlagManager: FeatureFlagManager { } } } + + // Override recordFirstTimeEvent to prevent real network calls and track invocations + override func recordFirstTimeEvent(flagId: String, projectId: Int, cohortHash: String) { + recordFirstTimeEventCallCount += 1 + lastRecordedFlagId = flagId + lastRecordedProjectId = projectId + lastRecordedCohortHash = cohortHash + + print("MockFeatureFlagManager: Intercepted recordFirstTimeEvent call #\(recordFirstTimeEventCallCount) for flag: \(flagId)") + + // DO NOT call super - prevents actual network calls + } } // MARK: - Refactored FeatureFlagManager Tests @@ -337,6 +360,332 @@ class FeatureFlagManagerTests: XCTestCase { } } + // MARK: - Test Helpers + + // Expectation & Waiting Helpers + + private func waitBriefly(timeout: TimeInterval = 0.5, file: StaticString = #file, line: UInt = #line) { + let expectation = XCTestExpectation(description: "Brief wait") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } + wait(for: [expectation], timeout: timeout) + } + + private func waitForAsyncOperation( + timeout: TimeInterval = 2.0, + description: String, + operation: (@escaping (T) -> Void) -> Void, + validation: (T) -> Void + ) { + let expectation = XCTestExpectation(description: description) + var result: T? + + operation { value in + result = value + expectation.fulfill() + } + + wait(for: [expectation], timeout: timeout) + + if let result = result { + validation(result) + } else { + XCTFail("Operation did not complete in time") + } + } + + // Tracking Helpers + + private func expectTracking( + expectedCount: Int = 1, + description: String = "Track called", + timeout: TimeInterval = 1.0, + operation: () -> Void + ) { + mockDelegate.trackedEvents.removeAll() + mockDelegate.trackExpectation = XCTestExpectation(description: description) + mockDelegate.trackExpectation?.expectedFulfillmentCount = expectedCount + + operation() + + wait(for: [mockDelegate.trackExpectation!], timeout: timeout) + XCTAssertEqual(mockDelegate.trackedEvents.count, expectedCount) + } + + private func verifyTrackingProperties( + _ properties: [String: Any?], + experimentName: String, + variantName: String, + file: StaticString = #file, + line: UInt = #line + ) { + AssertEqual(properties["Experiment name"] ?? nil, experimentName, file: file, line: line) + AssertEqual(properties["Variant name"] ?? nil, variantName, file: file, line: line) + AssertEqual(properties["$experiment_type"] ?? nil, "feature_flag", file: file, line: line) + } + + private func verifyTimingProperties( + _ properties: [String: Any?], + expectedLatency: Int? = nil, + file: StaticString = #file, + line: UInt = #line + ) { + XCTAssertTrue(properties.keys.contains("timeLastFetched"), "Should include timeLastFetched", file: file, line: line) + XCTAssertTrue(properties.keys.contains("fetchLatencyMs"), "Should include fetchLatencyMs", file: file, line: line) + + if let expected = expectedLatency, + let actual = properties["fetchLatencyMs"] as? Int { + XCTAssertEqual(actual, expected, file: file, line: line) + } + } + + // Event Verification Helper + + private func verifyTrackedEvent( + at index: Int = 0, + expectedEvent: String = "$experiment_started", + experimentName: String, + variantName: String, + checkTimingProperties: Bool = false, + expectedLatency: Int? = nil, + additionalChecks: ((Properties) -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) { + guard index < mockDelegate.trackedEvents.count else { + XCTFail("No tracked event at index \(index)", file: file, line: line) + return + } + + let tracked = mockDelegate.trackedEvents[index] + XCTAssertEqual(tracked.event, expectedEvent, file: file, line: line) + XCTAssertNotNil(tracked.properties, file: file, line: line) + + guard let props = tracked.properties else { return } + + verifyTrackingProperties(props, experimentName: experimentName, + variantName: variantName, file: file, line: line) + + if checkTimingProperties { + verifyTimingProperties(props, expectedLatency: expectedLatency, + file: file, line: line) + } + + additionalChecks?(props) + } + + // Async Operation Helper + + @discardableResult + private func getVariantAsync( + _ flagName: String, + fallback: MixpanelFlagVariant? = nil, + timeout: TimeInterval = 2.0, + description: String? = nil, + verifyMainThread: Bool = true, + file: StaticString = #file, + line: UInt = #line + ) -> MixpanelFlagVariant? { + let expectation = XCTestExpectation( + description: description ?? "Get variant async for \(flagName)" + ) + var receivedData: MixpanelFlagVariant? + + manager.getVariant(flagName, fallback: fallback ?? defaultFallback) { data in + if verifyMainThread { + XCTAssertTrue(Thread.isMainThread, + "Completion should be on main thread", + file: file, line: line) + } + receivedData = data + expectation.fulfill() + } + + wait(for: [expectation], timeout: timeout) + return receivedData + } + + // Fetch Setup Helpers + + private func setupReadyFlags(flags: [String: MixpanelFlagVariant]? = nil) { + simulateFetchSuccess(flags: flags) + waitBriefly() + } + + private func setupReadyFlagsAndVerify(flags: [String: MixpanelFlagVariant]? = nil) { + setupReadyFlags(flags: flags) + XCTAssertTrue(manager.areFlagsReady(), "Flags should be ready after setup") + } + + // JSON Parsing Helpers + + private func decodeJSON( + _ jsonString: String, + as type: T.Type, + file: StaticString = #file, + line: UInt = #line + ) -> T? { + guard let data = jsonString.data(using: .utf8) else { + XCTFail("Failed to convert JSON string to data", file: file, line: line) + return nil + } + + do { + return try JSONDecoder().decode(type, from: data) + } catch { + XCTFail("Failed to decode JSON: \(error)", file: file, line: line) + return nil + } + } + + private func assertJSONDecodes( + _ jsonString: String, + as type: T.Type, + file: StaticString = #file, + line: UInt = #line, + validation: (T) -> Void + ) { + if let result = decodeJSON(jsonString, as: type, file: file, line: line) { + validation(result) + } + } + + // Mock Configuration Helpers + + private var mockManager: MockFeatureFlagManager? { + return manager as? MockFeatureFlagManager + } + + private func configureMockFetch( + success: Bool, + flags: [String: MixpanelFlagVariant]? = nil, + withDelay: Bool = true + ) { + guard let mock = mockManager else { + XCTFail("Manager is not a MockFeatureFlagManager") + return + } + mock.simulatedFetchResult = (success: success, flags: flags ?? sampleFlags) + mock.shouldSimulateNetworkDelay = withDelay + } + + private func resetMockToSuccess() { + configureMockFetch(success: true, flags: sampleFlags, withDelay: true) + } + + // Context Verification Helper + + private func verifyRequestContext( + expectedDistinctId: String, + expectedDeviceId: String? = nil, + additionalChecks: (([String: Any]) -> Void)? = nil, + file: StaticString = #file, + line: UInt = #line + ) { + guard let mockMgr = mockManager, + let queryItems = mockMgr.lastQueryItems else { + XCTFail("No query items captured", file: file, line: line) + return + } + + let queryDict = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name, $0.value) }) + + guard let contextString = queryDict["context"], + let contextData = contextString?.data(using: .utf8), + let context = try? JSONSerialization.jsonObject(with: contextData) as? [String: Any] else { + XCTFail("Failed to parse context", file: file, line: line) + return + } + + XCTAssertEqual(context["distinct_id"] as? String, expectedDistinctId, file: file, line: line) + + if let expectedDeviceId = expectedDeviceId { + XCTAssertEqual(context["device_id"] as? String, expectedDeviceId, file: file, line: line) + } else { + XCTAssertNil(context["device_id"], file: file, line: line) + } + + additionalChecks?(context) + } + + // Variant Creation Helpers + + private func createExperimentVariant( + key: String, + value: Any?, + experimentID: String = "test-exp-id", + isActive: Bool = true, + isQATester: Bool = false + ) -> MixpanelFlagVariant { + return MixpanelFlagVariant( + key: key, + value: value, + isExperimentActive: isActive, + isQATester: isQATester, + experimentID: experimentID + ) + } + + private func createControlVariant(key: String = "control", value: Any? = false) -> MixpanelFlagVariant { + return MixpanelFlagVariant(key: key, value: value) + } + + // First-Time Event Helper + + private func setupAndTriggerFirstTimeEvent( + flagKey: String, + eventName: String, + eventProperties: [String: Any] = [:], + filters: [String: Any]? = nil, + pendingVariant: MixpanelFlagVariant, + initialVariant: MixpanelFlagVariant? = nil, + cohortHash: String = "hash123", + validation: ((MockFeatureFlagManager) -> Void)? = nil + ) { + guard let mockMgr = mockManager else { + XCTFail("Manager is not a MockFeatureFlagManager") + return + } + + let pendingEvent = createPendingEvent( + flagKey: flagKey, + eventName: eventName, + filters: filters, + pendingVariant: pendingVariant + ) + + let cohortKey = "\(flagKey):\(cohortHash)" + + mockMgr.accessQueue.sync { + let initial = initialVariant ?? createControlVariant() + mockMgr.flags = [flagKey: initial] + mockMgr.pendingFirstTimeEvents = [cohortKey: pendingEvent] + } + + mockMgr.checkFirstTimeEvents(eventName: eventName, properties: eventProperties) + waitBriefly(timeout: 1.0) + + mockMgr.accessQueue.sync { + validation?(mockMgr) + } + } + + // Manager State Helpers + + private func resetManagerFlags(_ flags: [String: MixpanelFlagVariant]? = nil) { + mockManager?.accessQueue.sync { + mockManager?.flags = flags + } + Thread.sleep(forTimeInterval: 0.01) + } + + private func clearManagerFlags() { + resetManagerFlags(nil) + } + + private func setManagerFlags(_ flags: [String: MixpanelFlagVariant]) { + resetManagerFlags(flags) + } + // --- State and Configuration Tests --- func testAreFeaturesReady_InitialState() { @@ -344,21 +693,12 @@ class FeatureFlagManagerTests: XCTestCase { } func testAreFeaturesReady_AfterSuccessfulFetchSimulation() { - simulateFetchSuccess() - // Need to wait briefly for the main queue dispatch in _completeFetch to potentially run - let expectation = XCTestExpectation(description: "Wait for potential completion dispatch") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } - wait(for: [expectation], timeout: 0.5) - XCTAssertTrue( - manager.areFlagsReady(), "Features should be ready after successful fetch simulation") + setupReadyFlagsAndVerify() } func testAreFeaturesReady_AfterFailedFetchSimulation() { simulateFetchFailure() - // Need to wait briefly for the main queue dispatch in _completeFetch to potentially run - let expectation = XCTestExpectation(description: "Wait for potential completion dispatch") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } - wait(for: [expectation], timeout: 0.5) + waitBriefly() XCTAssertFalse( manager.areFlagsReady(), "Features should not be ready after failed fetch simulation") } @@ -369,10 +709,7 @@ class FeatureFlagManagerTests: XCTestCase { mockDelegate.options = MixpanelOptions(token: "test", featureFlagsEnabled: false) // Explicitly disable manager.loadFlags() // Call public API - // Wait to ensure no async fetch operations started changing state - let expectation = XCTestExpectation(description: "Wait briefly") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } - wait(for: [expectation], timeout: 0.5) + waitBriefly() XCTAssertFalse(manager.areFlagsReady(), "Flags should not become ready if disabled") // We can't easily check if _fetchFlagsIfNeeded was *not* called without more testability hooks @@ -384,7 +721,7 @@ class FeatureFlagManagerTests: XCTestCase { // --- Sync Flag Retrieval Tests --- func testGetVariantSync_FlagsReady_ExistingFlag() { - simulateFetchSuccess() // Flags loaded + setupReadyFlags() let flagVariant = manager.getVariantSync("feature_string", fallback: defaultFallback) AssertEqual(flagVariant.key, "v_str") AssertEqual(flagVariant.value, "test_string") @@ -392,7 +729,7 @@ class FeatureFlagManagerTests: XCTestCase { } func testGetVariantSync_FlagsReady_MissingFlag_UsesFallback() { - simulateFetchSuccess() + setupReadyFlags() let fallback = MixpanelFlagVariant(key: "fb_key", value: "fb_value") let flagVariant = manager.getVariantSync("missing_feature", fallback: fallback) AssertEqual(flagVariant.key, fallback.key) @@ -410,13 +747,13 @@ class FeatureFlagManagerTests: XCTestCase { } func testGetVariantValueSync_FlagsReady() { - simulateFetchSuccess() + setupReadyFlags() let value = manager.getVariantValueSync("feature_int", fallbackValue: -1) AssertEqual(value, 101) } func testGetVariantValueSync_FlagsReady_MissingFlag() { - simulateFetchSuccess() + setupReadyFlags() let value = manager.getVariantValueSync("missing_feature", fallbackValue: "default") AssertEqual(value, "default") } @@ -428,23 +765,23 @@ class FeatureFlagManagerTests: XCTestCase { } func testIsFlagEnabledSync_FlagsReady_True() { - simulateFetchSuccess() + setupReadyFlags() XCTAssertTrue(manager.isEnabledSync("feature_bool_true")) } func testIsFlagEnabledSync_FlagsReady_False() { - simulateFetchSuccess() + setupReadyFlags() XCTAssertFalse(manager.isEnabledSync("feature_bool_false")) } func testIsFlagEnabledSync_FlagsReady_MissingFlag_UsesFallback() { - simulateFetchSuccess() + setupReadyFlags() XCTAssertTrue(manager.isEnabledSync("missing", fallbackValue: true)) XCTAssertFalse(manager.isEnabledSync("missing", fallbackValue: false)) } func testIsFlagEnabledSync_FlagsReady_NonBoolValue_UsesFallback() { - simulateFetchSuccess() + setupReadyFlags() XCTAssertTrue(manager.isEnabledSync("feature_string", fallbackValue: true)) // String value XCTAssertFalse(manager.isEnabledSync("feature_int", fallbackValue: false)) // Int value XCTAssertTrue(manager.isEnabledSync("feature_null", fallbackValue: true)) // Null value @@ -459,95 +796,37 @@ class FeatureFlagManagerTests: XCTestCase { // --- Async Flag Retrieval Tests --- func testGetVariant_Async_FlagsReady_ExistingFlag_XCTWaiter() { - // Arrange - simulateFetchSuccess() // Ensure flags are ready - let expectation = XCTestExpectation(description: "Async getFeature ready - XCTWaiter Wait") - var receivedData: MixpanelFlagVariant? - var assertionError: String? - - // Act - manager.getVariant("feature_double", fallback: defaultFallback) { data in - // This completion should run on the main thread - if !Thread.isMainThread { - assertionError = "Completion not on main thread (\(Thread.current))" - } - receivedData = data - // Perform crucial checks inside completion - if receivedData == nil { assertionError = (assertionError ?? "") + "; Received data was nil" } - if receivedData?.key != "v_double" { - assertionError = (assertionError ?? "") + "; Received key mismatch" - } - // Add other essential checks if needed - expectation.fulfill() - } - - // Assert - Wait using an explicit XCTWaiter instance - let waiter = XCTWaiter() - let result = waiter.wait(for: [expectation], timeout: 2.0) // Increased timeout - - // Check waiter result and any errors captured in completion - if result != .completed { - XCTFail( - "XCTWaiter timed out waiting for expectation. Error captured: \(assertionError ?? "None")") - } else if let error = assertionError { - XCTFail("Assertions failed within completion block: \(error)") - } - - // Final check on data after wait - // These might be redundant if checked thoroughly in completion, but good final check + setupReadyFlags() + let receivedData = getVariantAsync("feature_double") XCTAssertNotNil(receivedData, "Received data should be non-nil after successful wait") AssertEqual(receivedData?.key, "v_double") AssertEqual(receivedData?.value, 99.9) } func testGetVariant_Async_FlagsReady_MissingFlag_UsesFallback() { - simulateFetchSuccess() // Flags loaded - let expectation = XCTestExpectation( - description: "Async getFeature (Flags Ready, Missing) completes") + setupReadyFlags() let fallback = MixpanelFlagVariant(key: "fb_async", value: -1) - var receivedData: MixpanelFlagVariant? - - manager.getVariant("missing_feature", fallback: fallback) { data in - XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") - receivedData = data - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) - + let receivedData = getVariantAsync("missing_feature", fallback: fallback) XCTAssertNotNil(receivedData) AssertEqual(receivedData?.key, fallback.key) AssertEqual(receivedData?.value, fallback.value) - // Check delegate tracking after wait (should not have tracked) XCTAssertEqual(mockDelegate.trackedEvents.count, 0, "Should not track fallback") } // Test fetch triggering and completion via getFeature when not ready func testGetVariant_Async_FlagsNotReady_FetchSuccess() { XCTAssertFalse(manager.areFlagsReady()) - let expectation = XCTestExpectation( - description: "Async getFeature (Flags Not Ready) triggers fetch and succeeds") - var receivedData: MixpanelFlagVariant? // Setup tracking expectation *before* calling getFeature - mockDelegate.trackExpectation = XCTestExpectation( - description: "Tracking call for fetch success") + mockDelegate.trackExpectation = XCTestExpectation(description: "Tracking call for fetch success") - // Call getFeature - this should trigger the fetch logic internally - manager.getVariant("feature_int", fallback: defaultFallback) { data in - XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") - receivedData = data - expectation.fulfill() // Fulfill main expectation - } + let receivedData = getVariantAsync("feature_int", timeout: 3.0) - // MockFeatureFlagManager will automatically handle the fetch simulation - // No need for manual simulateFetchSuccess() - the mock handles it with delay - - // Wait for BOTH the getFeature completion AND the tracking expectation - wait(for: [expectation, mockDelegate.trackExpectation!], timeout: 3.0) // Increased timeout + // Wait for tracking to complete + wait(for: [mockDelegate.trackExpectation!], timeout: 3.0) XCTAssertNotNil(receivedData) - AssertEqual(receivedData?.key, "v_int") // Check correct flag data received + AssertEqual(receivedData?.key, "v_int") AssertEqual(receivedData?.value, 101) XCTAssertTrue(manager.areFlagsReady(), "Flags should be ready after successful fetch") XCTAssertEqual(mockDelegate.trackedEvents.count, 1, "Tracking event should have been recorded") @@ -555,27 +834,11 @@ class FeatureFlagManagerTests: XCTestCase { func testGetVariant_Async_FlagsNotReady_FetchFailure() { // Configure mock to simulate failure for this test - if let mockManager = manager as? MockFeatureFlagManager { - mockManager.simulatedFetchResult = (success: false, flags: nil) - } + configureMockFetch(success: false, flags: nil) XCTAssertFalse(manager.areFlagsReady()) - let expectation = XCTestExpectation( - description: "Async getFeature (Flags Not Ready) triggers fetch and fails") let fallback = MixpanelFlagVariant(key: "fb_fail", value: "failed_fetch") - var receivedData: MixpanelFlagVariant? - - // Call getFeature - mock will simulate failure automatically - manager.getVariant("feature_string", fallback: fallback) { data in - XCTAssertTrue(Thread.isMainThread, "Completion should be on main thread") - receivedData = data - expectation.fulfill() - } - - // MockFeatureFlagManager will automatically simulate failure - // No need for manual simulateFetchFailure() - - wait(for: [expectation], timeout: 3.0) + let receivedData = getVariantAsync("feature_string", fallback: fallback, timeout: 3.0) XCTAssertNotNil(receivedData) AssertEqual(receivedData?.key, fallback.key) // Should receive fallback @@ -585,9 +848,7 @@ class FeatureFlagManagerTests: XCTestCase { mockDelegate.trackedEvents.count, 0, "Should not track on fetch failure/fallback") // Reset mock configuration back to success for other tests - if let mockManager = manager as? MockFeatureFlagManager { - mockManager.simulatedFetchResult = (success: true, flags: sampleFlags) - } + resetMockToSuccess() } // --- Tracking Tests --- @@ -636,80 +897,33 @@ class FeatureFlagManagerTests: XCTestCase { } func testTracking_SendsCorrectProperties() { - simulateFetchSuccess() - mockDelegate.trackExpectation = XCTestExpectation( - description: "Track called for properties check") - - _ = manager.getVariantSync("feature_int", fallback: defaultFallback) // Trigger tracking - - wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) - - XCTAssertEqual(mockDelegate.trackedEvents.count, 1) - let tracked = mockDelegate.trackedEvents[0] - XCTAssertEqual(tracked.event, "$experiment_started") - XCTAssertNotNil(tracked.properties) - - let props = tracked.properties! - AssertEqual(props["Experiment name"] ?? nil, "feature_int") - AssertEqual(props["Variant name"] ?? nil, "v_int") - AssertEqual(props["$experiment_type"] ?? nil, "feature_flag") - - // Check timing properties are included (values may be nil if not set) - XCTAssertTrue(props.keys.contains("timeLastFetched"), "Should include timeLastFetched property") - XCTAssertTrue(props.keys.contains("fetchLatencyMs"), "Should include fetchLatencyMs property") + setupReadyFlags() + expectTracking { + _ = manager.getVariantSync("feature_int", fallback: defaultFallback) + } + verifyTrackedEvent(experimentName: "feature_int", variantName: "v_int", checkTimingProperties: true) } func testTracking_IncludesTimingProperties() { - simulateFetchSuccess() - mockDelegate.trackExpectation = XCTestExpectation( - description: "Track called with timing properties") - - _ = manager.getVariantSync("feature_string", fallback: defaultFallback) // Trigger tracking - - wait(for: [mockDelegate.trackExpectation!], timeout: 1.0) - - XCTAssertEqual(mockDelegate.trackedEvents.count, 1) - let tracked = mockDelegate.trackedEvents[0] - let props = tracked.properties! - - // Verify timing properties have expected values - if let timeLastFetched = props["timeLastFetched"] as? Int { - XCTAssertGreaterThan(timeLastFetched, 0, "timeLastFetched should be a positive timestamp") - } else { - XCTFail("timeLastFetched should be present and be an Int") - } - - if let fetchLatencyMs = props["fetchLatencyMs"] as? Int { - XCTAssertEqual(fetchLatencyMs, 150, "fetchLatencyMs should match simulated value") - } else { - XCTFail("fetchLatencyMs should be present and be an Int") + setupReadyFlags() + expectTracking { + _ = manager.getVariantSync("feature_string", fallback: defaultFallback) } + verifyTrackedEvent(experimentName: "feature_string", variantName: "v_str", checkTimingProperties: true, expectedLatency: 150) } func testTracking_DoesNotTrackForFallback_Sync() { - simulateFetchSuccess() // Flags ready - _ = manager.getVariantSync( - "missing_feature", fallback: MixpanelFlagVariant(key: "fb", value: "v")) // Request missing flag - // Wait briefly to ensure no unexpected tracking call - let expectation = XCTestExpectation(description: "Wait briefly for no track") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { expectation.fulfill() } - wait(for: [expectation], timeout: 0.5) + setupReadyFlags() + _ = manager.getVariantSync("missing_feature", fallback: MixpanelFlagVariant(key: "fb", value: "v")) + waitBriefly() XCTAssertEqual( mockDelegate.trackedEvents.count, 0, "Track should not be called when a fallback is used (sync)") } func testTracking_DoesNotTrackForFallback_Async() { - simulateFetchSuccess() // Flags ready - let expectation = XCTestExpectation(description: "Async getFeature (Fallback) completes") - - manager.getVariant("missing_feature", fallback: MixpanelFlagVariant(key: "fb", value: "v")) { - _ in - expectation.fulfill() - } - - wait(for: [expectation], timeout: 1.0) - // Check delegate tracking after wait + setupReadyFlags() + getVariantAsync("missing_feature", fallback: MixpanelFlagVariant(key: "fb", value: "v")) XCTAssertEqual( mockDelegate.trackedEvents.count, 0, "Track should not be called when a fallback is used (async)") @@ -1701,4 +1915,286 @@ class FeatureFlagManagerTests: XCTestCase { } } + // MARK: - First-Time Event Targeting Tests + + // MARK: Response Parsing Tests + + func testParsePendingFirstTimeEvents() { + let json = """ + { + "flags": { + "test-flag": { + "variant_key": "control", + "variant_value": false + } + }, + "pending_first_time_events": [ + { + "flag_key": "test-flag", + "flag_id": "flag-123", + "project_id": 3, + "cohort_hash": "abc123", + "event_name": "Purchase Complete", + "property_filters": { + ">": [{"var": "properties.amount"}, 100] + }, + "pending_variant": { + "variant_key": "treatment", + "variant_value": true, + "experiment_id": "exp-456", + "is_experiment_active": true + } + } + ] + } + """.data(using: .utf8)! + + do { + let response = try JSONDecoder().decode(FlagsResponse.self, from: json) + XCTAssertNotNil(response.flags) + XCTAssertNotNil(response.pendingFirstTimeEvents) + XCTAssertEqual(response.pendingFirstTimeEvents?.count, 1) + + let pendingEvent = response.pendingFirstTimeEvents![0] + XCTAssertEqual(pendingEvent.flagKey, "test-flag") + XCTAssertEqual(pendingEvent.flagId, "flag-123") + XCTAssertEqual(pendingEvent.projectId, 3) + XCTAssertEqual(pendingEvent.cohortHash, "abc123") + XCTAssertEqual(pendingEvent.eventName, "Purchase Complete") + XCTAssertNotNil(pendingEvent.propertyFilters) + XCTAssertEqual(pendingEvent.pendingVariant.key, "treatment") + XCTAssertEqual(pendingEvent.pendingVariant.value as? Bool, true) + } catch { + XCTFail("Failed to parse response: \(error)") + } + } + + func testParseEmptyPendingFirstTimeEvents() { + let json = """ + { + "flags": {}, + "pending_first_time_events": [] + } + """.data(using: .utf8)! + + do { + let response = try JSONDecoder().decode(FlagsResponse.self, from: json) + XCTAssertNotNil(response.pendingFirstTimeEvents) + XCTAssertEqual(response.pendingFirstTimeEvents?.count, 0) + } catch { + XCTFail("Failed to parse response: \(error)") + } + } + + // MARK: First-Time Event Matching Tests + + func testFirstTimeEventMatching_ExactNameMatch() { + let pendingVariant = createExperimentVariant(key: "activated", value: true, experimentID: "exp-123") + let initialVariant = createControlVariant(value: false) + + setupAndTriggerFirstTimeEvent( + flagKey: "welcome-modal", + eventName: "Dashboard Viewed", + pendingVariant: pendingVariant, + initialVariant: initialVariant + ) { mockMgr in + let flag = mockMgr.flags?["welcome-modal"] + XCTAssertEqual(flag?.key, "activated") + XCTAssertEqual(flag?.value as? Bool, true) + XCTAssertTrue(mockMgr.activatedFirstTimeEvents.contains("welcome-modal:hash123")) + } + } + + func testFirstTimeEventMatching_WithPropertyFilters() { + let pendingVariant = createExperimentVariant(key: "premium", value: ["discount": 20], experimentID: "exp-456") + let initialVariant = createControlVariant(value: nil) + let filters: [String: Any] = [">": [["var": "properties.amount"], 100]] + + setupAndTriggerFirstTimeEvent( + flagKey: "premium-welcome", + eventName: "Purchase Complete", + eventProperties: ["amount": 150], + filters: filters, + pendingVariant: pendingVariant, + initialVariant: initialVariant, + cohortHash: "hash456" + ) { mockMgr in + let flag = mockMgr.flags?["premium-welcome"] + XCTAssertEqual(flag?.key, "premium") + XCTAssertTrue(mockMgr.activatedFirstTimeEvents.contains("premium-welcome:hash456")) + } + } + + func testFirstTimeEventMatching_PropertyFilterNoMatch() { + let pendingVariant = MixpanelFlagVariant(key: "premium", value: true) + let initialVariant = createControlVariant(value: false) + let filters: [String: Any] = [">": [["var": "properties.amount"], 100]] + + // Trigger event with amount < 100 (should NOT match) + setupAndTriggerFirstTimeEvent( + flagKey: "premium-welcome", + eventName: "Purchase Complete", + eventProperties: ["amount": 50], + filters: filters, + pendingVariant: pendingVariant, + initialVariant: initialVariant, + cohortHash: "hash456" + ) { mockMgr in + let flag = mockMgr.flags?["premium-welcome"] + XCTAssertEqual(flag?.key, "control") + XCTAssertFalse(mockMgr.activatedFirstTimeEvents.contains("premium-welcome:hash456")) + } + } + + func testFirstTimeEventMatching_CaseInsensitiveProperties() { + let pendingVariant = MixpanelFlagVariant(key: "matched", value: true) + let initialVariant = createControlVariant(value: false) + let filters: [String: Any] = ["==": [["var": "properties.plan"], "PREMIUM"]] + + // Trigger event with lowercase plan (should match due to case-insensitive comparison) + setupAndTriggerFirstTimeEvent( + flagKey: "case-test", + eventName: "Test Event", + eventProperties: ["plan": "premium"], + filters: filters, + pendingVariant: pendingVariant, + initialVariant: initialVariant, + cohortHash: "hash789" + ) { mockMgr in + let flag = mockMgr.flags?["case-test"] + XCTAssertEqual(flag?.key, "matched") + } + } + + // MARK: Activation State Tests + + func testFirstTimeEventActivatesOnlyOnce() { + if let mockManager = manager as? MockFeatureFlagManager { + let pendingVariant = MixpanelFlagVariant(key: "activated", value: true) + + let pendingEvent = createPendingEvent( + flagKey: "once-only", + eventName: "Test Event", + filters: nil, + pendingVariant: pendingVariant + ) + + mockManager.accessQueue.sync { + mockManager.flags = ["once-only": MixpanelFlagVariant(key: "control", value: false)] + mockManager.pendingFirstTimeEvents = ["once-only:hash999": pendingEvent] + // Reset tracking state + mockManager.recordFirstTimeEventCallCount = 0 + } + + // Trigger event multiple times + mockManager.checkFirstTimeEvents(eventName: "Test Event", properties: [:]) + mockManager.checkFirstTimeEvents(eventName: "Test Event", properties: [:]) + mockManager.checkFirstTimeEvents(eventName: "Test Event", properties: [:]) + + let expectation = XCTestExpectation(description: "Event processing completes") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } + wait(for: [expectation], timeout: 1.0) + + // Verify activation occurred and is tracked + mockManager.accessQueue.sync { + XCTAssertTrue(mockManager.activatedFirstTimeEvents.contains("once-only:hash999")) + + // Verify recordFirstTimeEvent was called exactly once + XCTAssertEqual(mockManager.recordFirstTimeEventCallCount, 1, + "recordFirstTimeEvent should be called exactly once, not \(mockManager.recordFirstTimeEventCallCount) times") + + // Verify the correct parameters were recorded + XCTAssertEqual(mockManager.lastRecordedFlagId, "test-flag-id") + XCTAssertEqual(mockManager.lastRecordedProjectId, 1) + XCTAssertEqual(mockManager.lastRecordedCohortHash, "hash123") + } + } + } + + // MARK: Flag Refresh Edge Cases + + func testFlagRefresh_PreservesActivatedVariants() { + if let mockManager = manager as? MockFeatureFlagManager { + // Set up initial state with activated variant + mockManager.accessQueue.sync { + mockManager.flags = ["test-flag": MixpanelFlagVariant(key: "activated", value: true)] + mockManager.activatedFirstTimeEvents.insert("test-flag:hash123") + } + + // Simulate fetch response with different variant for same flag + let newFlags = ["test-flag": MixpanelFlagVariant(key: "control", value: false)] + mockManager.simulatedFetchResult = (success: true, flags: newFlags) + + // Trigger fetch + mockManager.loadFlags() + + let expectation = XCTestExpectation(description: "Fetch completes") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } + wait(for: [expectation], timeout: 1.0) + + // Verify activated variant was preserved + mockManager.accessQueue.sync { + let flag = mockManager.flags?["test-flag"] + XCTAssertEqual(flag?.key, "activated", "Activated variant should be preserved") + XCTAssertEqual(flag?.value as? Bool, true) + } + } + } + + func testFlagRefresh_KeepsOrphanedActivatedFlags() { + if let mockManager = manager as? MockFeatureFlagManager { + // Set up initial state with activated variant + mockManager.accessQueue.sync { + mockManager.flags = ["orphaned-flag": MixpanelFlagVariant(key: "activated", value: true)] + mockManager.activatedFirstTimeEvents.insert("orphaned-flag:hash123") + } + + // Simulate fetch response WITHOUT the orphaned flag + let newFlags = ["other-flag": MixpanelFlagVariant(key: "control", value: false)] + mockManager.simulatedFetchResult = (success: true, flags: newFlags) + + // Trigger fetch + mockManager.loadFlags() + + let expectation = XCTestExpectation(description: "Fetch completes") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { expectation.fulfill() } + wait(for: [expectation], timeout: 1.0) + + // Verify orphaned flag was kept + mockManager.accessQueue.sync { + let flag = mockManager.flags?["orphaned-flag"] + XCTAssertNotNil(flag, "Orphaned activated flag should be kept") + XCTAssertEqual(flag?.key, "activated") + } + } + } + + // MARK: Helper Methods + + private func createPendingEvent( + flagKey: String, + eventName: String, + filters: [String: Any]?, + pendingVariant: MixpanelFlagVariant + ) -> PendingFirstTimeEvent { + let json: [String: Any] = [ + "flag_key": flagKey, + "flag_id": "test-flag-id", + "project_id": 1, + "cohort_hash": "hash123", + "event_name": eventName, + "property_filters": filters as Any, + "pending_variant": [ + "variant_key": pendingVariant.key, + "variant_value": pendingVariant.value as Any, + "experiment_id": pendingVariant.experimentID as Any, + "is_experiment_active": pendingVariant.isExperimentActive as Any, + "is_qa_tester": pendingVariant.isQATester as Any + ] + ] + + let data = try! JSONSerialization.data(withJSONObject: json) + return try! JSONDecoder().decode(PendingFirstTimeEvent.self, from: data) + } + } // End Test Class diff --git a/Package.swift b/Package.swift index 844f6f94..76462b56 100644 --- a/Package.swift +++ b/Package.swift @@ -13,9 +13,18 @@ let package = Package( products: [ .library(name: "Mixpanel", targets: ["Mixpanel"]) ], + dependencies: [ + .package( + url: "https://github.com/advantagefse/json-logic-swift", + from: "1.2.0" + ) + ], targets: [ .target( name: "Mixpanel", + dependencies: [ + .product(name: "jsonlogic", package: "json-logic-swift") + ], path: "Sources", resources: [ .copy("Mixpanel/PrivacyInfo.xcprivacy") diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index ec91df94..d72d03fe 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -1,4 +1,7 @@ import Foundation +import jsonlogic + +// MARK: - AnyCodable // Wrapper to help decode 'Any' types within Codable structures // (Keep AnyCodable as defined previously, it holds the necessary decoding logic) @@ -76,9 +79,55 @@ public struct MixpanelFlagVariant: Decodable { } } +// MARK: - PendingFirstTimeEvent + +/// Represents a pending first-time event definition from the flags endpoint +struct PendingFirstTimeEvent: Decodable { + let flagKey: String + let flagId: String + let projectId: Int + let cohortHash: String + let eventName: String + let propertyFilters: [String: Any]? + let pendingVariant: MixpanelFlagVariant + + enum CodingKeys: String, CodingKey { + case flagKey = "flag_key" + case flagId = "flag_id" + case projectId = "project_id" + case cohortHash = "cohort_hash" + case eventName = "event_name" + case propertyFilters = "property_filters" + case pendingVariant = "pending_variant" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + flagKey = try container.decode(String.self, forKey: .flagKey) + flagId = try container.decode(String.self, forKey: .flagId) + projectId = try container.decode(Int.self, forKey: .projectId) + cohortHash = try container.decode(String.self, forKey: .cohortHash) + eventName = try container.decode(String.self, forKey: .eventName) + pendingVariant = try container.decode(MixpanelFlagVariant.self, forKey: .pendingVariant) + + // Decode propertyFilters using AnyCodable + if let filtersContainer = try? container.decode([String: AnyCodable].self, forKey: .propertyFilters) { + propertyFilters = filtersContainer.mapValues { $0.value } + } else { + propertyFilters = nil + } + } +} + // Response structure for the /flags endpoint struct FlagsResponse: Decodable { let flags: [String: MixpanelFlagVariant]? // Dictionary where key is flag name + let pendingFirstTimeEvents: [PendingFirstTimeEvent]? // Array of pending first-time event definitions + + enum CodingKeys: String, CodingKey { + case flags + case pendingFirstTimeEvents = "pending_first_time_events" + } } // --- FeatureFlagDelegate Protocol --- @@ -189,6 +238,14 @@ public protocol MixpanelFlags { /// - completion: A closure that is called with the boolean result. /// This closure will be executed on the main dispatch queue. func isEnabled(_ flagName: String, fallbackValue: Bool, completion: @escaping (Bool) -> Void) + + /// Checks if a tracked event matches any pending first-time events and activates the corresponding variant. + /// This method is called automatically from the tracking flow. + /// + /// - Parameters: + /// - eventName: The name of the tracked event + /// - properties: The properties associated with the event + func checkFirstTimeEvents(eventName: String, properties: [String: Any]) } // --- FeatureFlagManager Class --- @@ -211,6 +268,10 @@ class FeatureFlagManager: Network, MixpanelFlags { private var trackedFeatures: Set = Set() private var fetchCompletionHandlers: [(Bool) -> Void] = [] + // First-time event targeting state + internal var pendingFirstTimeEvents: [String: PendingFirstTimeEvent] = [:] // Keyed by "flagKey:cohortHash" + internal var activatedFirstTimeEvents: Set = Set() // Stores "flagKey:cohortHash" keys + // Timing tracking properties private var fetchStartTime: Date? var timeLastFetched: Date? @@ -461,13 +522,11 @@ class FeatureFlagManager: Network, MixpanelFlags { return } - guard let authData = "\(options.token):".data(using: .utf8) else { - print("Error: Failed to create auth data.") + guard let headers = createAuthHeaders(token: options.token) else { + print("Error: Failed to create auth headers.") self._completeFetch(success: false) return } - let base64Auth = authData.base64EncodedString() - let headers = ["Authorization": "Basic \(base64Auth)"] let queryItems = [ URLQueryItem(name: "context", value: contextString), @@ -505,7 +564,14 @@ class FeatureFlagManager: Network, MixpanelFlags { self.accessQueue.async { [weak self] in guard let self = self else { return } // already on accessQueue – write directly - self.flags = flagsResponse.flags ?? [:] + + let (mergedFlags, mergedPendingEvents) = self.mergeFlags( + responseFlags: flagsResponse.flags, + responsePendingEvents: flagsResponse.pendingFirstTimeEvents + ) + + self.flags = mergedFlags + self.pendingFirstTimeEvents = mergedPendingEvents // Calculate timing metrics if let startTime = self.fetchStartTime { @@ -514,7 +580,7 @@ class FeatureFlagManager: Network, MixpanelFlags { } self.timeLastFetched = fetchEndTime - print("Flags updated: \(self.flags ?? [:])") + print("Flags updated: \(self.flags ?? [:]), Pending events: \(self.pendingFirstTimeEvents.count)") self._completeFetch(success: true) // still on accessQueue } } @@ -532,6 +598,60 @@ class FeatureFlagManager: Network, MixpanelFlags { } } + // --- Flag Merging Helper --- + func mergeFlags( + responseFlags: [String: MixpanelFlagVariant]?, + responsePendingEvents: [PendingFirstTimeEvent]? + ) -> (flags: [String: MixpanelFlagVariant], pendingEvents: [String: PendingFirstTimeEvent]) { + var newFlags: [String: MixpanelFlagVariant] = [:] + var newPendingEvents: [String: PendingFirstTimeEvent] = [:] + + // Process flags from response + if let responseFlags = responseFlags { + for (flagKey, variant) in responseFlags { + // Check if any event for this flag was activated + let hasActivatedEvent = self.activatedFirstTimeEvents.contains { eventKey in + eventKey.hasPrefix("\(flagKey):") + } + + if hasActivatedEvent, let currentFlag = self.flags?[flagKey] { + // Preserve activated variant + newFlags[flagKey] = currentFlag + } else { + // Use server's current variant + newFlags[flagKey] = variant + } + } + } + + // Process pending first-time events from response + if let responsePendingEvents = responsePendingEvents { + for pendingEvent in responsePendingEvents { + let eventKey = self.getPendingEventKey(pendingEvent.flagKey, pendingEvent.cohortHash) + + // Skip if already activated + if self.activatedFirstTimeEvents.contains(eventKey) { + continue + } + + newPendingEvents[eventKey] = pendingEvent + } + } + + // Preserve orphaned activated flags + for eventKey in self.activatedFirstTimeEvents { + guard let flagKey = self.getFlagKeyFromPendingEventKey(eventKey) else { + print("Warning: Failed to parse flag key from event key: \(eventKey)") + continue + } + if newFlags[flagKey] == nil, let orphanedFlag = self.flags?[flagKey] { + newFlags[flagKey] = orphanedFlag + } + } + + return (flags: newFlags, pendingEvents: newPendingEvents) + } + // --- Tracking Logic --- // Performs the atomic check and triggers delegate call if needed @@ -613,4 +733,186 @@ class FeatureFlagManager: Network, MixpanelFlags { return fallbackValue } } + + // --- Auth Header Helper --- + private func createAuthHeaders(token: String, includeContentType: Bool = false) -> [String: String]? { + guard let authData = "\(token):".data(using: .utf8) else { + return nil + } + + var headers = ["Authorization": "Basic \(authData.base64EncodedString())"] + + if includeContentType { + headers["Content-Type"] = "application/json" + } + + return headers + } + + // MARK: - First-Time Event Helpers + + /// Generic recursive transformation function for nested structures + private func transformStringsRecursively( + _ val: Any, + transformDictKey: (String) -> String = { $0 } + ) -> Any { + if let stringValue = val as? String { + return stringValue.lowercased() + } else if let arrayValue = val as? [Any] { + return arrayValue.map { transformStringsRecursively($0, transformDictKey: transformDictKey) } + } else if let dictValue = val as? [String: Any] { + var result: [String: Any] = [:] + for (key, value) in dictValue { + let newKey = transformDictKey(key) + result[newKey] = transformStringsRecursively(value, transformDictKey: transformDictKey) + } + return result + } else { + return val + } + } + + /// Lowercase all string keys and values in a nested structure + private func lowercaseKeysAndValues(_ val: Any) -> Any { + return transformStringsRecursively(val, transformDictKey: { $0.lowercased() }) + } + + /// Lowercase only leaf node string values in a nested structure (keys unchanged) + private func lowercaseOnlyLeafNodes(_ val: Any) -> Any { + return transformStringsRecursively(val) + } + + /// Generate a unique key for a pending first-time event + private func getPendingEventKey(_ flagKey: String, _ cohortHash: String) -> String { + return "\(flagKey):\(cohortHash)" + } + + /// Extract the flag key from a pending event key + private func getFlagKeyFromPendingEventKey(_ eventKey: String) -> String? { + return eventKey.components(separatedBy: ":").first + } + + // MARK: - First-Time Event Checking + + /// Checks if a tracked event matches any pending first-time events and activates the corresponding variant + func checkFirstTimeEvents(eventName: String, properties: [String: Any]) { + accessQueue.async { [weak self] in + guard let self = self else { return } + + // Iterate through all pending first-time events + for (eventKey, pendingEvent) in self.pendingFirstTimeEvents { + // Skip if already activated + if self.activatedFirstTimeEvents.contains(eventKey) { + continue + } + + // Check exact event name match (case-sensitive) + if eventName != pendingEvent.eventName { + continue + } + + // Evaluate property filters using json-logic-swift library + if let filters = pendingEvent.propertyFilters, !filters.isEmpty { + // Lowercase all keys and values in event properties for case-insensitive matching + let lowercasedProperties = self.lowercaseKeysAndValues(properties) + + // Lowercase only leaf nodes in JsonLogic filters (keep operators intact) + let lowercasedFilters = self.lowercaseOnlyLeafNodes(filters) + + // Prepare data for JsonLogic evaluation + let data = ["properties": lowercasedProperties] + + // Convert to JSON strings for json-logic-swift library + guard let rulesData = try? JSONSerialization.data(withJSONObject: lowercasedFilters), + let rulesString = String(data: rulesData, encoding: .utf8), + let dataJSON = try? JSONSerialization.data(withJSONObject: data), + let dataString = String(data: dataJSON, encoding: .utf8) else { + print("Warning: Failed to serialize JsonLogic filters for event '\(eventKey)' matching '\(eventName)'") + continue + } + + // Evaluate the filter + guard let result: Bool = try? applyRule(rulesString, to: dataString), + result == true else { + continue + } + } + + // Event matched! Activate the variant + let flagKey = pendingEvent.flagKey + print("First-time event matched for flag '\(flagKey)': \(eventName)") + + // Update or create the flag with the pending variant + if self.flags == nil { + self.flags = [:] + } + self.flags![flagKey] = pendingEvent.pendingVariant + + // Mark this specific event as activated + self.activatedFirstTimeEvents.insert(eventKey) + + // Track the feature flag check event with the new variant + self._trackFlagIfNeeded(flagName: flagKey, variant: pendingEvent.pendingVariant) + + // Record to backend (fire-and-forget) + self.recordFirstTimeEvent( + flagId: pendingEvent.flagId, + projectId: pendingEvent.projectId, + cohortHash: pendingEvent.cohortHash + ) + } + } + } + + /// Records a first-time event activation to the backend + internal func recordFirstTimeEvent(flagId: String, projectId: Int, cohortHash: String) { + guard let delegate = self.delegate else { + print("Error: Delegate missing for recording first-time event") + return + } + + let distinctId = delegate.getDistinctId() + let url = "/flags/\(flagId)/first-time-events" + + let payload: [String: Any] = [ + "distinct_id": distinctId, + "project_id": projectId, + "cohort_hash": cohortHash + ] + + guard let jsonData = try? JSONSerialization.data(withJSONObject: payload), + let options = currentOptions else { + print("Error: Failed to prepare first-time event recording request") + return + } + + guard let headers = createAuthHeaders(token: options.token, includeContentType: true) else { + print("Error: Failed to create auth headers for first-time event recording") + return + } + + let responseParser: (Data) -> Bool? = { _ in true } + let resource = Network.buildResource( + path: url, + method: .post, + requestBody: jsonData, + headers: headers, + parse: responseParser + ) + + print("Recording first-time event for flag: \(flagId)") + + // Fire-and-forget POST request + Network.apiRequest( + base: serverURL, + resource: resource, + failure: { reason, _, _ in + // Silent failure - cohort sync will catch up + print("Failed to record first-time event for flag \(flagId): \(reason)") + }, + success: { _, _ in + print("Successfully recorded first-time event for flag \(flagId)") + } + ) + } } diff --git a/Sources/Track.swift b/Sources/Track.swift index 046cfb8d..a0932bad 100644 --- a/Sources/Track.swift +++ b/Sources/Track.swift @@ -81,6 +81,11 @@ class Track { p += properties } + // Check for first-time event matches + if let mixpanelInstance = mixpanelInstance { + mixpanelInstance.flags.checkFirstTimeEvents(eventName: ev, properties: p) + } + var trackEvent: InternalProperties = ["event": ev, "properties": p] metadata.toDict().forEach { (k, v) in trackEvent[k] = v }