FBSDKCoreKit/FBSDKCoreKitTests/AppLinkNavigationTests.swift (501 lines of code) (raw):

/* * Copyright (c) Meta Platforms, Inc. and affiliates. * All rights reserved. * * This source code is licensed under the license found in the * LICENSE file in the root directory of this source tree. */ @testable import FBSDKCoreKit import TestTools final class AppLinkNavigationTests: XCTestCase { struct AppLinkUrlPayload: Codable { let userAgent: String let version: String let extras: [String: String] let targetUrl: URL? enum CodingKeys: String, CodingKey { case userAgent = "user_agent" case version case extras case targetUrl = "target_url" } } var error: NSError? let target = AppLinkTarget(url: SampleURLs.valid, appStoreId: "123", appName: "ExampleApp") let emptyAppLink = AppLink(sourceURL: nil, targets: [], webURL: nil) let eventPoster = TestMeasurementEvent() let resolver = TestAppLinkResolver() let settings = Settings.shared lazy var navigation = AppLinkNavigation( appLink: emptyAppLink, extras: [:], appLinkData: [:], settings: settings ) override class func setUp() { super.setUp() AppLinkNavigation.reset() } override func setUp() { super.setUp() AppLinkNavigation.default = resolver } override class func tearDown() { super.tearDown() AppLinkNavigation.reset() } func testDefaultClassDependencies() { AppLinkNavigation.reset() XCTAssertNil( AppLinkNavigation.settings, "Should not have a settings by default" ) XCTAssertNil( AppLinkNavigation.urlOpener, "Should not have a url opener by default" ) XCTAssertNil( AppLinkNavigation.appLinkEventPoster, "Should not have an event poster by default" ) XCTAssertNil( AppLinkNavigation.appLinkResolver, "Should not have an app link resolver by default" ) } func testDefaultResolver() { AppLinkNavigation.reset() XCTAssertTrue( AppLinkNavigation.default === WebViewAppLinkResolver.shared, "Should use the shared webview app link resolver by default" ) } func testSettingDefaultResolver() { let resolver = AppLinkResolver() AppLinkNavigation.default = resolver XCTAssertTrue( AppLinkNavigation.default === resolver, "Should be able to set the default app link resolver" ) XCTAssertTrue( AppLinkNavigation.appLinkResolver === resolver, "Should set the underlying resolver when setting the default" ) } func testCreatingWithEmptyAppLink() { XCTAssertNotNil( navigation, "Should be able to create an app link without verifying anything about it at all" ) } func testCallbackAppLinkData() { XCTAssertEqual( AppLinkNavigation.callbackAppLinkData(forApp: "foo", url: "bar"), ["referer_app_link": ["app_name": "foo", "url": "bar"]], "Should produce the expected app link callback data" ) } // MARK: - Dependencies Configuration func testDependenciesArePassed() { XCTAssertNotNil( navigation.settings, "Settings dependency should not be nil" ) } // MARK: - Link Creation func testAppLinkWithTargetUrl() { do { let url = try navigation.appLinkURL(withTargetURL: SampleURLs.valid) let payload = decodedPayload(url: url) XCTAssertEqual(payload?.userAgent, "FBSDK \(FBSDK_VERSION_STRING)") XCTAssertEqual(payload?.version, "1.0") XCTAssertEqual(payload?.extras, [:]) XCTAssertNil(payload?.targetUrl) } catch { XCTAssertNil( error, "Should not populate an error when creating an app link with a valid target url" ) } } func testAppLinkWithTargetUrlWithValidStartingAppLink() { let appLink = AppLink(sourceURL: SampleURLs.valid, targets: [target], webURL: SampleURLs.valid) navigation = AppLinkNavigation( appLink: appLink, extras: [:], appLinkData: [:], settings: settings ) do { let url = try navigation.appLinkURL(withTargetURL: SampleURLs.valid) let payload = decodedPayload(url: url) XCTAssertEqual(payload?.userAgent, "FBSDK \(FBSDK_VERSION_STRING)") XCTAssertEqual(payload?.version, "1.0") XCTAssertEqual(payload?.extras, [:]) XCTAssertEqual(payload?.targetUrl, SampleURLs.valid) } catch { XCTAssertNil( error, "Should not populate an error when creating an app link with a valid target url" ) } } func testAppLinkWithTargetUrlWithInvalidStartingAppLinkData() { navigation = AppLinkNavigation( appLink: emptyAppLink, extras: [:], appLinkData: ["foo": Any.self], settings: settings ) do { let url = try navigation.appLinkURL(withTargetURL: SampleURLs.valid) guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, let appLinkItem = queryItems.first(where: { $0.name == "al_applink_data" }) else { return XCTFail("Should have a query item for app link data") } XCTAssertEqual( appLinkItem.value, "", "This probably shouldn't be the behavior but right now it is." ) } catch { XCTAssertNil( error, "This probably shouldn't be the behavior but right now it is." ) } } func testAppLinkWithTargetUrlWithValidStartingAppLinkData() { let appLinkData = ["user_agent": "foo", "version": "bar"] navigation = AppLinkNavigation( appLink: emptyAppLink, extras: ["some": "extra"], appLinkData: appLinkData, settings: settings ) do { let url = try navigation.appLinkURL(withTargetURL: SampleURLs.valid) let payload = decodedPayload(url: url) XCTAssertEqual(payload?.userAgent, "foo") XCTAssertEqual(payload?.version, "bar") XCTAssertEqual(payload?.extras, ["some": "extra"]) XCTAssertNil(payload?.targetUrl) } catch { XCTAssertNil( error, "Should not populate an error when creating an app link with a valid target url" ) } } func testAppLinkWithBadData() { let unencodable = String( bytes: [0xD8, 0x00] as [UInt8], encoding: String.Encoding.utf16BigEndian )! // swiftlint:disable:this force_unwrapping let appLinkData = ["bad value": unencodable] navigation = AppLinkNavigation(appLink: emptyAppLink, extras: [:], appLinkData: appLinkData, settings: settings) do { let url = try navigation.appLinkURL(withTargetURL: SampleURLs.valid) let payload = decodedPayload(url: url) XCTAssertEqual(payload?.userAgent, "foo") XCTAssertEqual(payload?.version, "bar") XCTAssertEqual(payload?.extras, ["some": "extra"]) XCTAssertNil(payload?.targetUrl) XCTAssertNil( error, "Should not populate an error when creating an app link with a valid target url" ) } catch { print(error) } } // MARK: - Posting Navigation Events func testPostingNavigationEventWithTypeApp() { navigation.postAppLinkNavigateEventNotification( withTargetURL: nil, error: nil, type: .app, eventPoster: eventPoster ) XCTAssertEqual( eventPoster.capturedEventName, AppLinkNavigateOutEventName, "Should post a notification with the expected event name" ) XCTAssertEqual( eventPoster.capturedArgs, ["type": "app", "success": "1"], "Post an event with type 'app' should be considered a success" ) } func testPostingNavigationEventWithTypeBrowser() { navigation.postAppLinkNavigateEventNotification( withTargetURL: nil, error: nil, type: .browser, eventPoster: eventPoster ) XCTAssertEqual( eventPoster.capturedEventName, AppLinkNavigateOutEventName, "Should post a notification with the expected event name" ) XCTAssertEqual( eventPoster.capturedArgs, ["type": "web", "success": "1"], "Post an event with type 'browser' should be considered a success" ) } func testPostingNavigationEventWithTypeFailure() { navigation.postAppLinkNavigateEventNotification( withTargetURL: nil, error: nil, type: .failure, eventPoster: eventPoster ) XCTAssertEqual( eventPoster.capturedEventName, AppLinkNavigateOutEventName, "Should post a notification with the expected event name" ) XCTAssertEqual( eventPoster.capturedArgs, ["type": "fail", "success": "0"], "Post an event with type 'failure' should be considered a failure" ) } func testPostingNavigationEventWithAppLink() { let appLink = AppLink(sourceURL: SampleURLs.valid, targets: [target], webURL: SampleURLs.valid) navigation = AppLinkNavigation( appLink: appLink, extras: [:], appLinkData: [:], settings: settings ) navigation.postAppLinkNavigateEventNotification( withTargetURL: nil, error: nil, type: .app, eventPoster: eventPoster ) XCTAssertEqual( eventPoster.capturedEventName, AppLinkNavigateOutEventName, "Should post a notification with the expected event name" ) XCTAssertEqual( eventPoster.capturedArgs["sourceHost"], SampleURLs.valid.host, "A navigation event notification should include information about the app link" ) XCTAssertEqual( eventPoster.capturedArgs["sourceScheme"], SampleURLs.valid.scheme, "A navigation event notification should include information about the app link" ) XCTAssertEqual( eventPoster.capturedArgs["sourceURL"], SampleURLs.valid.absoluteString, "A navigation event notification should include information about the app link" ) } func testPostingNavigationEventWithError() { navigation.postAppLinkNavigateEventNotification( withTargetURL: nil, error: SampleError(), type: .app, eventPoster: eventPoster ) XCTAssertEqual( eventPoster.capturedEventName, AppLinkNavigateOutEventName, "Should post a notification with the expected event name" ) XCTAssertEqual( eventPoster.capturedArgs["error"], "The operation couldn’t be completed. (TestTools.SampleError error 1.)", "A navigation event notification should include information about any errors" ) XCTAssertEqual( eventPoster.capturedArgs["success"], "1", "A navigation event notification should be considered successful even if there's an error" ) } func testPostingNavigationEventWithBackToReferrer() { let appLink = AppLink(sourceURL: nil, targets: [], webURL: nil, isBackToReferrer: true) navigation = AppLinkNavigation( appLink: appLink, extras: [:], appLinkData: [:], settings: settings ) navigation.postAppLinkNavigateEventNotification( withTargetURL: nil, error: nil, type: .app, eventPoster: eventPoster ) XCTAssertEqual( eventPoster.capturedEventName, AppLinkNavigateBackToReferrerEventName, "A navigation event notification should be indicate if the app link points back to the referrer" ) } // MARK: - Navigation Type func testNavigationTypeWithoutTarget() { XCTAssertEqual( navigation.navigationType(for: [], urlOpener: TestInternalURLOpener(canOpenURL: true)), .failure, "The navigation type for an empty list of targets should be a failure" ) } func testNavigationTypeWithInvalidTargetWithoutWebUrl() { let target = AppLinkTarget(url: SampleURLs.valid, appStoreId: nil, appName: name) let appLink = AppLink(sourceURL: nil, targets: [target], webURL: nil) navigation = AppLinkNavigation(appLink: appLink, extras: [:], appLinkData: [:], settings: settings) XCTAssertEqual( navigation.navigationType(for: [target], urlOpener: TestInternalURLOpener(canOpenURL: false)), .failure, "The navigation type when there is an invalid target and no web url should be 'failure'" ) } func testNavigationTypeWithValidTargetWithoutWebUrl() { let target = AppLinkTarget(url: SampleURLs.valid, appStoreId: nil, appName: name) let appLink = AppLink(sourceURL: nil, targets: [target], webURL: nil) navigation = AppLinkNavigation(appLink: appLink, extras: [:], appLinkData: [:], settings: settings) XCTAssertEqual( navigation.navigationType(for: [target], urlOpener: TestInternalURLOpener(canOpenURL: true)), .app, "The navigation type when there is a valid target and no web url should be 'app'" ) } func testNavigationTypeWithValidTargetWithWebUrl() { let target = AppLinkTarget(url: SampleURLs.valid, appStoreId: nil, appName: name) let appLink = AppLink(sourceURL: nil, targets: [target], webURL: SampleURLs.valid) navigation = AppLinkNavigation(appLink: appLink, extras: [:], appLinkData: [:], settings: settings) XCTAssertEqual( navigation.navigationType(for: [target], urlOpener: TestInternalURLOpener(canOpenURL: true)), .app, "The navigation type when there is a valid target and a web url should be 'app'" ) } func testNavigationTypeWithInvalidTargetWithWebUrl() { let target = AppLinkTarget(url: SampleURLs.valid, appStoreId: nil, appName: name) let appLink = AppLink(sourceURL: nil, targets: [target], webURL: SampleURLs.valid) navigation = AppLinkNavigation(appLink: appLink, extras: [:], appLinkData: [:], settings: settings) XCTAssertEqual( navigation.navigationType(for: [target], urlOpener: TestInternalURLOpener(canOpenURL: false)), .browser, "The navigation type when there is an invalid target and a web url should be 'browser'" ) } // MARK: - Navigating func testSuccessfullyNavigatingWithTargetWithoutWebUrl() { let target = AppLinkTarget(url: SampleURLs.valid, appStoreId: nil, appName: name) let appLink = AppLink(sourceURL: nil, targets: [target], webURL: nil) let opener = TestInternalURLOpener(canOpenURL: true) navigation = AppLinkNavigation(appLink: appLink, extras: [:], appLinkData: [:], settings: settings) do { let targetUrl = try navigation.appLinkURL(withTargetURL: SampleURLs.valid) opener.stubOpen(url: targetUrl, success: true) let result = navigation.navigate( urlOpener: opener, eventPoster: eventPoster, error: &error ) XCTAssertEqual(result, .app, "Should return the correct navigation type") XCTAssertNotNil( opener.capturedOpenURL, "Should create an open a url for a valid target" ) XCTAssertEqual( opener.capturedOpenURL?.absoluteString, eventPoster.capturedArgs["outputURL"], "Should post a notification with the url that was opened" ) } catch { XCTAssertNil(error) } } func testUnsuccessfullyNavigatingWithTargetWithWebUrl() { let target = AppLinkTarget(url: SampleURLs.valid, appStoreId: nil, appName: name) let appLink = AppLink(sourceURL: nil, targets: [target], webURL: SampleURLs.valid(path: name)) let opener = TestInternalURLOpener(canOpenURL: true) navigation = AppLinkNavigation(appLink: appLink, extras: [:], appLinkData: [:], settings: settings) do { let targetUrl = try navigation.appLinkURL(withTargetURL: SampleURLs.valid) let webUrl = try navigation.appLinkURL(withTargetURL: SampleURLs.valid(path: name)) opener.stubOpen(url: targetUrl, success: false) opener.stubOpen(url: webUrl, success: true) let result = navigation.navigate( urlOpener: opener, eventPoster: eventPoster, error: &error ) XCTAssertEqual(result, .browser, "Should return the correct navigation type") XCTAssertNotNil( opener.capturedOpenURL, "Should create an open a url for a valid target" ) XCTAssertEqual( opener.capturedOpenURL?.absoluteString, eventPoster.capturedArgs["outputURL"], "Should post a notification with the url that was opened" ) } catch { XCTAssertNil(error) } } func testNavigatingToUrlWithoutAppLink() { let expectation = self.expectation(description: name) AppLinkNavigation.navigate(to: SampleURLs.valid) { _, _ in expectation.fulfill() } // The captured completion itself is dispatched asynchronously to the main thread // so we can delay a tick here to make sure it's complete DispatchQueue.main.async { self.resolver.capturedCompletion?(nil, nil) } waitForExpectations(timeout: 1, handler: nil) } func testNavigatingToUrlWithAppLink() { let expectation = self.expectation(description: name) var callbackNavigationType: AppLinkNavigation.`Type`? var callbackError: Error? AppLinkNavigation.navigate(to: SampleURLs.valid) { potentialNavigationType, potentialError in callbackNavigationType = potentialNavigationType callbackError = potentialError expectation.fulfill() } let appLink = AppLink(sourceURL: SampleURLs.valid, targets: [], webURL: nil) // The captured completion itself is dispatched asynchronously to the main thread // so we can delay a tick here to make sure it's complete DispatchQueue.main.async { self.resolver.capturedCompletion?(appLink, nil) } waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(callbackNavigationType, .failure) XCTAssertNil(callbackError) } func testNavigatingToUrlWithError() { let expectation = self.expectation(description: name) var callbackNavigationType: AppLinkNavigation.`Type`? var callbackError: Error? AppLinkNavigation.navigate(to: SampleURLs.valid) { potentialNavigationType, potentialError in callbackNavigationType = potentialNavigationType callbackError = potentialError expectation.fulfill() } let appLink = AppLink(sourceURL: SampleURLs.valid, targets: [], webURL: nil) // The captured completion itself is dispatched asynchronously to the main thread // so we can delay a tick here to make sure it's complete DispatchQueue.main.async { self.resolver.capturedCompletion?( appLink, NSError(domain: "foo", code: 0, userInfo: nil) ) } waitForExpectations(timeout: 1, handler: nil) XCTAssertEqual(callbackNavigationType, .failure) XCTAssertNotNil(callbackError) } // MARK: - Resolving func testResolvingAppLinkWithMissingDestination() { var didInvokeCompletion = false AppLinkNavigation.resolveAppLink(SampleURLs.valid) { _, _ in didInvokeCompletion = true } resolver.capturedCompletion?(nil, nil) XCTAssertEqual( resolver.capturedURL, SampleURLs.valid, "Should resolve using the provided url" ) XCTAssertTrue(didInvokeCompletion) } // MARK: - Helpers func decodedPayload( url: URL, file: StaticString = #file, line: UInt = #line ) -> AppLinkUrlPayload? { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let queryItems = components.queryItems, let data = queryItems.first?.value?.data(using: .utf8), let payload = try? JSONDecoder().decode(AppLinkUrlPayload.self, from: data) else { XCTFail("Could not decode the payload from the query item", file: file, line: line) return nil } return payload } }