From 3999a42c5c74f994845dba37df047e2257cd8942 Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Fri, 8 Sep 2023 15:03:19 -0700 Subject: [PATCH] `Paywalls`: automatically flush events This uses the new `Delay.long` (#3168) to flush events on app foreground. Also added more tests. --- RevenueCat.xcodeproj/project.pbxproj | 4 ++ .../Networking/PaywallEventsRequest.swift | 8 ++-- .../Events/PaywallEventsManager.swift | 2 + Sources/Purchasing/Purchases/Purchases.swift | 11 +++++ .../BaseBackendIntegrationTests.swift | 11 +++-- .../PaywallEventsIntegrationTests.swift | 10 +++++ .../Mocks/MockOperationDispatcher.swift | 22 ++++++++++ .../Events/PaywallEventsManagerTests.swift | 22 ++++++++++ .../Events/PurchasesPaywallEventsTests.swift | 41 +++++++++++++++++++ 9 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 Tests/UnitTests/Paywalls/Events/PurchasesPaywallEventsTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index e54cbb7f00..b3507744f1 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -300,6 +300,7 @@ 4FD3688B2AA7C12600F63354 /* PaywallEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD3688A2AA7C12600F63354 /* PaywallEvent.swift */; }; 4FD368B42AA7CFED00F63354 /* PaywallEventStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD368B32AA7CFED00F63354 /* PaywallEventStore.swift */; }; 4FD368B62AA7D09C00F63354 /* PaywallEventSerializer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD368B52AA7D09C00F63354 /* PaywallEventSerializer.swift */; }; + 4FD7E8662AABC4470055406F /* PurchasesPaywallEventsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD7E8652AABC4470055406F /* PurchasesPaywallEventsTests.swift */; }; 4FDA13842A33D9BD00C45CFE /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 4FDA13662A33D13700C45CFE /* PrivacyInfo.xcprivacy */; }; 4FDF10E72A725EA6004F3680 /* ExternalPurchasesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FDF10E62A725EA6004F3680 /* ExternalPurchasesManager.swift */; }; 4FDF10E82A725EA6004F3680 /* ExternalPurchasesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FDF10E62A725EA6004F3680 /* ExternalPurchasesManager.swift */; }; @@ -1040,6 +1041,7 @@ 4FD3688A2AA7C12600F63354 /* PaywallEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallEvent.swift; sourceTree = ""; }; 4FD368B32AA7CFED00F63354 /* PaywallEventStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallEventStore.swift; sourceTree = ""; }; 4FD368B52AA7D09C00F63354 /* PaywallEventSerializer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallEventSerializer.swift; sourceTree = ""; }; + 4FD7E8652AABC4470055406F /* PurchasesPaywallEventsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesPaywallEventsTests.swift; sourceTree = ""; }; 4FDA13662A33D13700C45CFE /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 4FDF10E62A725EA6004F3680 /* ExternalPurchasesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExternalPurchasesManager.swift; sourceTree = ""; }; 4FDF10E92A726269004F3680 /* ObserverModeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObserverModeManager.swift; sourceTree = ""; }; @@ -2358,6 +2360,7 @@ 4FFCED802AA941B200118EF4 /* PaywallEventsRequestTests.swift */, 4FE6FEE82AA940E300780B45 /* PaywallEventSerializerTests.swift */, 4FE6FEE72AA940E300780B45 /* PaywallEventStoreTests.swift */, + 4FD7E8652AABC4470055406F /* PurchasesPaywallEventsTests.swift */, ); path = Events; sourceTree = ""; @@ -3701,6 +3704,7 @@ 5796A39427D6BD6900653165 /* BackendGetOfferingsTests.swift in Sources */, 5766AA42283C768600FA6091 /* OperatorExtensionsTests.swift in Sources */, 4F54DF3F2A1D8C7500FD72BF /* MockStoreKit2TransactionFetcher.swift in Sources */, + 4FD7E8662AABC4470055406F /* PurchasesPaywallEventsTests.swift in Sources */, 351B516A26D44CB300BD2BD7 /* ISOPeriodFormatterTests.swift in Sources */, 57DC9F4A27CD37BA00DA6AF9 /* HTTPStatusCodeTests.swift in Sources */, 2DDF41DE24F6F527005BC22D /* MockAppleReceiptBuilder.swift in Sources */, diff --git a/Sources/Paywalls/Events/Networking/PaywallEventsRequest.swift b/Sources/Paywalls/Events/Networking/PaywallEventsRequest.swift index 478d035889..031f67cc1a 100644 --- a/Sources/Paywalls/Events/Networking/PaywallEventsRequest.swift +++ b/Sources/Paywalls/Events/Networking/PaywallEventsRequest.swift @@ -33,9 +33,9 @@ extension PaywallEventsRequest { enum EventType: String { - case view - case cancel - case close + case view = "paywall_view" + case cancel = "paywall_cancel" + case close = "paywall_close" } @@ -111,7 +111,7 @@ extension PaywallEventsRequest.Event: Encodable { case timestamp case displayMode case darkMode - case localeIdentifier + case localeIdentifier = "locale" } diff --git a/Sources/Paywalls/Events/PaywallEventsManager.swift b/Sources/Paywalls/Events/PaywallEventsManager.swift index 8b6b98b1fe..c59c285f99 100644 --- a/Sources/Paywalls/Events/PaywallEventsManager.swift +++ b/Sources/Paywalls/Events/PaywallEventsManager.swift @@ -83,4 +83,6 @@ actor PaywallEventsManager: PaywallEventsManagerType { } } + static let defaultEventFlushCount = 50 + } diff --git a/Sources/Purchasing/Purchases/Purchases.swift b/Sources/Purchasing/Purchases/Purchases.swift index 32a8b80d8d..cfb7545e88 100644 --- a/Sources/Purchasing/Purchases/Purchases.swift +++ b/Sources/Purchasing/Purchases/Purchases.swift @@ -1576,6 +1576,8 @@ private extension Purchases { } #endif + self.postPaywallEventsIfNeeded() + #endif } @@ -1687,6 +1689,15 @@ private extension Purchases { } } + private func postPaywallEventsIfNeeded() { + guard #available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *), + let manager = self.paywallEventsManager else { return } + + self.operationDispatcher.dispatchOnWorkerThread(delay: .long) { + _ = try? await manager.flushEvents(count: PaywallEventsManager.defaultEventFlushCount) + } + } + } // MARK: - Deprecations diff --git a/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift b/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift index bc29401a7a..5495cdcdfb 100644 --- a/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/BaseBackendIntegrationTests.swift @@ -39,6 +39,7 @@ final class TestPurchaseDelegate: NSObject, PurchasesDelegate, Sendable { class BaseBackendIntegrationTests: TestCase { private var userDefaults: UserDefaults! + private var documentsDirectory: URL! private var testUUID: UUID! // swiftlint:disable:next weak_delegate @@ -97,6 +98,10 @@ class BaseBackendIntegrationTests: TestCase { self.createUserDefaults() + self.documentsDirectory = URL + .cachesDirectory + .appendingPathComponent(UUID().uuidString, conformingTo: .directory) + // We use a different identifier for each test to ensure the backend // doesn't produce conflicts when producing similar receipts across // separate test invocations. @@ -220,12 +225,6 @@ private extension BaseBackendIntegrationTests { internalSettings: self) } - var documentsDirectory: URL { - return URL - .cachesDirectory - .appendingPathComponent(UUID().uuidString, conformingTo: .directory) - } - } extension BaseBackendIntegrationTests: InternalDangerousSettingsType { diff --git a/Tests/BackendIntegrationTests/PaywallEventsIntegrationTests.swift b/Tests/BackendIntegrationTests/PaywallEventsIntegrationTests.swift index 583fdf1e0d..fb3d92189b 100644 --- a/Tests/BackendIntegrationTests/PaywallEventsIntegrationTests.swift +++ b/Tests/BackendIntegrationTests/PaywallEventsIntegrationTests.swift @@ -103,6 +103,16 @@ class PaywallEventsIntegrationTests: BaseStoreKitIntegrationTests { expect(result) == 0 } + func testRemembersEventsWhenReopeningApp() async throws { + try await self.purchases.track(paywallEvent: .view(self.eventData)) + try await self.purchases.track(paywallEvent: .close(self.eventData)) + + await self.resetSingleton() + + let result = try await self.purchases.flushPaywallEvents(count: 10) + expect(result) == 2 + } + } private extension PaywallEventsIntegrationTests { diff --git a/Tests/UnitTests/Mocks/MockOperationDispatcher.swift b/Tests/UnitTests/Mocks/MockOperationDispatcher.swift index 3c373bfea3..04150c08fe 100644 --- a/Tests/UnitTests/Mocks/MockOperationDispatcher.swift +++ b/Tests/UnitTests/Mocks/MockOperationDispatcher.swift @@ -72,4 +72,26 @@ class MockOperationDispatcher: OperationDispatcher { } } + var invokedDispatchAsyncOnWorkerThread = false + var invokedDispatchAsyncOnWorkerThreadCount = 0 + var invokedDispatchAsyncOnWorkerThreadDelayParam: Delay? + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *) + override func dispatchOnWorkerThread( + delay: Delay = .none, + block: @escaping @Sendable () async -> Void + ) { + self.invokedDispatchAsyncOnWorkerThreadDelayParam = delay + self.invokedDispatchAsyncOnWorkerThread = true + self.invokedDispatchAsyncOnWorkerThreadCount += 1 + + if self.forwardToOriginalDispatchOnWorkerThread { + super.dispatchOnWorkerThread(delay: delay, block: block) + } else if self.shouldInvokeDispatchOnWorkerThreadBlock { + Task { + await block() + } + } + } + } diff --git a/Tests/UnitTests/Paywalls/Events/PaywallEventsManagerTests.swift b/Tests/UnitTests/Paywalls/Events/PaywallEventsManagerTests.swift index a2d4277250..9b0098c820 100644 --- a/Tests/UnitTests/Paywalls/Events/PaywallEventsManagerTests.swift +++ b/Tests/UnitTests/Paywalls/Events/PaywallEventsManagerTests.swift @@ -187,6 +187,28 @@ class PaywallEventsManagerTests: TestCase { await self.verifyEvents([.init(event: event2, userID: Self.userID)]) } + func testCannotFlushMultipleTimesInParallel() async throws { + let event1 = await self.storeRandomEvent() + _ = await self.storeRandomEvent() + + let task1 = Task { [manager = self.manager!] in try await manager.flushEvents(count: 1) } + let task2 = Task { [manager = self.manager!] in try await manager.flushEvents(count: 1) } + + let result1 = try await task1.value + let result2 = try await task2.value + + expect(result1) == 1 + expect(result2) == 0 + + expect(self.api.invokedPostPaywallEvents) == true + expect(self.api.invokedPostPaywallEventsParameters).to(haveCount(1)) + expect(self.api.invokedPostPaywallEventsParameters.onlyElement) == [.init(event: event1, userID: Self.userID)] + + self.logger.verifyMessageWasLogged(Strings.paywalls.event_flush_already_in_progress, + level: .debug, + expectedCount: 1) + } + // MARK: - private static let userID = "nacho" diff --git a/Tests/UnitTests/Paywalls/Events/PurchasesPaywallEventsTests.swift b/Tests/UnitTests/Paywalls/Events/PurchasesPaywallEventsTests.swift new file mode 100644 index 0000000000..b73dd0e3f8 --- /dev/null +++ b/Tests/UnitTests/Paywalls/Events/PurchasesPaywallEventsTests.swift @@ -0,0 +1,41 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// PurchasesPaywallEventsTests.swift +// +// Created by Nacho Soto on 9/8/23. + +import Nimble +import StoreKit +import XCTest + +@testable import RevenueCat + +@available(iOS 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) +class PurchasesPaywallEventsTests: BasePurchasesTests { + + override func setUpWithError() throws { + try super.setUpWithError() + + try AvailabilityChecks.iOS15APIAvailableOrSkipTest() + + self.setupPurchases() + } + + func testApplicationWillEnterForegroundSendsEvents() async throws { + self.notificationCenter.fireNotifications() + + let manager = try self.mockPaywallEventsManager + + try await asyncWait { await manager.invokedFlushEvents == true } + + expect(self.mockOperationDispatcher.invokedDispatchAsyncOnWorkerThreadDelayParam) == .long + } + +}