diff --git a/.circleci/config.yml b/.circleci/config.yml index 7fe14e81..aa20b961 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -84,8 +84,10 @@ commands: command: | mv IntegrationTests/Assets/Editor/CIEditorScript.cs IntegrationTests/Assets/Editor/CIEditorScript.cs.break mv IntegrationTests/Assets/Main.cs IntegrationTests/Assets/Main.cs.break - # rename all the .cs files in APITests to .cs.break - for file in IntegrationTests/Assets/APITests/*.cs ; do mv "$file" "${file%%}.break" ; done + # Hide API fixtures and tests until the SDK assemblies have been imported. + find IntegrationTests/Assets/APITests IntegrationTests/Assets/Tests \ + -type f \( -name "*.cs" -o -name "*.asmdef" \) \ + -exec sh -c 'for file do mv "$file" "$file.break"; done' sh {} + - run: @@ -117,8 +119,9 @@ commands: command: | mv IntegrationTests/Assets/Editor/CIEditorScript.cs.break IntegrationTests/Assets/Editor/CIEditorScript.cs mv IntegrationTests/Assets/Main.cs.break IntegrationTests/Assets/Main.cs - # rename all the .cs.break files in APITests to .cs - for file in IntegrationTests/Assets/APITests/*.cs.break ; do mv "$file" "${file%.*}" ; done + find IntegrationTests/Assets/APITests IntegrationTests/Assets/Tests \ + -type f -name "*.break" \ + -exec sh -c 'for file do mv "$file" "${file%.break}"; done' sh {} + perform-build: description: "Builds Unity project" @@ -347,6 +350,29 @@ jobs: - store_artifacts: path: IntegrationTests/Builds/Android/Android.apk + test-edit-mode: + executor: unity + steps: + - checkout + - attach_workspace: + at: . + + - unity/prepare-env: + project-path: IntegrationTests + + - import-package + + - unity/test: + project-path: IntegrationTests + test-platform: editmode + custom-parameters: -disable-assembly-updater + + - store_artifacts: + path: IntegrationTests/editmode-results.xml + + - store_artifacts: + path: IntegrationTests/editmode-junit-results.xml + build-integration-tests-ios: executor: unity-ios parameters: @@ -513,6 +539,10 @@ workflows: context: unity requires: - export-package + - test-edit-mode: + context: unity + requires: + - export-package - build-integration-tests-ios: variant: spm context: unity @@ -538,6 +568,7 @@ workflows: requires: - check-android-keep-annotations - build-integration-tests-android + - test-edit-mode - build-subtester-android - build-subtester-ios - archive-ios diff --git a/IntegrationTests/Assets/Tests.meta b/IntegrationTests/Assets/Tests.meta new file mode 100644 index 00000000..3bcc8ae1 --- /dev/null +++ b/IntegrationTests/Assets/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8057e675b41f47818c383db9afa1668e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/IntegrationTests/Assets/Tests/EditMode.meta b/IntegrationTests/Assets/Tests/EditMode.meta new file mode 100644 index 00000000..c477ba34 --- /dev/null +++ b/IntegrationTests/Assets/Tests/EditMode.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ecb0fe24604f4e649d25c13636f86a72 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/IntegrationTests/Assets/Tests/EditMode/PurchasesWrapperSpy.cs b/IntegrationTests/Assets/Tests/EditMode/PurchasesWrapperSpy.cs new file mode 100644 index 00000000..94856026 --- /dev/null +++ b/IntegrationTests/Assets/Tests/EditMode/PurchasesWrapperSpy.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using RevenueCat; + +namespace RevenueCat.Tests +{ + internal sealed class PurchasesWrapperSpy : IPurchasesWrapper + { + internal sealed class Invocation + { + internal readonly string Method; + internal readonly object[] Arguments; + + internal Invocation(string method, object[] arguments) + { + Method = method; + Arguments = arguments; + } + } + + internal readonly List Invocations = new List(); + + internal Invocation LastInvocation => + Invocations.Count > 0 + ? Invocations[Invocations.Count - 1] + : throw new InvalidOperationException( + $"No {nameof(IPurchasesWrapper)} calls were recorded on the spy."); + + private void Record(string method, params object[] arguments) + { + Invocations.Add(new Invocation(method, arguments)); + } + + public void Setup(string gameObject, string apiKey, string appUserId, + Purchases.PurchasesAreCompletedBy purchasesAreCompletedBy, Purchases.StoreKitVersion storeKitVersion, + string userDefaultsSuiteName, bool useAmazon, string dangerousSettingsJson, + bool shouldShowInAppMessagesAutomatically, bool pendingTransactionsForPrepaidPlansEnabled, + bool diagnosticsEnabled, bool automaticDeviceIdentifierCollectionEnabled, string preferredUILocaleOverride) + { + Record(nameof(Setup), gameObject, apiKey, appUserId, purchasesAreCompletedBy, storeKitVersion, + userDefaultsSuiteName, useAmazon, dangerousSettingsJson, shouldShowInAppMessagesAutomatically, + pendingTransactionsForPrepaidPlansEnabled, diagnosticsEnabled, + automaticDeviceIdentifierCollectionEnabled, preferredUILocaleOverride); + } + + public void Setup(string gameObject, string apiKey, string appUserId, + Purchases.PurchasesAreCompletedBy purchasesAreCompletedBy, Purchases.StoreKitVersion storeKitVersion, + string userDefaultsSuiteName, bool useAmazon, string dangerousSettingsJson, + bool shouldShowInAppMessagesAutomatically, + Purchases.EntitlementVerificationMode entitlementVerificationMode, + bool pendingTransactionsForPrepaidPlansEnabled, bool diagnosticsEnabled, + bool automaticDeviceIdentifierCollectionEnabled, string preferredUILocaleOverride) + { + Record(nameof(Setup), gameObject, apiKey, appUserId, purchasesAreCompletedBy, storeKitVersion, + userDefaultsSuiteName, useAmazon, dangerousSettingsJson, shouldShowInAppMessagesAutomatically, + entitlementVerificationMode, pendingTransactionsForPrepaidPlansEnabled, diagnosticsEnabled, + automaticDeviceIdentifierCollectionEnabled, preferredUILocaleOverride); + } + + public void GetStorefront() => Record(nameof(GetStorefront)); + public void GetProducts(string[] productIdentifiers, string type = "subs") => + Record(nameof(GetProducts), productIdentifiers, type); + + public void PurchaseProduct(string productIdentifier, string type = "subs", string oldSku = null, + Purchases.ProrationMode prorationMode = + Purchases.ProrationMode.UnknownSubscriptionUpgradeDowngradePolicy, + bool googleIsPersonalizedPrice = false, string presentedOfferingIdentifier = null, + Purchases.PromotionalOffer discount = null) => + Record(nameof(PurchaseProduct), productIdentifier, type, oldSku, prorationMode, + googleIsPersonalizedPrice, presentedOfferingIdentifier, discount); + + public void PurchasePackage(Purchases.Package packageToPurchase, string oldSku = null, + Purchases.ProrationMode prorationMode = + Purchases.ProrationMode.UnknownSubscriptionUpgradeDowngradePolicy, + bool googleIsPersonalizedPrice = false, Purchases.PromotionalOffer discount = null) => + Record(nameof(PurchasePackage), packageToPurchase, oldSku, prorationMode, + googleIsPersonalizedPrice, discount); + + public void PurchaseSubscriptionOption(Purchases.SubscriptionOption subscriptionOption, + Purchases.GoogleProductChangeInfo googleProductChangeInfo = null, + bool googleIsPersonalizedPrice = false) => + Record(nameof(PurchaseSubscriptionOption), subscriptionOption, googleProductChangeInfo, + googleIsPersonalizedPrice); + + public void RestorePurchases() => Record(nameof(RestorePurchases)); + public void LogIn(string appUserId) => Record(nameof(LogIn), appUserId); + public void LogOut() => Record(nameof(LogOut)); + public void SetAllowSharingStoreAccount(bool allow) => Record(nameof(SetAllowSharingStoreAccount), allow); + public void SetDebugLogsEnabled(bool enabled) => Record(nameof(SetDebugLogsEnabled), enabled); + public void SetLogLevel(Purchases.LogLevel level) => Record(nameof(SetLogLevel), level); + public void SetLogHandler() => Record(nameof(SetLogHandler)); + public void SetProxyURL(string proxyURL) => Record(nameof(SetProxyURL), proxyURL); + public string GetAppUserId() + { + Record(nameof(GetAppUserId)); + return null; + } + + public void GetCustomerInfo() => Record(nameof(GetCustomerInfo)); + public void GetOfferings() => Record(nameof(GetOfferings)); + public void GetCurrentOfferingForPlacement(string placementIdentifier) => + Record(nameof(GetCurrentOfferingForPlacement), placementIdentifier); + + public void SyncAttributesAndOfferingsIfNeeded() => Record(nameof(SyncAttributesAndOfferingsIfNeeded)); + public void SyncPurchases() => Record(nameof(SyncPurchases)); + + public void SyncAmazonPurchase(string productID, string receiptID, string amazonUserID, + string isoCurrencyCode, double price) => + Record(nameof(SyncAmazonPurchase), productID, receiptID, amazonUserID, isoCurrencyCode, price); + + public void GetAmazonLWAConsentStatus() => Record(nameof(GetAmazonLWAConsentStatus)); + public void EnableAdServicesAttributionTokenCollection() => + Record(nameof(EnableAdServicesAttributionTokenCollection)); + + public bool IsAnonymous() + { + Record(nameof(IsAnonymous)); + return false; + } + + public bool IsConfigured() + { + Record(nameof(IsConfigured)); + return false; + } + + // The cast keeps `string[]` from binding as the `params object[]` array itself (array covariance), so the + // identifiers are recorded as one argument, like every other method here. + public void CheckTrialOrIntroductoryPriceEligibility(string[] productIdentifiers) => + Record(nameof(CheckTrialOrIntroductoryPriceEligibility), (object)productIdentifiers); + + public void InvalidateCustomerInfoCache() => Record(nameof(InvalidateCustomerInfoCache)); + public void OverridePreferredUILocale(string locale) => Record(nameof(OverridePreferredUILocale), locale); + public void PresentCodeRedemptionSheet() => Record(nameof(PresentCodeRedemptionSheet)); + public void RecordPurchase(string productID) => Record(nameof(RecordPurchase), productID); + public void SetSimulatesAskToBuyInSandbox(bool enabled) => + Record(nameof(SetSimulatesAskToBuyInSandbox), enabled); + + public void SetAttributes(string attributesJson) => Record(nameof(SetAttributes), attributesJson); + public void SetEmail(string email) => Record(nameof(SetEmail), email); + public void SetPhoneNumber(string phoneNumber) => Record(nameof(SetPhoneNumber), phoneNumber); + public void SetDisplayName(string displayName) => Record(nameof(SetDisplayName), displayName); + public void SetPushToken(string token) => Record(nameof(SetPushToken), token); + public void SetAdjustID(string adjustID) => Record(nameof(SetAdjustID), adjustID); + public void SetAppsflyerID(string appsflyerID) => Record(nameof(SetAppsflyerID), appsflyerID); + public void SetFBAnonymousID(string fbAnonymousID) => Record(nameof(SetFBAnonymousID), fbAnonymousID); + public void SetMparticleID(string mparticleID) => Record(nameof(SetMparticleID), mparticleID); + public void SetOnesignalID(string onesignalID) => Record(nameof(SetOnesignalID), onesignalID); + public void SetOnesignalUserID(string onesignalUserID) => Record(nameof(SetOnesignalUserID), onesignalUserID); + public void SetAirshipChannelID(string airshipChannelID) => + Record(nameof(SetAirshipChannelID), airshipChannelID); + + public void SetCleverTapID(string cleverTapID) => Record(nameof(SetCleverTapID), cleverTapID); + public void SetMixpanelDistinctID(string mixpanelDistinctID) => + Record(nameof(SetMixpanelDistinctID), mixpanelDistinctID); + + public void SetFirebaseAppInstanceID(string firebaseAppInstanceID) => + Record(nameof(SetFirebaseAppInstanceID), firebaseAppInstanceID); + + public void SetMediaSource(string mediaSource) => Record(nameof(SetMediaSource), mediaSource); + public void SetCampaign(string campaign) => Record(nameof(SetCampaign), campaign); + public void SetAdGroup(string adGroup) => Record(nameof(SetAdGroup), adGroup); + public void SetAd(string ad) => Record(nameof(SetAd), ad); + public void SetKeyword(string keyword) => Record(nameof(SetKeyword), keyword); + public void SetCreative(string creative) => Record(nameof(SetCreative), creative); + public void SetAppsFlyerConversionData(string conversionDataJson) => + Record(nameof(SetAppsFlyerConversionData), conversionDataJson); + + public void CollectDeviceIdentifiers() => Record(nameof(CollectDeviceIdentifiers)); + public void CanMakePayments(Purchases.BillingFeature[] features) => + Record(nameof(CanMakePayments), features); + + public void GetPromotionalOffer(string productIdentifier, string discountIdentifier) => + Record(nameof(GetPromotionalOffer), productIdentifier, discountIdentifier); + + public void ShowInAppMessages(Purchases.InAppMessageType[] messageTypes) => + Record(nameof(ShowInAppMessages), messageTypes); + + public void ParseAsWebPurchaseRedemption(string urlString) => + Record(nameof(ParseAsWebPurchaseRedemption), urlString); + + public void RedeemWebPurchase(Purchases.WebPurchaseRedemption webPurchaseRedemption) => + Record(nameof(RedeemWebPurchase), webPurchaseRedemption); + + public void GetVirtualCurrencies() => Record(nameof(GetVirtualCurrencies)); + + public string GetCachedVirtualCurrencies() + { + Record(nameof(GetCachedVirtualCurrencies)); + return null; + } + + public void InvalidateVirtualCurrenciesCache() => Record(nameof(InvalidateVirtualCurrenciesCache)); + public void GetEligibleWinBackOffersForProduct(Purchases.StoreProduct storeProduct) => + Record(nameof(GetEligibleWinBackOffersForProduct), storeProduct); + + public void GetEligibleWinBackOffersForPackage(Purchases.Package package) => + Record(nameof(GetEligibleWinBackOffersForPackage), package); + + public void PurchaseProductWithWinBackOffer(Purchases.StoreProduct storeProduct, + Purchases.WinBackOffer winBackOffer) => + Record(nameof(PurchaseProductWithWinBackOffer), storeProduct, winBackOffer); + + public void PurchasePackageWithWinBackOffer(Purchases.Package package, + Purchases.WinBackOffer winBackOffer) => + Record(nameof(PurchasePackageWithWinBackOffer), package, winBackOffer); + + public void TrackCustomPaywallImpression(Purchases.CustomPaywallImpressionParams parameters) => + Record(nameof(TrackCustomPaywallImpression), parameters); + + public void TrackAdDisplayed(AdDisplayedData data) => Record(nameof(TrackAdDisplayed), data); + public void TrackAdOpened(AdOpenedData data) => Record(nameof(TrackAdOpened), data); + public void TrackAdRevenue(AdRevenueData data) => Record(nameof(TrackAdRevenue), data); + public void TrackAdLoaded(AdLoadedData data) => Record(nameof(TrackAdLoaded), data); + public void TrackAdFailedToLoad(AdFailedToLoadData data) => Record(nameof(TrackAdFailedToLoad), data); + } +} diff --git a/IntegrationTests/Assets/Tests/EditMode/PurchasesWrapperSpy.cs.meta b/IntegrationTests/Assets/Tests/EditMode/PurchasesWrapperSpy.cs.meta new file mode 100644 index 00000000..935aa898 --- /dev/null +++ b/IntegrationTests/Assets/Tests/EditMode/PurchasesWrapperSpy.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 536c0eb613ec41f68eba862bd85dc4a8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/IntegrationTests/Assets/Tests/EditMode/RevenueCat.Tests.EditMode.asmdef b/IntegrationTests/Assets/Tests/EditMode/RevenueCat.Tests.EditMode.asmdef new file mode 100644 index 00000000..9cab16e1 --- /dev/null +++ b/IntegrationTests/Assets/Tests/EditMode/RevenueCat.Tests.EditMode.asmdef @@ -0,0 +1,21 @@ +{ + "name": "RevenueCat.Tests.EditMode", + "rootNamespace": "RevenueCat.Tests", + "references": [ + "revenuecat.purchases-unity" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": [ + "TestAssemblies" + ] +} diff --git a/IntegrationTests/Assets/Tests/EditMode/RevenueCat.Tests.EditMode.asmdef.meta b/IntegrationTests/Assets/Tests/EditMode/RevenueCat.Tests.EditMode.asmdef.meta new file mode 100644 index 00000000..cc6a30f9 --- /dev/null +++ b/IntegrationTests/Assets/Tests/EditMode/RevenueCat.Tests.EditMode.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 22b4de0286b948bca3f72ee4897af9b8 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/IntegrationTests/README.md b/IntegrationTests/README.md index ebb3fa4d..b2e8adfb 100644 --- a/IntegrationTests/README.md +++ b/IntegrationTests/README.md @@ -9,3 +9,12 @@ In order to use it: 1. Set the RevenueCat API key in the Purchases object 1. Set the appUserId if needed in the Purchases object 1. Set the Parent Panel, Button Prefab and Customer Info Label objects in the PurchasesListener. + +### Automated Edit Mode tests + +Executable tests live in `Assets/Tests/EditMode`. After importing `Purchases.unitypackage` and +`PurchasesUI.unitypackage` into this project, run them from **Window > General > Test Runner** by selecting +**EditMode** and **Run All**. + +CircleCI exports and imports both packages before running the same suite, so the tests cover the packaged SDK +that users install. diff --git a/RevenueCat/Scripts/AssemblyInfo.cs b/RevenueCat/Scripts/AssemblyInfo.cs new file mode 100644 index 00000000..a5345f79 --- /dev/null +++ b/RevenueCat/Scripts/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("RevenueCat.Tests.EditMode")] diff --git a/RevenueCat/Scripts/AssemblyInfo.cs.meta b/RevenueCat/Scripts/AssemblyInfo.cs.meta new file mode 100644 index 00000000..309d2935 --- /dev/null +++ b/RevenueCat/Scripts/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0ad31db8e8574a72a9df2dd9140a8738 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/RevenueCat/Scripts/Purchases.cs b/RevenueCat/Scripts/Purchases.cs index af05f408..3fd27f3e 100644 --- a/RevenueCat/Scripts/Purchases.cs +++ b/RevenueCat/Scripts/Purchases.cs @@ -107,16 +107,21 @@ public partial class Purchases : MonoBehaviour /// Experimental: this API is unstable and may change in a future release. public RevenueCat.AdTracker AdTracker { get; private set; } + internal void SetWrapper(IPurchasesWrapper wrapper) + { + _wrapper = wrapper ?? throw new ArgumentNullException(nameof(wrapper)); + AdTracker = new RevenueCat.AdTracker(_wrapper); + } + private void Start() { #if UNITY_ANDROID && !UNITY_EDITOR - _wrapper = new PurchasesWrapperAndroid(); + SetWrapper(new PurchasesWrapperAndroid()); #elif (UNITY_IOS || UNITY_VISIONOS) && !UNITY_EDITOR - _wrapper = new PurchasesWrapperiOS(); + SetWrapper(new PurchasesWrapperiOS()); #else - _wrapper = new PurchasesWrapperNoop(); + SetWrapper(new PurchasesWrapperNoop()); #endif - AdTracker = new RevenueCat.AdTracker(_wrapper); if (!string.IsNullOrEmpty(proxyURL)) { _wrapper.SetProxyURL(proxyURL);