diff --git a/README.md b/README.md index 7bae44a..2f7b559 100644 --- a/README.md +++ b/README.md @@ -196,7 +196,7 @@ The diagnostics will categorize issues as: The result object returned by `postInstallSearchLink()` and `extractLinkFromURL()` contains: - `url: URL?` - The extracted deep link URL to navigate to -- `match_type: MatchType` - How the link was detected (`.unique`, `.default`, `.none`, `.unknown`) +- `matchType: MatchType` - How the link was detected (`.unique`, `.heuristics`, `.ambiguous`, `.intent`, `.none`) - `analytics: [TracebackAnalyticsEvent]` - Analytics events you can send to your preferred platform ### TracebackConfiguration @@ -283,4 +283,4 @@ do { - Test on physical devices for Universal Links validation - Use different network conditions - Test both fresh installs and existing app scenarios -- Verify clipboard functionality works as expected \ No newline at end of file +- Verify clipboard functionality works as expected diff --git a/Sources/Traceback/Private/APIProvider.swift b/Sources/Traceback/Private/API/APIProvider.swift similarity index 52% rename from Sources/Traceback/Private/APIProvider.swift rename to Sources/Traceback/Private/API/APIProvider.swift index 2403c3b..daac5d6 100644 --- a/Sources/Traceback/Private/APIProvider.swift +++ b/Sources/Traceback/Private/API/APIProvider.swift @@ -33,4 +33,30 @@ struct APIProvider: Sendable { throw NetworkError(error: error) } } + + func getCampaignLink(from url: String, isFirstCampaignOpen: Bool) async throws -> CampaignResponse { + var urlComponents = URLComponents( + url: config.host.appendingPathComponent("v1_get_campaign", isDirectory: false), + resolvingAgainstBaseURL: false + ) + + // Add query parameters + urlComponents?.queryItems = [ + URLQueryItem(name: "link", value: url), + URLQueryItem(name: "isFirstCampaignOpen", value: String(isFirstCampaignOpen)) + ] + + guard let url = urlComponents?.url else { + throw NetworkError.unknown + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + + do { + return try await network.fetch(CampaignResponse.self, request: request) + } catch { + throw NetworkError(error: error) + } + } } diff --git a/Sources/Traceback/Private/API/CampaignResponse.swift b/Sources/Traceback/Private/API/CampaignResponse.swift new file mode 100644 index 0000000..67f40bb --- /dev/null +++ b/Sources/Traceback/Private/API/CampaignResponse.swift @@ -0,0 +1,13 @@ +// +// CampaignResponse.swift +// traceback-ios +// +// Created by Nacho Sánchez on 10/24/25. +// + +import Foundation + +struct CampaignResponse: Decodable, Equatable, Sendable { + let result: URL? + let error: String? +} diff --git a/Sources/Traceback/Private/PostInstallLinkSearchResponse.swift b/Sources/Traceback/Private/API/PostInstallLinkSearchResponse.swift similarity index 90% rename from Sources/Traceback/Private/PostInstallLinkSearchResponse.swift rename to Sources/Traceback/Private/API/PostInstallLinkSearchResponse.swift index 30ddd3d..b042bb5 100644 --- a/Sources/Traceback/Private/PostInstallLinkSearchResponse.swift +++ b/Sources/Traceback/Private/API/PostInstallLinkSearchResponse.swift @@ -11,6 +11,7 @@ struct PostInstallLinkSearchResponse: Decodable, Equatable, Sendable { let deep_link_id: URL? let match_message: String let match_type: String + let match_campaign: String? let request_ip_version: String let utm_medium: String? let utm_source: String? @@ -27,6 +28,8 @@ extension PostInstallLinkSearchResponse { return .ambiguous case "none": return .none + case "intent": + return .intent default: return .unknown } diff --git a/Sources/Traceback/Private/CampaignTracker.swift b/Sources/Traceback/Private/CampaignTracker.swift new file mode 100644 index 0000000..ac519bb --- /dev/null +++ b/Sources/Traceback/Private/CampaignTracker.swift @@ -0,0 +1,56 @@ +// +// CampaignTracker.swift +// Traceback +// +// Created by Nacho Sanchez on 24/10/25. +// + +import Foundation + +class CampaignTracker { + private let userDefaults: UserDefaults + private let seenCampaignsKey = "_traceback_seen_campaigns" + + init(userDefaults: UserDefaults = .standard) { + self.userDefaults = userDefaults + } + + // Get all seen campaigns + private func getSeenCampaigns() -> Set { + if let campaigns = userDefaults.array(forKey: seenCampaignsKey) as? [String] { + return Set(campaigns) + } + return Set() + } + + // Save seen campaigns + private func saveSeenCampaigns(_ campaigns: Set) { + userDefaults.set(Array(campaigns), forKey: seenCampaignsKey) + } + + // Check if campaign has been seen before + func hasSeenCampaign(_ campaign: String) -> Bool { + return getSeenCampaigns().contains(campaign) + } + + // Mark campaign as seen + func markCampaignAsSeen(_ campaign: String) { + var campaigns = getSeenCampaigns() + campaigns.insert(campaign) + saveSeenCampaigns(campaigns) + } + + // Check and mark in one operation (returns true if first time) + func isFirstTimeSeen(_ campaign: String) -> Bool { + let isFirstTime = !hasSeenCampaign(campaign) + if isFirstTime { + markCampaignAsSeen(campaign) + } + return isFirstTime + } + + // Clear all seen campaigns (useful for testing) + func clearSeenCampaigns() { + userDefaults.removeObject(forKey: seenCampaignsKey) + } +} diff --git a/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift b/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift index f0b9594..a16537e 100644 --- a/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift +++ b/Sources/Traceback/Private/DependenciesImpl/LoggerImpl.swift @@ -18,14 +18,14 @@ extension Logger { return Logger( info: { message in guard level == .info || level == .debug else { return } - logger.info("\(message())") + logger.info("\(message(), privacy: .public)") }, debug: { message in guard level == .debug else { return } - logger.debug("\(message())") + logger.debug("\(message(), privacy: .public)") }, error: { message in - logger.error("\(message())") + logger.error("\(message(), privacy: .public)") } ) } diff --git a/Sources/Traceback/Private/DependenciesImpl/SystemInfoImpl.swift b/Sources/Traceback/Private/DependenciesImpl/SystemInfoImpl.swift index 13bc113..6cae66f 100644 --- a/Sources/Traceback/Private/DependenciesImpl/SystemInfoImpl.swift +++ b/Sources/Traceback/Private/DependenciesImpl/SystemInfoImpl.swift @@ -14,7 +14,7 @@ enum TracebackSystemImpl { SystemInfo( installationTime: installationTime(), deviceModelName: deviceModelName(), - sdkVersion: sdkVersion(), + sdkVersion: TracebackSDK.sdkVersion, localeIdentifier: Locale.preferredLanguages.first ?? Locale.current.identifier, timezone: TimeZone.current, osVersion: UIDevice.current.systemVersion, @@ -49,14 +49,4 @@ enum TracebackSystemImpl { } #endif } - - private static func sdkVersion() -> String { - guard let infoDictSDKVersion = Bundle(for: TracebackSDKImpl.self) - .infoDictionary?["CFBundleShortVersionString"] as? String else { - // fails in unit test - // assertionFailure() - return "1.0.0" - } - return infoDictSDKVersion - } } diff --git a/Sources/Traceback/Private/DeviceFingerprint.swift b/Sources/Traceback/Private/DeviceFingerprint.swift index 72f64c7..23486e5 100644 --- a/Sources/Traceback/Private/DeviceFingerprint.swift +++ b/Sources/Traceback/Private/DeviceFingerprint.swift @@ -15,6 +15,7 @@ struct DeviceFingerprint: Codable, Equatable, Sendable { let osVersion: String let sdkVersion: String let uniqueMatchLinkToCheck: URL? + let intentLink: URL? let device: DeviceInfo struct DeviceInfo: Codable, Equatable, Sendable { @@ -33,6 +34,7 @@ struct DeviceFingerprint: Codable, Equatable, Sendable { func createDeviceFingerprint( system: SystemInfo, linkFromClipboard: URL?, + linkFromIntent: URL?, webviewInfo: WebViewInfo? ) -> DeviceFingerprint { @@ -65,6 +67,7 @@ func createDeviceFingerprint( osVersion: system.osVersion, sdkVersion: system.sdkVersion, uniqueMatchLinkToCheck: linkFromClipboard, + intentLink: linkFromIntent, device: deviceInfo ) } diff --git a/Sources/Traceback/Private/TracebackSDKImpl.swift b/Sources/Traceback/Private/TracebackSDKImpl.swift index 980f0c0..e72e58a 100644 --- a/Sources/Traceback/Private/TracebackSDKImpl.swift +++ b/Sources/Traceback/Private/TracebackSDKImpl.swift @@ -12,17 +12,20 @@ private let userDefaultsExistingRunKey = "traceback_existingRun" extension TracebackSDK.Result { static var empty: Self { - TracebackSDK.Result(url: nil, match_type: .none, analytics: []) + TracebackSDK.Result(url: nil, matchType: .none, analytics: []) } } final class TracebackSDKImpl { private let config: TracebackConfiguration private let logger: Logger + private let campaignTracker: CampaignTracker + private let linkDetectionActor = ValueWaiter() - init(config: TracebackConfiguration, logger: Logger) { + init(config: TracebackConfiguration, logger: Logger, campaignTracker: CampaignTracker) { self.config = config self.logger = logger + self.campaignTracker = campaignTracker } func detectPostInstallLink() async -> TracebackSDK.Result { @@ -34,6 +37,10 @@ final class TracebackSDKImpl { return .empty } + logger.debug("Waiting for universal link") + let linkFromIntent = await linkDetectionActor.waitForValue(timeoutSeconds: 0.5) + logger.debug("Got universal link: \(linkFromIntent?.absoluteString ?? "none")") + logger.info("Checking for post-install link") do { @@ -57,6 +64,7 @@ final class TracebackSDKImpl { let fingerprint = await createDeviceFingerprint( system: system, linkFromClipboard: linkFromClipboard, + linkFromIntent: linkFromIntent, webviewInfo: webviewInfo ) @@ -72,60 +80,140 @@ final class TracebackSDKImpl { let response = try await api.sendFingerprint(fingerprint) logger.info("Server responded with match type: \(response.match_type)") + logger.debug("Server responded deep link: \(response)") + // 5. Save checks locally UserDefaults.standard.set(true, forKey: userDefaultsExistingRunKey) logger.info("Post-install success saved to user defaults \(userDefaultsExistingRunKey)" + " so it is no longer checked") - if let deep_link_id = response.deep_link_id { - return TracebackSDK.Result( - url: response.deep_link_id, - match_type: response.matchType, - analytics: [ - .postInstallDetected(deep_link_id) - ] - ) - } else { - return TracebackSDK.Result( - url: response.deep_link_id, - match_type: response.matchType, - analytics: [] - ) + if let campaign = response.match_campaign { + campaignTracker.markCampaignAsSeen(campaign) + logger.info("Campaign \(campaign) seen for first time") } + + // 6. Return what we have found + return TracebackSDK.Result( + url: response.deep_link_id, + matchType: response.matchType, + analytics: response.deep_link_id.map { [.postInstallDetected($0)] } ?? [] + ) } catch { logger.error("Failed to detect post-install link: \(error.localizedDescription)") return TracebackSDK.Result( url: nil, - match_type: .none, + matchType: .none, analytics: [ .postInstallError(error) ] ) } } - - func extractLink(from: URL) throws -> TracebackSDK.Result { + func getCampaignLink(from url: URL) async -> TracebackSDK.Result { + do { + logger.info("Get campaign link") + + // 1. Check if first run, if not save link and continue + guard UserDefaults.standard.bool(forKey: userDefaultsExistingRunKey) else { + logger.info("Do not get campaign link on first run, do it via postInstallSearch") + await linkDetectionActor.provideValue(url) + return .empty + } + + // 2. Extract campaign from url + let campaign = extractCampaign(from: url) + + // 3. If no campaign, process locally + guard let campaign else { + logger.info("The link does not have a campaign, treat locally as intent") + let deeplink = try? extractLink(from: url) + + return TracebackSDK.Result( + url: deeplink, + matchType: .intent, + analytics: [] + ) + } + + // 3. Get campaign resolution from backend + let api = APIProvider( + config: NetworkConfiguration( + host: config.mainAssociatedHost + ), + network: Network.live + ) + + logger.info("Getting campaign deeplink remotely for campaign \(campaign)") + + let isFirstCampaignOpen = campaignTracker.isFirstTimeSeen(campaign) + logger.debug("Campaign \(campaign) first open \(isFirstCampaignOpen)") + + let response = try await api.getCampaignLink(from: url.absoluteString, isFirstCampaignOpen: isFirstCampaignOpen) + logger.info("Server responded with link: \(String(describing: response.result))") + + guard + let deeplink = response.result + else { + return .empty + } + + return TracebackSDK.Result( + url: deeplink, + matchType: .intent, + analytics: [ + .campaignResolved(deeplink) + ] + ) + } catch { + logger.error("Failed to resolve campaign link: \(error.localizedDescription)") + return TracebackSDK.Result( + url: nil, + matchType: .none, + analytics: [ + .campaignError(error) + ] + ) + } + } - guard let components = URLComponents(url: from, resolvingAgainstBaseURL: false) else { + /// Parses the url that triggered app launch and extracts the real expected url to be opened + /// + /// @Discussion When a specific content is expected to be opened inside the application. The real url + /// defining the content is not allways plain visible in the url which opened the app, since we need to build + /// a url that is valid for all platforms, and for installation path. This method extracts the real url to be + /// opened. + private func extractLink(from url: URL) throws -> URL? { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { throw TracebackError.ExtractLink.invalidURL } + for queryItem in components.queryItems ?? [] { if queryItem.name == "link", let value = queryItem.value, let url = URL(string: value) { - return TracebackSDK.Result( - url: url, - match_type: .unknown, - analytics: [] - ) + return url } } - return TracebackSDK.Result( - url: nil, - match_type: .unknown, - analytics: [] - ) + + return nil + } + + /// Parses the campaign from a Traceback URL, which is the path of the Traceback URL + private func extractCampaign(from url: URL) -> String? { + let path = url.path + if path.count > 1 { + return String(path.dropFirst()) + } + return nil + } + + /// Determines whether a URL should be processed by Traceback or not based on the configured domains + func isTracebackURL(_ url: URL) -> Bool { + guard let host = url.host else { return false } + + return host == config.mainAssociatedHost.host || + (config.associatedHosts?.contains(where: { $0.host == host }) ?? false) } @MainActor diff --git a/Sources/Traceback/Private/ValueWaiter.swift b/Sources/Traceback/Private/ValueWaiter.swift new file mode 100644 index 0000000..ee676d1 --- /dev/null +++ b/Sources/Traceback/Private/ValueWaiter.swift @@ -0,0 +1,39 @@ +import Foundation + +actor ValueWaiter { + private var pendingValue: T? + private var continuation: CheckedContinuation? + private var alreadyCalled = false + + func waitForValue(timeoutSeconds: TimeInterval) async -> T? { + assert(!alreadyCalled) + alreadyCalled = true + if let pendingValue = self.pendingValue { + self.pendingValue = nil + return pendingValue + } + return await withCheckedContinuation { continuation in + self.continuation = continuation + Task { + try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000)) + self.timeoutExpired() + } + } + } + + func provideValue(_ value: T) { + if let continuation = self.continuation { + self.continuation = nil + continuation.resume(returning: value) + } else { + self.pendingValue = value + } + } + + private func timeoutExpired() { + if let continuation = self.continuation { + self.continuation = nil + continuation.resume(returning: nil) + } + } +} diff --git a/Sources/Traceback/TracebackConfiguration.swift b/Sources/Traceback/TracebackConfiguration.swift index 7581fe4..b4bfbd2 100644 --- a/Sources/Traceback/TracebackConfiguration.swift +++ b/Sources/Traceback/TracebackConfiguration.swift @@ -8,11 +8,15 @@ import Foundation /// Events returned by the sdk methods so they can be reported to your preferred analytics platform -public enum TracebackAnalyticsEvent { +public enum TracebackAnalyticsEvent: Sendable { /// A post-installation content url has been detected case postInstallDetected(URL) /// A post-installation content url failure case postInstallError(Error) + /// A get-campaign content url has been resolved + case campaignResolved(URL) + /// A get-campaign content url failure + case campaignError(Error) } /// Main configuration for the traceback sdk. diff --git a/Sources/Traceback/TracebackSDK.swift b/Sources/Traceback/TracebackSDK.swift index e838d04..8771e15 100644 --- a/Sources/Traceback/TracebackSDK.swift +++ b/Sources/Traceback/TracebackSDK.swift @@ -11,21 +11,31 @@ import Foundation /// Main SDK entry object offering methods for searching the app opening content url and debug /// public struct TracebackSDK { - /// initialization value passed to TracebackSDK.live + + static let sdkVersion = "iOS/0.3.1" + + /// Initialization value passed to TracebackSDK.live public let configuration: TracebackConfiguration + /// Searches for the right content url that triggered app install /// /// @Discussion Calling this method right after app installation will search for the content url that /// was expected to be displayed at the very beginning of app install path. The method will return a /// valid url only once, later calls will no longer search for the opening url. - public let postInstallSearchLink: () async throws -> Result? - /// Parses the url that triggered app launch and extracts the real expected url to be opened + public let postInstallSearchLink: () async throws -> TracebackSDK.Result? + + /// Searches for the right content url associated to the url that opened the app /// - /// @Discussion When a specific content is expected to be opened inside the application. The real url - /// defining the content is not allways plain visible in the url which opened the app, since we need to build - /// a url that is valid for all platforms, and for installation path. This method extracts the real url to be - /// opened. - public let extractLinkFromURL: (URL) throws -> Result? + /// @Discussion Calling this method when the app is opened via Universal Link or scheme + /// will search for the content url associated to the opened url. + public let campaignSearchLink: (URL) async throws -> TracebackSDK.Result? + + /// Validate input URL + /// + /// @Discussion Validates if the domain of the given URL matches any of the + /// associated domains in configuration + public let isTracebackURL: (URL) -> Bool + /// Diagnostics info /// /// @Discussion Call this method at app startup to diagnose your current setup @@ -34,20 +44,21 @@ public struct TracebackSDK { /// /// Match type when searching for a post-install link /// - public enum MatchType { + public enum MatchType: Sendable { case unique /// A unique result returned, given by pasteboard case heuristics /// Heuristics search success case ambiguous /// Heuristics seach success but ambiguous match case none /// No match found + case intent /// A unique result returned, given by link opened by client case unknown /// Not determined match type } /// Result of traceback main methods. Contains the resulting URL and events so analytics can be saved - public struct Result { + public struct Result: Sendable { /// A valid url if the method correctly finds a post install link, or opened url contains a valid deep link public let url: URL? /// The match type when extracting the post install - public let match_type: MatchType + public let matchType: MatchType /// Analytics to be sent to your preferred analytics platform public let analytics: [TracebackAnalyticsEvent] } @@ -65,23 +76,23 @@ public extension TracebackSDK { /// static func live(config: TracebackConfiguration) -> TracebackSDK { let logger = Logger.live(level: config.logLevel) + let campaignTracker = CampaignTracker() + let implementation = TracebackSDKImpl( + config: config, + logger: logger, + campaignTracker: campaignTracker + ) + return TracebackSDK( configuration: config, postInstallSearchLink: { - await TracebackSDKImpl( - config: config, - logger: logger - ) - .detectPostInstallLink() + await implementation.detectPostInstallLink() + }, + campaignSearchLink: { url in + await implementation.getCampaignLink(from: url) }, - extractLinkFromURL: { url in - try? TracebackSDKImpl( - config: config, - logger: logger - ) - .extractLink( - from: url - ) + isTracebackURL: { url in + implementation.isTracebackURL(url) }, performDiagnostics: { Task { @MainActor in diff --git a/Tests/TracebackTests/AdditionalTests.swift b/Tests/TracebackTests/AdditionalTests.swift index dcd644d..a936bc1 100644 --- a/Tests/TracebackTests/AdditionalTests.swift +++ b/Tests/TracebackTests/AdditionalTests.swift @@ -35,53 +35,6 @@ func testTracebackConfigurationDefaults() throws { #expect(config.logLevel == .info) } -// MARK: - URL Extraction Tests - -@Test -func testExtractLinkFromURL() throws { - let config = TracebackConfiguration( - mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! - ) - let sdk = TracebackSDK.live(config: config) - - // Test valid URL with link parameter - let urlWithLink = URL(string: "https://example.com?link=https%3A%2F%2Fmyapp.com%2Fproduct%2F123")! - let result = try sdk.extractLinkFromURL(urlWithLink) - - #expect(result?.url?.absoluteString == "https://myapp.com/product/123") - #expect(result?.match_type == TracebackSDK.MatchType.unknown) -} - -@Test -func testExtractLinkFromURLWithoutLinkParameter() throws { - let config = TracebackConfiguration( - mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! - ) - let sdk = TracebackSDK.live(config: config) - - // Test URL without link parameter - let urlWithoutLink = URL(string: "https://example.com?other=value")! - let result = try sdk.extractLinkFromURL(urlWithoutLink) - - #expect(result?.url == nil) - #expect(result?.match_type == TracebackSDK.MatchType.unknown) -} - -@Test -func testExtractLinkFromURLWithMultipleQueryParams() throws { - let config = TracebackConfiguration( - mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! - ) - let sdk = TracebackSDK.live(config: config) - - // Test URL with multiple query parameters including link - let complexURL = URL(string: "https://example.com?utm_source=email&link=https%3A%2F%2Fmyapp.com%2Fshare%2Fabc&utm_campaign=test")! - let result = try sdk.extractLinkFromURL(complexURL) - - #expect(result?.url?.absoluteString == "https://myapp.com/share/abc") - #expect(result?.match_type == TracebackSDK.MatchType.unknown) -} - // MARK: - Response Model Tests @Test @@ -91,6 +44,7 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { deep_link_id: URL(string: "https://example.com/product/123"), match_message: "Unique match found", match_type: "unique", + match_campaign: nil, request_ip_version: "ipv4", utm_medium: "social", utm_source: "facebook" @@ -102,6 +56,7 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { deep_link_id: nil, match_message: "No match found", match_type: "none", + match_campaign: nil, request_ip_version: "ipv4", utm_medium: nil, utm_source: nil @@ -113,6 +68,7 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { deep_link_id: URL(string: "https://example.com/default"), match_message: "Ambiguous match", match_type: "heuristics", + match_campaign: nil, request_ip_version: "ipv4", utm_medium: nil, utm_source: nil @@ -124,17 +80,31 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { deep_link_id: URL(string: "https://example.com/default"), match_message: "Heuristics match", match_type: "ambiguous", + match_campaign: nil, request_ip_version: "ipv4", utm_medium: nil, utm_source: nil ) #expect(heuristicsResponse.matchType == TracebackSDK.MatchType.ambiguous) + + // Test intent match type + let intentResponse = PostInstallLinkSearchResponse( + deep_link_id: URL(string: "https://example.com/default"), + match_message: "Intent match", + match_type: "intent", + match_campaign: nil, + request_ip_version: "ipv4", + utm_medium: nil, + utm_source: nil + ) + #expect(intentResponse.matchType == TracebackSDK.MatchType.intent) // Test unknown match type let unknownResponse = PostInstallLinkSearchResponse( deep_link_id: nil, match_message: "Unknown", match_type: "other", + match_campaign: nil, request_ip_version: "ipv4", utm_medium: nil, utm_source: nil @@ -142,6 +112,27 @@ func testPostInstallLinkSearchResponseMatchTypes() throws { #expect(unknownResponse.matchType == TracebackSDK.MatchType.unknown) } +@Test +func testCampaignLinkSearchResponse() throws { + + // Test response with valid URL + let response = CampaignResponse( + result: URL(string: "https://example.com/default"), + error: nil + ) + #expect(response.result?.absoluteString == "https://example.com/default") + #expect(response.error == nil) + + // Test response with error + let errorResponse = CampaignResponse( + result: nil, + error: "Invalid link" + ) + #expect(errorResponse.result == nil) + #expect(errorResponse.error == "Invalid link") + +} + // MARK: - Network Error Tests @Test @@ -213,7 +204,7 @@ func testAnalyticsEvents() throws { switch detectedEvent { case .postInstallDetected(let url): #expect(url == testURL) - case .postInstallError: + default: #expect(Bool(false), "Expected postInstallDetected event") } @@ -223,10 +214,10 @@ func testAnalyticsEvents() throws { let errorEvent = TracebackAnalyticsEvent.postInstallError(error) switch errorEvent { - case .postInstallDetected: - #expect(Bool(false), "Expected postInstallError event") case .postInstallError(let receivedError): #expect(receivedError is TestError) + default: + #expect(Bool(false), "Expected postInstallError event") } } @@ -283,8 +274,9 @@ func testDeviceFingerprintEquality() throws { appInstallationTime: 1234567890, bundleId: "com.example.app", osVersion: "18.0", - sdkVersion: "1.0.0", + sdkVersion: TracebackSDK.sdkVersion, uniqueMatchLinkToCheck: nil, + intentLink: nil, device: deviceInfo1 ) @@ -292,8 +284,9 @@ func testDeviceFingerprintEquality() throws { appInstallationTime: 1234567890, bundleId: "com.example.app", osVersion: "18.0", - sdkVersion: "1.0.0", + sdkVersion: TracebackSDK.sdkVersion, uniqueMatchLinkToCheck: nil, + intentLink: nil, device: deviceInfo2 ) @@ -303,7 +296,7 @@ func testDeviceFingerprintEquality() throws { // MARK: - Integration Tests with Mock Network @Test -func testAPIProviderWithMockNetwork() async throws { +func testPostInstallSearchAPIProviderWithMockNetwork() async throws { let mockNetwork = Network { request in // Create JSON manually since PostInstallLinkSearchResponse is only Decodable let jsonString = """ @@ -311,6 +304,7 @@ func testAPIProviderWithMockNetwork() async throws { "deep_link_id": "https://example.com/product/123", "match_message": "Test match", "match_type": "unique", + "match_campaign": null, "request_ip_version": "ipv4", "utm_medium": null, "utm_source": null @@ -333,8 +327,9 @@ func testAPIProviderWithMockNetwork() async throws { appInstallationTime: 1234567890, bundleId: "com.test.app", osVersion: "18.0", - sdkVersion: "1.0.0", + sdkVersion: TracebackSDK.sdkVersion, uniqueMatchLinkToCheck: URL(string: "https://test.com/link"), + intentLink: nil, device: DeviceFingerprint.DeviceInfo( deviceModelName: "iPhone15,2", languageCode: "en-US", @@ -354,6 +349,35 @@ func testAPIProviderWithMockNetwork() async throws { #expect(response.matchType == TracebackSDK.MatchType.unique) } +@Test +func testCampaignSearchAPIProviderWithMockNetwork() async throws { + let mockNetwork = Network { request in + // Create JSON manually since CampaignResponse is only Decodable + let jsonString = """ + { + "result": "https://example.com/product/123", + "error": null, + } + """ + let jsonData = jsonString.data(using: .utf8)! + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + return (jsonData, response) + } + + let config = NetworkConfiguration(host: URL(string: "https://test.firebaseapp.com")!) + let apiProvider = APIProvider(config: config, network: mockNetwork) + + let response = try await apiProvider.getCampaignLink(from: "https://test.firebaseapp.com/product", isFirstCampaignOpen: false) + + #expect(response.result?.absoluteString == "https://example.com/product/123") + #expect(response.error == nil) +} + @Test func testAPIProviderNetworkError() async throws { let mockNetwork = Network { request in @@ -367,8 +391,9 @@ func testAPIProviderNetworkError() async throws { appInstallationTime: 1234567890, bundleId: "com.test.app", osVersion: "18.0", - sdkVersion: "1.0.0", + sdkVersion: TracebackSDK.sdkVersion, uniqueMatchLinkToCheck: nil, + intentLink: nil, device: DeviceFingerprint.DeviceInfo( deviceModelName: "iPhone15,2", languageCode: "en-US", @@ -392,23 +417,6 @@ func testAPIProviderNetworkError() async throws { } } -// MARK: - URL Components Edge Cases - -@Test -func testExtractLinkFromURLWithMalformedEncoding() throws { - let config = TracebackConfiguration( - mainAssociatedHost: URL(string: "https://example.firebaseapp.com")! - ) - let sdk = TracebackSDK.live(config: config) - - // Test URL with improperly encoded link parameter - let malformedURL = URL(string: "https://example.com?link=https://myapp.com/product/123")! // Not URL encoded - let result = try sdk.extractLinkFromURL(malformedURL) - - // Should still extract the link even if not properly encoded - #expect(result?.url?.absoluteString == "https://myapp.com/product/123") -} - // MARK: - Result Object Tests @Test @@ -418,12 +426,12 @@ func testResultObjectCreation() throws { let result = TracebackSDK.Result( url: testURL, - match_type: .unique, + matchType: .unique, analytics: testAnalytics ) #expect(result.url == testURL) - #expect(result.match_type == TracebackSDK.MatchType.unique) + #expect(result.matchType == TracebackSDK.MatchType.unique) #expect(result.analytics.count == 1) if case .postInstallDetected(let analyticsURL) = result.analytics.first { @@ -438,6 +446,6 @@ func testEmptyResult() throws { let emptyResult = TracebackSDK.Result.empty #expect(emptyResult.url == nil) - #expect(emptyResult.match_type == TracebackSDK.MatchType.none) + #expect(emptyResult.matchType == TracebackSDK.MatchType.none) #expect(emptyResult.analytics.isEmpty) } diff --git a/Tests/TracebackTests/CampaignTrackerTests.swift b/Tests/TracebackTests/CampaignTrackerTests.swift new file mode 100644 index 0000000..d3f09f8 --- /dev/null +++ b/Tests/TracebackTests/CampaignTrackerTests.swift @@ -0,0 +1,70 @@ +import Testing +import Foundation +@testable import Traceback + +@Test +func testCampaignTrackerBasicFlow() throws { + let suiteName = "test.campaignTracker.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw NSError(domain: "UserDefaultsInit", code: 1) + } + // Ensure a clean slate + defaults.removePersistentDomain(forName: suiteName) + + let tracker = CampaignTracker(userDefaults: defaults) + + // fresh state + #expect(tracker.hasSeenCampaign("campaign1") == false) + #expect(tracker.isFirstTimeSeen("campaign1") == true) + #expect(tracker.hasSeenCampaign("campaign1") == true) + #expect(tracker.isFirstTimeSeen("campaign1") == false) + + // mark another campaign + tracker.markCampaignAsSeen("campaign2") + #expect(tracker.hasSeenCampaign("campaign2") == true) + + // clear + tracker.clearSeenCampaigns() + #expect(tracker.hasSeenCampaign("campaign1") == false) + #expect(tracker.hasSeenCampaign("campaign2") == false) + + // cleanup + defaults.removePersistentDomain(forName: suiteName) +} + +@Test +func testPersistenceAcrossInstances() throws { + let suiteName = "test.campaignTracker.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw NSError(domain: "UserDefaultsInit", code: 1) + } + defaults.removePersistentDomain(forName: suiteName) + + let tracker1 = CampaignTracker(userDefaults: defaults) + #expect(tracker1.hasSeenCampaign("persist") == false) + tracker1.markCampaignAsSeen("persist") + #expect(tracker1.hasSeenCampaign("persist") == true) + + // Create a new tracker backed by the same suite to ensure persistence + let tracker2 = CampaignTracker(userDefaults: defaults) + #expect(tracker2.hasSeenCampaign("persist") == true) + + // cleanup + defaults.removePersistentDomain(forName: suiteName) +} + +@Test +func testEmptyCampaignString() throws { + let suiteName = "test.campaignTracker.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw NSError(domain: "UserDefaultsInit", code: 1) + } + defaults.removePersistentDomain(forName: suiteName) + + let tracker = CampaignTracker(userDefaults: defaults) + + #expect(tracker.isFirstTimeSeen("") == true) + #expect(tracker.isFirstTimeSeen("") == false) + + defaults.removePersistentDomain(forName: suiteName) +} diff --git a/Tests/TracebackTests/NetworkTests.swift b/Tests/TracebackTests/NetworkTests.swift index 5f596e1..1821a13 100644 --- a/Tests/TracebackTests/NetworkTests.swift +++ b/Tests/TracebackTests/NetworkTests.swift @@ -274,6 +274,7 @@ func testNetworkConfigurationWithAPIProvider() async throws { "deep_link_id": "https://example.com/test", "match_message": "Success", "match_type": "unique", + "match_campaign": "summer_sale", "request_ip_version": "ipv4", "utm_medium": null, "utm_source": null @@ -299,6 +300,7 @@ func testNetworkConfigurationWithAPIProvider() async throws { osVersion: "18.0", sdkVersion: "1.0.0", uniqueMatchLinkToCheck: nil, + intentLink: nil, device: DeviceFingerprint.DeviceInfo( deviceModelName: "iPhone15,2", languageCode: "en-US", diff --git a/Tests/TracebackTests/TracebackTests.swift b/Tests/TracebackTests/TracebackTests.swift index 7b4063b..1299718 100644 --- a/Tests/TracebackTests/TracebackTests.swift +++ b/Tests/TracebackTests/TracebackTests.swift @@ -21,6 +21,7 @@ func checkCreateFingerPrint() async throws { let createdFingerPrint = await createDeviceFingerprint( system: system, linkFromClipboard: link, + linkFromIntent: nil, webviewInfo: WebViewInfo( language: localeFromWebView, appVersion: appVersionFromWebView @@ -32,6 +33,7 @@ func checkCreateFingerPrint() async throws { osVersion: system.osVersion, sdkVersion: system.sdkVersion, uniqueMatchLinkToCheck: link, + intentLink: nil, device: .init( deviceModelName: system.deviceModelName, languageCode: "es-ES",