diff --git a/Demo/UITests/PopupBridge_DemoUITests.swift b/Demo/UITests/PopupBridge_DemoUITests.swift index c4db7ff..f640f18 100644 --- a/Demo/UITests/PopupBridge_DemoUITests.swift +++ b/Demo/UITests/PopupBridge_DemoUITests.swift @@ -56,7 +56,7 @@ final class PopupBridge_DemoUITests: XCTestCase { // MARK: - Helpers - func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 10) { + func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 15) { expectation(for: NSPredicate(format: "exists ==1"), evaluatedWith: element) waitForExpectations(timeout: timeout) } diff --git a/PopupBridge.xcodeproj/project.pbxproj b/PopupBridge.xcodeproj/project.pbxproj index 8f2419e..ca5dc08 100644 --- a/PopupBridge.xcodeproj/project.pbxproj +++ b/PopupBridge.xcodeproj/project.pbxproj @@ -7,10 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 45AE41D22D7649F800388548 /* MockAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45AE41D12D7649EE00388548 /* MockAnalyticsService.swift */; }; 45CD0C2C2D64F08F0072C5A4 /* FPTIBatchData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD0C2B2D64F0810072C5A4 /* FPTIBatchData.swift */; }; 45CD0C2E2D664D140072C5A4 /* Date+MilisecondTimestamp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD0C2D2D664CF50072C5A4 /* Date+MilisecondTimestamp.swift */; }; 45CD0C302D6788A10072C5A4 /* Bundle+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD0C2F2D67888F0072C5A4 /* Bundle+Extension.swift */; }; 45CD0C322D6793FB0072C5A4 /* PopupBridgeAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45CD0C312D6793F90072C5A4 /* PopupBridgeAnalytics.swift */; }; + 45FBAF272D6F9CCF000D550B /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBAF262D6F9CC6000D550B /* AnalyticsService.swift */; }; + 45FBAF292D701AFE000D550B /* Sessionable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBAF282D701AF7000D550B /* Sessionable.swift */; }; + 45FBAF2B2D701B12000D550B /* NetworkError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBAF2A2D701B0D000D550B /* NetworkError.swift */; }; + 45FBAF2F2D701E24000D550B /* MockSessionable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBAF2E2D701E1D000D550B /* MockSessionable.swift */; }; + 45FBAF352D70CFB6000D550B /* AnalyticsService_Test.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBAF342D70CFB6000D550B /* AnalyticsService_Test.swift */; }; 45FC74C22D8347B500E50035 /* UIApplication+URLOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FC74C12D8347AA00E50035 /* UIApplication+URLOpener.swift */; }; 62D5EC522B9F753100D09C5D /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 62D5EC512B9F753100D09C5D /* PrivacyInfo.xcprivacy */; }; 79DB9F7F53319F206CDE119E /* Pods_UnitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 28245E4F1AC5126D54985D88 /* Pods_UnitTests.framework */; }; @@ -31,10 +37,16 @@ /* Begin PBXFileReference section */ 28245E4F1AC5126D54985D88 /* Pods_UnitTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_UnitTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 45AE41D12D7649EE00388548 /* MockAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAnalyticsService.swift; sourceTree = ""; }; 45CD0C2B2D64F0810072C5A4 /* FPTIBatchData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FPTIBatchData.swift; sourceTree = ""; }; 45CD0C2D2D664CF50072C5A4 /* Date+MilisecondTimestamp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+MilisecondTimestamp.swift"; sourceTree = ""; }; 45CD0C2F2D67888F0072C5A4 /* Bundle+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+Extension.swift"; sourceTree = ""; }; 45CD0C312D6793F90072C5A4 /* PopupBridgeAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupBridgeAnalytics.swift; sourceTree = ""; }; + 45FBAF262D6F9CC6000D550B /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = ""; }; + 45FBAF282D701AF7000D550B /* Sessionable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sessionable.swift; sourceTree = ""; }; + 45FBAF2A2D701B0D000D550B /* NetworkError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkError.swift; sourceTree = ""; }; + 45FBAF2E2D701E1D000D550B /* MockSessionable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSessionable.swift; sourceTree = ""; }; + 45FBAF342D70CFB6000D550B /* AnalyticsService_Test.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService_Test.swift; sourceTree = ""; }; 45FC74C12D8347AA00E50035 /* UIApplication+URLOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIApplication+URLOpener.swift"; sourceTree = ""; }; 4EF7C7DDAB0B99FF28DD6541 /* Pods-UnitTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-UnitTests.debug.xcconfig"; path = "Target Support Files/Pods-UnitTests/Pods-UnitTests.debug.xcconfig"; sourceTree = ""; }; 6003F58D195388D20070C39A /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; @@ -96,6 +108,16 @@ path = Pods; sourceTree = ""; }; + 45FC74C32D84922F00E50035 /* Analytics */ = { + isa = PBXGroup; + children = ( + 45FBAF262D6F9CC6000D550B /* AnalyticsService.swift */, + 45CD0C2B2D64F0810072C5A4 /* FPTIBatchData.swift */, + 45CD0C312D6793F90072C5A4 /* PopupBridgeAnalytics.swift */, + ); + path = Analytics; + sourceTree = ""; + }; 6003F581195388D10070C39A = { isa = PBXGroup; children = ( @@ -153,8 +175,11 @@ A775A07D1DEE4E7E009E67C2 /* UnitTests */ = { isa = PBXGroup; children = ( + 45FBAF342D70CFB6000D550B /* AnalyticsService_Test.swift */, A775A0801DEE4E7E009E67C2 /* Info.plist */, + 45AE41D12D7649EE00388548 /* MockAnalyticsService.swift */, BE2524552A17FB9F00168D77 /* MockScriptMessage.swift */, + 45FBAF2E2D701E1D000D550B /* MockSessionable.swift */, BE2524532A17DFCC00168D77 /* MockUserContentController.swift */, BEF9ED202A2A4896005D54AB /* MockWebAuthenticationSession.swift */, BE2524512A17DF8200168D77 /* PopupBridge_UnitTests.swift */, @@ -165,16 +190,17 @@ A775A08C1DEE4EF0009E67C2 /* PopupBridge */ = { isa = PBXGroup; children = ( - 45FC74C12D8347AA00E50035 /* UIApplication+URLOpener.swift */, + 45FC74C32D84922F00E50035 /* Analytics */, 45CD0C2F2D67888F0072C5A4 /* Bundle+Extension.swift */, 45CD0C2D2D664CF50072C5A4 /* Date+MilisecondTimestamp.swift */, - 45CD0C2B2D64F0810072C5A4 /* FPTIBatchData.swift */, + 45FBAF2A2D701B0D000D550B /* NetworkError.swift */, 800A09D72995F143003ED16E /* POPPopupBridge.swift */, A79330F01DF0F98F00EE479D /* PopupBridge-Framework-Info.plist */, - 45CD0C312D6793F90072C5A4 /* PopupBridgeAnalytics.swift */, BEF9ED222A2A6A2C005D54AB /* PopupBridgeConstants.swift */, 8079D7192996F6C200A2E336 /* PopupBridgeUserScript.swift */, 62D5EC512B9F753100D09C5D /* PrivacyInfo.xcprivacy */, + 45FBAF282D701AF7000D550B /* Sessionable.swift */, + 45FC74C12D8347AA00E50035 /* UIApplication+URLOpener.swift */, 800E789E29E09A2A00D1B0FC /* URLDetailsPayload.swift */, BE8E37B52A17B79E00181FDA /* WebAuthenticationSession.swift */, 800E789C29E0958A00D1B0FC /* WebViewMessage.swift */, @@ -320,10 +346,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 45FBAF2F2D701E24000D550B /* MockSessionable.swift in Sources */, BE2524542A17DFCC00168D77 /* MockUserContentController.swift in Sources */, + 45AE41D22D7649F800388548 /* MockAnalyticsService.swift in Sources */, BE2524562A17FB9F00168D77 /* MockScriptMessage.swift in Sources */, BEF9ED212A2A4896005D54AB /* MockWebAuthenticationSession.swift in Sources */, BE2524522A17DF8200168D77 /* PopupBridge_UnitTests.swift in Sources */, + 45FBAF352D70CFB6000D550B /* AnalyticsService_Test.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -331,8 +360,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 45FBAF292D701AFE000D550B /* Sessionable.swift in Sources */, + 45FBAF2B2D701B12000D550B /* NetworkError.swift in Sources */, 800E789F29E09A2A00D1B0FC /* URLDetailsPayload.swift in Sources */, 45CD0C302D6788A10072C5A4 /* Bundle+Extension.swift in Sources */, + 45FBAF272D6F9CCF000D550B /* AnalyticsService.swift in Sources */, BEF9ED232A2A6A2C005D54AB /* PopupBridgeConstants.swift in Sources */, 45FC74C22D8347B500E50035 /* UIApplication+URLOpener.swift in Sources */, 800A09D82995F143003ED16E /* POPPopupBridge.swift in Sources */, diff --git a/Sources/PopupBridge/Analytics/AnalyticsService.swift b/Sources/PopupBridge/Analytics/AnalyticsService.swift new file mode 100644 index 0000000..c5ad439 --- /dev/null +++ b/Sources/PopupBridge/Analytics/AnalyticsService.swift @@ -0,0 +1,62 @@ +import Foundation + +protocol AnalyticsServiceable { + func sendAnalyticsEvent(_ eventName: String, sessionID: String) +} + +final class AnalyticsService: AnalyticsServiceable { + + // MARK: - Private Properties + + /// The FPTI URL to post all analytic events. + private let url = URL(string: "https://api.paypal.com/v1/tracking/batch/events")! + private let session: Sessionable + + // MARK: - Initializer + + init(session: Sessionable = URLSession.shared) { + self.session = session + } + + // MARK: - Internal Methods + + func sendAnalyticsEvent(_ eventName: String, sessionID: String) { + Task(priority: .background) { + await performEventRequest(eventName, sessionID: sessionID) + } + } + + func performEventRequest(_ eventName: String, sessionID: String) async { + let body = createAnalyticsEvent(eventName: eventName, sessionID: sessionID) + do { + try await post(url: url, body: body) + } catch { + NSLog("[PopupBridge SDK] Failed to send analytics: %@", error.localizedDescription) + } + } + + // MARK: - Private Methods + + /// Constructs POST params to be sent to FPTI + private func createAnalyticsEvent(eventName: String, sessionID: String) -> FPTIBatchData { + let batchMetadata = FPTIBatchData.Metadata(sessionID: sessionID) + let event = FPTIBatchData.Event(eventName: eventName) + return FPTIBatchData(metadata: batchMetadata, events: [event]) + } + + private func post(url: URL, body: FPTIBatchData) async throws { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.allHTTPHeaderFields = ["Content-Type": "application/json"] + + let encodedBody = try JSONEncoder().encode(body) + request.httpBody = encodedBody + + let (_, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw NetworkError.invalidResponse + } + } +} diff --git a/Sources/PopupBridge/Analytics/FPTIBatchData.swift b/Sources/PopupBridge/Analytics/FPTIBatchData.swift new file mode 100644 index 0000000..41d04e3 --- /dev/null +++ b/Sources/PopupBridge/Analytics/FPTIBatchData.swift @@ -0,0 +1,111 @@ +import UIKit + +struct FPTIBatchData: Codable { + + let events: [EventsContainer] + + init(metadata: Metadata, events fptiEvents: [Event]) { + self.events = [ + EventsContainer( + metadata: metadata, + fptiEvents: fptiEvents + ) + ] + } + + struct EventsContainer: Codable { + + let metadata: Metadata + let fptiEvents: [Event] + + enum CodingKeys: String, CodingKey { + case metadata = "batch_params" + case fptiEvents = "event_params" + } + } + + /// Encapsulates a single event by it's name and timestamp. + struct Event: Codable { + + let eventName: String + + let timestamp: String = String(Date().utcTimestampMilliseconds) + + let tenantName: String = "Braintree" + + enum CodingKeys: String, CodingKey { + case eventName = "event_name" + case timestamp = "t" + case tenantName = "tenant_name" + } + } + + /// The FPTI tags/metadata applicable to all events in the batch upload. + struct Metadata: Codable { + + let appID: String = Bundle.main.infoDictionary?[kCFBundleIdentifierKey as String] as? String ?? "N/A" + + let appName: String = Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String ?? "N/A" + + let clientSDKVersion: String = Bundle.clientSDKVersion + + let clientOS: String = UIDevice.current.systemName + " " + UIDevice.current.systemVersion + + let component: String = "popupbridgesdk" + + let deviceManufacturer: String = "Apple" + + let deviceModel: String = { + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + return identifier + }() + + let eventSource: String = "mobile-native" + + let isSimulator: Bool = { + #if targetEnvironment(simulator) + true + #else + false + #endif + }() + + let merchantAppVersion: String = Bundle.main.infoDictionary?[kCFBundleVersionKey as String] as? String ?? "N/A" + + let packageManager: String = { + #if COCOAPODS + "CocoaPods" + #elseif SWIFT_PACKAGE + "Swift Package Manager" + #else + "Carthage or Other" + #endif + }() + + let platform: String = "iOS" + + let sessionID: String + + enum CodingKeys: String, CodingKey { + case appID = "app_id" + case appName = "app_name" + case clientSDKVersion = "c_sdk_ver" + case clientOS = "client_os" + case component = "comp" + case deviceManufacturer = "device_manufacturer" + case deviceModel = "mobile_device_model" + case eventSource = "event_source" + case isSimulator = "is_simulator" + case merchantAppVersion = "mapv" + case packageManager = "ios_package_manager" + case platform + case sessionID = "session_id" + } + } +} diff --git a/Sources/PopupBridge/PopupBridgeAnalytics.swift b/Sources/PopupBridge/Analytics/PopupBridgeAnalytics.swift similarity index 100% rename from Sources/PopupBridge/PopupBridgeAnalytics.swift rename to Sources/PopupBridge/Analytics/PopupBridgeAnalytics.swift diff --git a/Sources/PopupBridge/FPTIBatchData.swift b/Sources/PopupBridge/FPTIBatchData.swift deleted file mode 100644 index 69943f4..0000000 --- a/Sources/PopupBridge/FPTIBatchData.swift +++ /dev/null @@ -1,78 +0,0 @@ -import UIKit - -struct FPTIBatchData: Codable { - - let appID: String = Bundle.main.infoDictionary?[kCFBundleIdentifierKey as String] as? String ?? "N/A" - - let appName: String = Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String ?? "N/A" - - let clientSDKVersion: String = Bundle.clientSDKVersion - - let clientOS: String = UIDevice.current.systemName + " " + UIDevice.current.systemVersion - - let component: String = "popupbridgesdk" - - let deviceManufacturer: String = "Apple" - - let deviceModel: String = { - var systemInfo = utsname() - uname(&systemInfo) - let machineMirror = Mirror(reflecting: systemInfo.machine) - let identifier = machineMirror.children.reduce("") { identifier, element in - guard let value = element.value as? Int8, value != 0 else { return identifier } - return identifier + String(UnicodeScalar(UInt8(value))) - } - return identifier - }() - - let eventName: String - - let eventSource: String = "mobile-native" - - let isSimulator: Bool = { - #if targetEnvironment(simulator) - true - #else - false - #endif - }() - - let merchantAppVersion: String = Bundle.main.infoDictionary?[kCFBundleVersionKey as String] as? String ?? "N/A" - - let packageManager: String = { - #if COCOAPODS - "CocoaPods" - #elseif SWIFT_PACKAGE - "Swift Package Manager" - #else - "Carthage or Other" - #endif - }() - - let platform: String = "iOS" - - let sessionID: String - - let timestamp: String = String(Date().utcTimestampMilliseconds) - - let tenantName: String = "Braintree" - - enum CodingKeys: String, CodingKey { - case appID = "app_id" - case appName = "app_name" - case clientSDKVersion = "c_sdk_ver" - case clientOS = "client_os" - case component = "comp" - case deviceManufacturer = "device_manufacturer" - case deviceModel = "mobile_device_model" - case eventName = "event_name" - case eventSource = "event_source" - case isSimulator = "is_simulator" - case merchantAppVersion = "mapv" - case packageManager = "ios_package_manager" - case platform - case sessionID = "session_id" - case timestamp = "t" - case tenantName = "tenant_name" - } -} diff --git a/Sources/PopupBridge/NetworkError.swift b/Sources/PopupBridge/NetworkError.swift new file mode 100644 index 0000000..ab07a1b --- /dev/null +++ b/Sources/PopupBridge/NetworkError.swift @@ -0,0 +1,5 @@ +import Foundation + +enum NetworkError: Error { + case invalidResponse +} diff --git a/Sources/PopupBridge/POPPopupBridge.swift b/Sources/PopupBridge/POPPopupBridge.swift index 7fd89c6..f31ec28 100644 --- a/Sources/PopupBridge/POPPopupBridge.swift +++ b/Sources/PopupBridge/POPPopupBridge.swift @@ -8,14 +8,17 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { /// Exposed for testing var returnedWithURL: Bool = false + static var analyticsService: AnalyticsServiceable = AnalyticsService() + // MARK: - Private Properties private let messageHandlerName = "POPPopupBridge" - private let hostName = "popupbridgev1" + private let hostName = "popupbridgev1" + private let sessionID = UUID().uuidString.replacingOccurrences(of: "-", with: "") private let webView: WKWebView private let application: URLOpener = UIApplication.shared - private var webAuthenticationSession: WebAuthenticationSession = WebAuthenticationSession() + private var webAuthenticationSession: WebAuthenticationSession = WebAuthenticationSession() private var returnBlock: ((URL) -> Void)? = nil // MARK: - Initializers @@ -30,7 +33,9 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { self.webView = webView super.init() - + + Self.analyticsService.sendAnalyticsEvent(PopupBridgeAnalytics.started, sessionID: sessionID) + configureWebView() webAuthenticationSession.prefersEphemeralWebBrowserSession = prefersEphemeralWebBrowserSession @@ -45,7 +50,10 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { } /// Exposed for testing - convenience init(webView: WKWebView, webAuthenticationSession: WebAuthenticationSession) { + convenience init( + webView: WKWebView, + webAuthenticationSession: WebAuthenticationSession + ) { self.init(webView: webView) self.webAuthenticationSession = webAuthenticationSession } @@ -77,10 +85,12 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { if let payloadData = try? JSONEncoder().encode(payload), let payload = String(data: payloadData, encoding: .utf8) { + Self.analyticsService.sendAnalyticsEvent(PopupBridgeAnalytics.succeeded, sessionID: sessionID) return "window.popupBridge.onComplete(null, \(payload));" } else { let errorMessage = "Failed to parse query items from return URL." let errorResponse = "new Error(\"\(errorMessage)\")" + Self.analyticsService.sendAnalyticsEvent(PopupBridgeAnalytics.failed, sessionID: sessionID) return "window.popupBridge.onComplete(\(errorResponse), null);" } } @@ -141,7 +151,9 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { window.popupBridge.onComplete(null, null);\ } """ - + + Self.analyticsService.sendAnalyticsEvent(PopupBridgeAnalytics.canceled, sessionID: sessionID) + injectWebView(webView: webView, withJavaScript: script) return } @@ -155,13 +167,8 @@ public class POPPopupBridge: NSObject, WKScriptMessageHandler { extension POPPopupBridge: ASWebAuthenticationPresentationContextProviding { public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { - if #available(iOS 15, *) { - let firstScene = UIApplication.shared.connectedScenes.first as? UIWindowScene - let window = firstScene?.windows.first { $0.isKeyWindow } - return window ?? ASPresentationAnchor() - } else { - let window = UIApplication.shared.windows.first { $0.isKeyWindow } - return window ?? ASPresentationAnchor() - } + let firstScene = UIApplication.shared.connectedScenes.first as? UIWindowScene + let window = firstScene?.windows.first { $0.isKeyWindow } + return window ?? ASPresentationAnchor() } } diff --git a/Sources/PopupBridge/Sessionable.swift b/Sources/PopupBridge/Sessionable.swift new file mode 100644 index 0000000..f6909df --- /dev/null +++ b/Sources/PopupBridge/Sessionable.swift @@ -0,0 +1,14 @@ +import Foundation + +protocol Sessionable { + func data(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) +} + +extension Sessionable { + + func data(for request: URLRequest, delegate: (any URLSessionTaskDelegate)? = nil) async throws -> (Data, URLResponse) { + try await data(for: request, delegate: delegate) + } +} + +extension URLSession: Sessionable { } diff --git a/UnitTests/AnalyticsService_Test.swift b/UnitTests/AnalyticsService_Test.swift new file mode 100644 index 0000000..1d2aea3 --- /dev/null +++ b/UnitTests/AnalyticsService_Test.swift @@ -0,0 +1,48 @@ +@testable import PopupBridge +import XCTest + +class AnalyticsService_Test: XCTestCase { + + var sut: AnalyticsService! + var mockSession: MockSession! + + let eventName = "some-event-name" + let sessionID = "some-session-id" + let testURL = URL(string: "https://api.paypal.com/v1/tracking/batch/events")! + + override func setUp() { + super.setUp() + mockSession = MockSession() + sut = AnalyticsService(session: mockSession) + } + + override func tearDown() { + sut = nil + mockSession = nil + super.tearDown() + } + + func testPerformEventRequest_handleSuccess() async { + mockSession.response = (data: Data(), response: HTTPURLResponse(url: testURL, statusCode: 200, httpVersion: nil, headerFields: nil)!) + + await sut.performEventRequest(eventName, sessionID: sessionID) + + let decodedResponse = decodeData(mockSession.requestedBody!) + + XCTAssertEqual(mockSession.requestedURL, testURL) + XCTAssertEqual(mockSession.requestHttpMethod, "POST") + XCTAssertEqual(mockSession.requestAllHTTPHeaderFields, ["Content-Type": "application/json"]) + XCTAssertEqual(decodedResponse?.events.first?.fptiEvents.first?.eventName, eventName) + XCTAssertEqual(decodedResponse?.events.first?.metadata.sessionID, sessionID) + } + + func decodeData(_ data: Data) -> FPTIBatchData? { + let decoder = JSONDecoder() + do { + let event = try decoder.decode(FPTIBatchData.self, from: data) + return event + } catch { + return nil + } + } +} diff --git a/UnitTests/MockAnalyticsService.swift b/UnitTests/MockAnalyticsService.swift new file mode 100644 index 0000000..036eb43 --- /dev/null +++ b/UnitTests/MockAnalyticsService.swift @@ -0,0 +1,15 @@ +import Foundation +@testable import PopupBridge + +class MockAnalyticsService: AnalyticsServiceable { + + var lastEventName: String? + var lastSessionID: String? + var eventCount = 0 + + func sendAnalyticsEvent(_ eventName: String, sessionID: String) { + lastEventName = eventName + lastSessionID = sessionID + eventCount += 1 + } +} diff --git a/UnitTests/MockSessionable.swift b/UnitTests/MockSessionable.swift new file mode 100644 index 0000000..860689e --- /dev/null +++ b/UnitTests/MockSessionable.swift @@ -0,0 +1,28 @@ +import Foundation +@testable import PopupBridge + +class MockSession: Sessionable { + + var requestedURL: URL? + var requestedBody: Data? + var requestHttpMethod: String? + var requestAllHTTPHeaderFields: [String: String] = [:] + var response: (data: Data, response: URLResponse)? + var error: Error? + + func data(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) { + + requestedURL = request.url + requestedBody = request.httpBody + requestHttpMethod = request.httpMethod + requestAllHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] + + if let error { + throw error + } else if let response { + return response + } else { + throw NetworkError.invalidResponse + } + } +} diff --git a/UnitTests/MockWebAuthenticationSession.swift b/UnitTests/MockWebAuthenticationSession.swift index 4f8b539..62608d7 100644 --- a/UnitTests/MockWebAuthenticationSession.swift +++ b/UnitTests/MockWebAuthenticationSession.swift @@ -5,6 +5,7 @@ import AuthenticationServices class MockWebAuthenticationSession: WebAuthenticationSession { var cannedResponseURL: URL? var cannedErrorResponse: Error? + var shouldCancel: Bool = false override func start( url: URL, @@ -12,6 +13,10 @@ class MockWebAuthenticationSession: WebAuthenticationSession { sessionDidComplete: @escaping (URL?, Error?) -> Void, sessionDidCancel: @escaping () -> Void ) { - sessionDidComplete(cannedResponseURL, cannedErrorResponse) + if shouldCancel { + sessionDidCancel() + } else { + sessionDidComplete(cannedResponseURL, cannedErrorResponse) + } } } diff --git a/UnitTests/PopupBridge_UnitTests.swift b/UnitTests/PopupBridge_UnitTests.swift index e74f2d9..07ab7ea 100644 --- a/UnitTests/PopupBridge_UnitTests.swift +++ b/UnitTests/PopupBridge_UnitTests.swift @@ -7,6 +7,7 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { let scriptMessageHandlerName: String = "POPPopupBridge" let returnURL: String = "com.braintreepayments.popupbridgeexample" let mockWebAuthenticationSession = MockWebAuthenticationSession() + let mockAnalyticsService = MockAnalyticsService() var webViewReadyBlock: (Void)? @@ -52,7 +53,10 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { configuration.userContentController = mockUserContentController let webView = WKWebView(frame: CGRect(), configuration: configuration) - let pub = POPPopupBridge(webView: webView, webAuthenticationSession: mockWebAuthenticationSession) + let pub = POPPopupBridge( + webView: webView, + webAuthenticationSession: mockWebAuthenticationSession + ) mockWebAuthenticationSession.cannedResponseURL = URL(string: "http://example.com/?hello=world") pub.userContentController(WKUserContentController(), didReceive: stubMessage) @@ -135,7 +139,10 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { configuration.userContentController = mockUserContentController let webView = WKWebView(frame: CGRect(), configuration: configuration) - let pub = POPPopupBridge(webView: webView, webAuthenticationSession: mockWebAuthenticationSession) + let pub = POPPopupBridge( + webView: webView, + webAuthenticationSession: mockWebAuthenticationSession + ) let mockURL = URL(string: "sdk.ios.popup-bridge://popupbridgev1/return?something=foo&other=bar")! mockWebAuthenticationSession.cannedResponseURL = mockURL @@ -161,7 +168,10 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { configuration.userContentController = mockUserContentController let webView = WKWebView(frame: CGRect(), configuration: configuration) - let pub = POPPopupBridge(webView: webView, webAuthenticationSession: mockWebAuthenticationSession) + let pub = POPPopupBridge( + webView: webView, + webAuthenticationSession: mockWebAuthenticationSession + ) let mockURL = URL(string: "sdk.ios.popup-bridge://popupbridgev1/return")! mockWebAuthenticationSession.cannedResponseURL = mockURL @@ -181,7 +191,10 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { stubMessage.body = stubMessageBody stubMessage.name = stubMessageName - let pub = POPPopupBridge(webView: WKWebView(), webAuthenticationSession: mockWebAuthenticationSession) + let pub = POPPopupBridge( + webView: WKWebView(), + webAuthenticationSession: mockWebAuthenticationSession + ) pub.userContentController(WKUserContentController(), didReceive: stubMessage) @@ -204,7 +217,10 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { stubMessage.body = stubMessageBody stubMessage.name = stubMessageName - let pub = POPPopupBridge(webView: WKWebView(), webAuthenticationSession: mockWebAuthenticationSession) + let pub = POPPopupBridge( + webView: WKWebView(), + webAuthenticationSession: mockWebAuthenticationSession + ) pub.userContentController(WKUserContentController(), didReceive: stubMessage) @@ -271,4 +287,81 @@ final class PopupBridge_UnitTests: XCTestCase, WKNavigationDelegate { return nil } + + func testInit_sendsAnalytics() { + XCTAssertEqual(mockAnalyticsService.eventCount, 0) + POPPopupBridge.analyticsService = mockAnalyticsService + + let _ = POPPopupBridge( + webView: WKWebView(), + webAuthenticationSession: mockWebAuthenticationSession + ) + + XCTAssertEqual(mockAnalyticsService.eventCount, 1) + XCTAssertEqual(mockAnalyticsService.lastEventName, PopupBridgeAnalytics.started) + XCTAssertNotNil(mockAnalyticsService.lastSessionID) + } + + func testConstructJavaScriptCompletionResult_whenReturnURL_sendsAnalytics() { + XCTAssertEqual(mockAnalyticsService.eventCount, 0) + let configuration = WKWebViewConfiguration() + let mockUserContentController = MockUserContentController() + + configuration.userContentController = mockUserContentController + + let webView = WKWebView(frame: CGRect(), configuration: configuration) + POPPopupBridge.analyticsService = mockAnalyticsService + let pub = POPPopupBridge( + webView: webView, + webAuthenticationSession: mockWebAuthenticationSession + ) + let mockURL = URL(string: "sdk.ios.popup-bridge://popupbridgev1/return?something=foo&other=bar")! + mockWebAuthenticationSession.cannedResponseURL = mockURL + + let expectedResult = "window.popupBridge.onComplete(null, {\"path\":\"\\/return\",\"queryItems\":{\"other\":\"bar\",\"something\":\"foo\"}});" + let result = pub.constructJavaScriptCompletionResult(returnURL: mockURL) + + let expectedJSON = extractJSON(from: expectedResult) + let actualJSON = extractJSON(from: result!) + + XCTAssertEqual(actualJSON, expectedJSON) + XCTAssertEqual(mockAnalyticsService.eventCount, 2) + XCTAssertEqual(mockAnalyticsService.lastEventName, PopupBridgeAnalytics.succeeded) + XCTAssertNotNil(mockAnalyticsService.lastSessionID) + } + + func testPopupBridge_whenCancelButtonTappedOnSafariViewController_sendsAnalytics() { + XCTAssertEqual(mockAnalyticsService.eventCount, 0) + let stubMessageBody: [String: String] = ["url": "http://example.com/?hello=world"] + let stubMessageName = scriptMessageHandlerName + let stubMessage = MockScriptMessage() + stubMessage.body = stubMessageBody + stubMessage.name = stubMessageName + + let configuration = WKWebViewConfiguration() + let mockUserContentController = MockUserContentController() + + configuration.userContentController = mockUserContentController + + POPPopupBridge.analyticsService = mockAnalyticsService + let webView = WKWebView(frame: CGRect(), configuration: configuration) + let pub = POPPopupBridge( + webView: webView, + webAuthenticationSession: mockWebAuthenticationSession + ) + mockWebAuthenticationSession.shouldCancel = true + pub.userContentController(WKUserContentController(), didReceive: stubMessage) + + webView.evaluateJavaScript(""" + "if (typeof window.popupBridge.onCancel === 'function') {" + " window.popupBridge.onCancel();" + "} else {" + " window.popupBridge.onComplete(null, null);" + "}" + """) + + XCTAssertEqual(mockAnalyticsService.eventCount, 2) + XCTAssertEqual(mockAnalyticsService.lastEventName, PopupBridgeAnalytics.canceled) + XCTAssertNotNil(mockAnalyticsService.lastSessionID) + } }