From eb8d9b7027ce59255faf736ecd7b628e3c5aad48 Mon Sep 17 00:00:00 2001 From: Cesar de la Vega <664544+vegaro@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:51:50 +0200 Subject: [PATCH 1/4] feat(remote-config): serve workflows and ui_config from the config endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkflowManager stays the consumer-facing seam but becomes a thin adapter over WorkflowsConfigProvider, which reads the `workflows` topic through RemoteConfigManager's topic()/body() facade. The old backend-per-workflow path is unwired: no Backend.getWorkflow, no CDN envelope resolution, no WorkflowsCache reads, no stale-while-revalidate, and offerings no longer fan out a workflows-list fetch. ui_config is served from its own topic: UiConfigProvider assembles the `app`, `localizations` and `variable_config` parts into one UiConfig and the workflow parser swaps it in (PublishedWorkflow.uiConfig now defaults instead of failing when absent). The workflowIdForOfferingId chain (Purchases → orchestrator → manager → provider) becomes suspend so topic reads run on the manager's IO dispatcher instead of the caller's thread; PaywallViewModel already calls it from a suspend context. PurchasesFactory constructs the RemoteConfigManager whenever useWorkflows is on (the config layer is now a prerequisite for workflows), keeping ENABLE_REMOTE_CONFIG as the standalone flag. Co-Authored-By: Claude Fable 5 --- .../com/revenuecat/purchases/Purchases.kt | 3 +- .../revenuecat/purchases/PurchasesFactory.kt | 61 +- .../purchases/PurchasesOrchestrator.kt | 11 +- .../common/offerings/OfferingsManager.kt | 21 +- .../common/remoteconfig/UiConfigProvider.kt | 30 + .../common/workflows/WorkflowManager.kt | 481 +--- .../common/workflows/WorkflowModels.kt | 4 +- .../workflows/WorkflowsConfigProvider.kt | 73 + .../purchases/PurchasesCommonTest.kt | 8 +- .../common/offerings/OfferingsManagerTest.kt | 245 -- .../common/workflows/WorkflowManagerTest.kt | 2398 +---------------- .../WorkflowsConfigIntegrationTest.kt | 352 +++ .../ui/revenuecatui/data/MockPurchasesType.kt | 2 +- .../ui/revenuecatui/data/PurchasesType.kt | 4 +- .../ui/revenuecatui/data/MockPurchasesType.kt | 2 +- .../revenuecatui/data/PaywallViewModelTest.kt | 10 +- 16 files changed, 565 insertions(+), 3140 deletions(-) create mode 100644 purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/UiConfigProvider.kt create mode 100644 purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowsConfigProvider.kt create mode 100644 purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowsConfigIntegrationTest.kt diff --git a/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt b/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt index 3529a26d6d..85e6359ad6 100644 --- a/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt +++ b/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt @@ -426,7 +426,8 @@ public class Purchases internal constructor( } @InternalRevenueCatAPI - public fun workflowIdForOfferingId(offeringId: String): String? = + @JvmSynthetic + public suspend fun workflowIdForOfferingId(offeringId: String): String? = purchasesOrchestrator.workflowIdForOfferingId(offeringId) /** diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt index 401e0f4ac3..eddab6403d 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt @@ -43,16 +43,14 @@ import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager import com.revenuecat.purchases.common.verification.SignatureVerificationMode import com.revenuecat.purchases.common.verification.SigningManager import com.revenuecat.purchases.common.warnLog -import com.revenuecat.purchases.common.workflows.FileCachedWorkflowCdnFetcher -import com.revenuecat.purchases.common.workflows.WorkflowDetailResolver import com.revenuecat.purchases.common.workflows.WorkflowManager import com.revenuecat.purchases.common.workflows.WorkflowsCache +import com.revenuecat.purchases.common.workflows.WorkflowsConfigProvider import com.revenuecat.purchases.identity.IdentityManager import com.revenuecat.purchases.paywalls.FontLoader import com.revenuecat.purchases.paywalls.OfferingFontPreDownloader import com.revenuecat.purchases.paywalls.PaywallPresentedCache import com.revenuecat.purchases.paywalls.events.PaywallStoredEvent -import com.revenuecat.purchases.storage.DefaultFileCache import com.revenuecat.purchases.storage.DefaultFileRepository import com.revenuecat.purchases.strings.ConfigureStrings import com.revenuecat.purchases.strings.Emojis @@ -65,13 +63,8 @@ import com.revenuecat.purchases.utils.IsDebugBuildProvider import com.revenuecat.purchases.utils.OfferingImagePreDownloader import com.revenuecat.purchases.utils.PaywallComponentsImagePreDownloader import com.revenuecat.purchases.utils.PurchaseParamsValidator -import com.revenuecat.purchases.utils.WorkflowAssetPreDownloader import com.revenuecat.purchases.utils.isAndroidNOrNewer import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencyManager -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.NonCancellable import java.net.URL import java.util.concurrent.ExecutorService import java.util.concurrent.Executors @@ -85,7 +78,6 @@ internal class PurchasesFactory( @OptIn( ExperimentalPreviewRevenueCatPurchasesAPI::class, InternalRevenueCatAPI::class, - ExperimentalCoroutinesApi::class, ) @Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod") fun createPurchases( @@ -274,7 +266,9 @@ internal class PurchasesFactory( val workflowsCache = if (appConfig.useWorkflows) WorkflowsCache(deviceCache = cache) else null - val remoteConfigManager = if (BuildConfig.ENABLE_REMOTE_CONFIG) { + // useWorkflows implies the config layer: workflows are served from `/v1/config`, so the manager + // must exist whenever workflows are on, not only when the standalone flag is set. + val remoteConfigManager = if (BuildConfig.ENABLE_REMOTE_CONFIG || appConfig.useWorkflows) { RemoteConfigManager( backend = backend, diskCache = RemoteConfigDiskCache(contextForStorage), @@ -383,32 +377,14 @@ internal class PurchasesFactory( fontLoader = fontLoader, ) - val workflowManager = workflowsCache?.let { - WorkflowManager( - backend = backend, - workflowDetailResolver = WorkflowDetailResolver( - workflowCdnFetcher = FileCachedWorkflowCdnFetcher( - // Dedicated FileRepository instance with a concurrency-limited scope, so workflow - // CDN downloads are capped without affecting the instances used for images/video. - fileRepository = DefaultFileRepository( - fileCacheManager = DefaultFileCache(contextForStorage, "rc_compiled_workflows"), - ioScope = CoroutineScope( - Dispatchers.IO.limitedParallelism(MAX_CONCURRENT_WORKFLOW_CDN_FETCHES) + - NonCancellable, - ), - ), - ), - ), - workflowAssetPreDownloader = WorkflowAssetPreDownloader( - paywallComponentsImagePreDownloader = paywallComponentsImagePreDownloader, - offeringFontPreDownloader = offeringFontPreDownloader, - ), - workflowsCache = it, - prefetchDispatcher = Dispatcher( - createConcurrentExecutor(), - runningIntegrationTests = runningIntegrationTests, - ), - ) + // Workflows are served from the `/v1/config` layer: WorkflowManager stays the consumer-facing seam, + // but behind it sit the RemoteConfig stack (sync + blob store + on-demand fetch) and the + // WorkflowsConfigProvider. Lifecycle (foreground refresh, identity clearCache, teardown) is driven + // through remoteConfigManager, which the orchestrator and IdentityManager already own. + val workflowManager = if (appConfig.useWorkflows && remoteConfigManager != null) { + WorkflowManager(WorkflowsConfigProvider(remoteConfigManager)) + } else { + null } val offeringsManager = OfferingsManager( @@ -422,7 +398,6 @@ internal class PurchasesFactory( diagnosticsTracker, offeringFontPreDownloader = offeringFontPreDownloader, uiPreviewMode = appConfig.uiPreviewMode, - workflowManager = workflowManager, ) log(LogIntent.DEBUG) { ConfigureStrings.DEBUG_ENABLED } @@ -574,12 +549,6 @@ internal class PurchasesFactory( return Executors.newSingleThreadScheduledExecutor(LowPriorityThreadFactory("revenuecat-events-thread")) } - // Bounded pool for backend calls that are issued many-at-a-time (workflow detail prefetch), so - // they run concurrently instead of serializing on the single-threaded default executor. - private fun createConcurrentExecutor(): ExecutorService { - return Executors.newScheduledThreadPool(CONCURRENT_BACKEND_CALLS) - } - private class LowPriorityThreadFactory(private val threadName: String) : ThreadFactory { override fun newThread(r: Runnable?): Thread { val wrapperRunnable = Runnable { @@ -593,12 +562,6 @@ internal class PurchasesFactory( } companion object { - private const val CONCURRENT_BACKEND_CALLS = 4 - - // Caps concurrent workflow CDN downloads on the dedicated workflows FileRepository scope, the - // same way CONCURRENT_BACKEND_CALLS caps concurrent workflow detail fetches on prefetchDispatcher. - private const val MAX_CONCURRENT_WORKFLOW_CDN_FETCHES = 4 - @VisibleForTesting internal fun shouldInitializeDiagnostics( diagnosticsEnabled: Boolean, diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt index 9aef88fc8f..19e0e9ecef 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt @@ -581,10 +581,9 @@ internal class PurchasesOrchestrator( ) { // Deliver every outcome through dispatch so the callback always lands on the main thread, // matching the rest of the SDK's callback APIs (e.g. getOfferings, getCustomerInfo). - // WorkflowManager.getWorkflow intentionally has no fixed delivery thread — a cache hit calls - // back synchronously on the caller's thread while a miss resolves on its IO scope, and the - // prefetch path routes detail callbacks onto a dedicated dispatcher — so normalizing here, at - // the consumer boundary, is what gives callers (including awaitGetWorkflow) a stable thread. + // WorkflowManager.getWorkflow intentionally has no fixed delivery thread — it calls back on its + // IO scope — so normalizing here, at the consumer boundary, is what gives callers (including + // awaitGetWorkflow) a stable thread. if (appConfig.uiPreviewMode) { dispatch { onError( @@ -608,15 +607,13 @@ internal class PurchasesOrchestrator( return } workflowManager.getWorkflow( - appUserID = identityManager.currentAppUserID, workflowOrOfferingId = workflowId, - appInBackground = state.appInBackground, onSuccess = { dispatch { onSuccess(it) } }, onError = { dispatch { onError(it) } }, ) } - fun workflowIdForOfferingId(offeringId: String): String? = + suspend fun workflowIdForOfferingId(offeringId: String): String? = workflowManager?.workflowIdForOfferingId(offeringId) fun getProducts( diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/offerings/OfferingsManager.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/offerings/OfferingsManager.kt index 1f0f47509c..8e762702d0 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/offerings/OfferingsManager.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/offerings/OfferingsManager.kt @@ -17,7 +17,6 @@ import com.revenuecat.purchases.common.diagnostics.DiagnosticsTracker import com.revenuecat.purchases.common.errorLog import com.revenuecat.purchases.common.log import com.revenuecat.purchases.common.warnLog -import com.revenuecat.purchases.common.workflows.WorkflowManager import com.revenuecat.purchases.paywalls.OfferingFontPreDownloader import com.revenuecat.purchases.strings.OfferingStrings import com.revenuecat.purchases.utils.OfferingImagePreDownloader @@ -39,7 +38,6 @@ internal class OfferingsManager( private val dateProvider: DateProvider = DefaultDateProvider(), // This is nullable due to: https://github.com/RevenueCat/purchases-flutter/issues/408 private val mainHandler: Handler? = Handler(Looper.getMainLooper()), - private val workflowManager: WorkflowManager? = null, ) { private val emptyOfferings: Offerings = Offerings(current = null, all = emptyMap()) @@ -157,9 +155,7 @@ internal class OfferingsManager( null, null, ) - val dispatchSuccess = { dispatch { onSuccess?.invoke(cachedOfferings) } } - workflowManager?.getWorkflowsList(appUserID, appInBackground, onComplete = dispatchSuccess) - ?: dispatchSuccess() + dispatch { onSuccess?.invoke(cachedOfferings) } if (isCacheStale) { log(LogIntent.DEBUG) { if (appInBackground) { @@ -200,8 +196,6 @@ internal class OfferingsManager( appInBackground, { body, originalDataSource -> createAndCacheOfferings( - appUserID = appUserID, - appInBackground = appInBackground, offeringsJSON = body, originalDataSource = originalDataSource, loadedFromDiskCache = false, @@ -228,8 +222,6 @@ internal class OfferingsManager( } } ?: HTTPResponseOriginalSource.MAIN createAndCacheOfferings( - appUserID = appUserID, - appInBackground = appInBackground, offeringsJSON = cachedOfferingsResponse, originalDataSource = originalDataSource, loadedFromDiskCache = true, @@ -247,8 +239,6 @@ internal class OfferingsManager( } private fun createAndCacheOfferings( - appUserID: String, - appInBackground: Boolean, offeringsJSON: JSONObject, originalDataSource: HTTPResponseOriginalSource, loadedFromDiskCache: Boolean, @@ -268,14 +258,7 @@ internal class OfferingsManager( } offeringFontPreDownloader.preDownloadOfferingFontsIfNeeded(offeringsResultData.offerings) offeringsCache.cacheOfferings(offeringsResultData.offerings, offeringsJSON) - val dispatchSuccess = { dispatch { onSuccess?.invoke(offeringsResultData) } } - workflowManager?.getWorkflowsList( - appUserID, - appInBackground, - // Refetch workflows only when these offerings are fresh from the network. - forceRefresh = !loadedFromDiskCache, - onComplete = dispatchSuccess, - ) ?: dispatchSuccess() + dispatch { onSuccess?.invoke(offeringsResultData) } }, ) } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/UiConfigProvider.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/UiConfigProvider.kt new file mode 100644 index 0000000000..2899d611da --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/remoteconfig/UiConfigProvider.kt @@ -0,0 +1,30 @@ +package com.revenuecat.purchases.common.remoteconfig + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.JsonTools +import com.revenuecat.purchases.UiConfig +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement + +/** + * The topic-specific front door for `ui_config`: three independently-updated parts — `app`, `localizations`, + * `variable_config` — that together deserialize as one [UiConfig], the same shape the legacy offerings response + * sends pre-assembled in a single JSON object. Each part is inline item metadata (no blob), so it's read straight + * off the topic's item index. A part with no body (topic absent or item absent) is simply omitted, so [UiConfig]'s + * own field defaults fill the gap. + */ +@OptIn(InternalRevenueCatAPI::class) +internal class UiConfigProvider( + private val manager: RemoteConfigManager, +) { + + suspend fun getUiConfig(): UiConfig { + val topic = manager.topic(RemoteConfigTopic.UiConfig) + val parts = PART_KEYS.mapNotNull { key -> topic?.get(key)?.let { key to it.metadata } }.toMap() + return JsonTools.json.decodeFromJsonElement(JsonObject(parts)) + } + + private companion object { + private val PART_KEYS = listOf("app", "localizations", "variable_config") + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowManager.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowManager.kt index d681a09178..5b071a630b 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowManager.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowManager.kt @@ -4,477 +4,64 @@ package com.revenuecat.purchases.common.workflows import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.PurchasesError -import com.revenuecat.purchases.common.Backend -import com.revenuecat.purchases.common.Dispatcher -import com.revenuecat.purchases.common.GetWorkflowsErrorHandlingBehavior -import com.revenuecat.purchases.common.errorLog -import com.revenuecat.purchases.common.safeResume -import com.revenuecat.purchases.common.toPurchasesError -import com.revenuecat.purchases.common.warnLog -import com.revenuecat.purchases.utils.WorkflowAssetPreDownloader -import kotlinx.coroutines.CancellationException +import com.revenuecat.purchases.PurchasesErrorCode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -@Suppress("TooManyFunctions") +/** + * The consumer-facing entry point for reading workflows. It stays as the seam the SDK calls (so + * `PurchasesOrchestrator` and the public API are unchanged), but it is now a thin adapter that reads from the + * `/v1/config` layer through [WorkflowsConfigProvider] instead of calling the backend per workflow. + * + * Everything the old backend-backed path owned is gone: there is no per-workflow `Backend.getWorkflow` call, no + * CDN envelope resolution, no `WorkflowsCache`, no stale-while-revalidate, and no disk-fallback. Freshness comes + * from the shared config sync ([com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager]); bodies are + * read — or downloaded on demand and deduped — by that manager. Prefetch is no longer driven from here either: + * the sync prefetches the blobs the `workflows` topic marks, so offerings no longer gate on a workflows-list + * fetch. + * + * `appUserID` has dropped out of the read entirely: a workflow body is a shared, content-addressed blob, not a + * per-user document. (Per-user data — A/B `enrolled_variants` — is being designed separately.) + */ internal class WorkflowManager( - private val backend: Backend, - private val workflowDetailResolver: WorkflowDetailResolver, - private val workflowAssetPreDownloader: WorkflowAssetPreDownloader, - private val workflowsCache: WorkflowsCache, - // Detail fetches in the prefetch path run here so they fan out instead of serializing on the - // backend's single-threaded dispatcher. On-demand fetches use the default dispatcher. - private val prefetchDispatcher: Dispatcher, + private val workflowsConfigProvider: WorkflowsConfigProvider, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), ) { - // Tracks in-flight workflows-list fetches per appUserID so concurrent callers for the same user - // join the running request, while a different user starts its own. - private val callbackLock = Any() - private val pendingCompletionCallbacks = mutableMapOf Unit>>() - fun close() { scope.cancel() - prefetchDispatcher.close() } /** - * Fetches and resolves a single workflow using stale-while-revalidate, mirroring - * [com.revenuecat.purchases.common.offerings.OfferingsManager]'s offerings serving: - * a fresh cache hit is served directly; a stale-but-present hit is served immediately and the - * cache is refreshed in the background (no callbacks, failures logged); a miss fetches from the - * backend and delivers the outcome through [onSuccess]/[onError]. - * - * @param staleWhileRevalidate when true (the default, used by the on-demand render path), a - * stale-but-present cached workflow is served immediately with a background refresh. When false - * (the prefetch path), a stale workflow blocks on a full refetch instead — so prefetch keeps - * forcing a fresh fetch and persisting its envelope rather than serving a stale value. - * @param persistEnvelopeOnResolve when true, also persists the raw detail envelope to disk after - * a successful resolve, so it survives an app restart and can be re-resolved while the backend is - * down (see [getWorkflowsList]'s recovery path). Only the prefetch path sets this: prefetched - * workflows are the curated, bounded set the backend marked as mattering, so persisting all of - * them is safe. On-demand fetches leave it false to avoid unbounded disk growth (a user can open - * many distinct paywalls in a session), so persisting those behind an LRU cap is a planned - * follow-up rather than part of this path. + * Resolves a workflow by workflow id or offering id and delivers it through [onSuccess], or an error through + * [onError] when it cannot be served. Callbacks fire on [scope]; the consumer boundary + * (`PurchasesOrchestrator`) normalizes delivery to the main thread, as it always has. */ - @Suppress("LongParameterList") fun getWorkflow( - appUserID: String, workflowOrOfferingId: String, - appInBackground: Boolean, onSuccess: (WorkflowDataResult) -> Unit, onError: (PurchasesError) -> Unit, - callbackDispatcher: Dispatcher? = null, - persistEnvelopeOnResolve: Boolean = false, - staleWhileRevalidate: Boolean = true, - recordOfferingIdMappingOnResolve: Boolean = true, ) { - // The identifier may be a workflow ID (prefetch, or a repeat render once the mapping is known) - // or an offering ID (the on-demand render path asks by offering ID, and the backend lazily - // converts it). Resolve it through the discovered offeringId → workflowId map to the canonical - // key the cache is keyed by, so an ask by offering ID hits the entry cached under the real - // workflow ID. An as-yet-unconverted offering ID has no mapping and passes through unchanged; - // fetchAndCacheWorkflow learns its real ID from the response and records the mapping. - val mappedWorkflowId = workflowsCache.workflowIdForOfferingId(workflowOrOfferingId) - val workflowId = mappedWorkflowId ?: workflowOrOfferingId - val offeringIdToRecordOnResolve = if (recordOfferingIdMappingOnResolve && mappedWorkflowId == null) { - workflowOrOfferingId - } else { - null - } - val cached = workflowsCache.cachedWorkflow(workflowId) - when { - cached != null && !workflowsCache.isWorkflowCacheStale(workflowId, appInBackground) -> { - onSuccess(cached) - } - cached != null && staleWhileRevalidate -> { - // Serve the stale value immediately, then refresh the cache in the background. - // The caller already has a usable value, so the refresh delivers no callbacks: a - // success updates the cache, and a failure goes through the same disk fallback as any - // other fetch (see fetchAndCacheWorkflow) — on a backend-down error with a persisted - // envelope it re-pins from disk and re-stamps fresh, matching OfferingsManager. - // Known gap vs offerings: offerings persists every fetch, so its disk copy is always - // the latest; the detail path persists prefetched workflows only, so a background - // refresh can re-pin an older prefetched envelope over a newer on-demand value and - // suppress the retry for a TTL. Bounded and self-healing; the on-demand envelope - // persistence + LRU follow-up closes it. Concurrent stale callers can each fire a - // refresh; this is not deduplicated, matching the offerings stale path. - onSuccess(cached) - fetchAndCacheWorkflow( - appUserID = appUserID, - workflowId = workflowId, - appInBackground = appInBackground, - callbackDispatcher = callbackDispatcher, - persistEnvelopeOnResolve = persistEnvelopeOnResolve, - offeringIdToRecordOnResolve = offeringIdToRecordOnResolve, - onSuccess = {}, - onError = { error -> - errorLog { - "Background workflow refresh failed for $workflowId: " + - error.underlyingErrorMessage - } - }, - ) - } - else -> { - // Miss, or stale with SWR disabled (the prefetch path): block on the fetch. - fetchAndCacheWorkflow( - appUserID = appUserID, - workflowId = workflowId, - appInBackground = appInBackground, - callbackDispatcher = callbackDispatcher, - persistEnvelopeOnResolve = persistEnvelopeOnResolve, - offeringIdToRecordOnResolve = offeringIdToRecordOnResolve, - onSuccess = onSuccess, - onError = onError, + scope.launch { + // An offering id resolves to its workflow id up front from persisted config metadata; a workflow id + // passes through. No backend round-trip, no lazy offering→workflow conversion. + val workflowId = workflowsConfigProvider.workflowIdForOfferingId(workflowOrOfferingId) + ?: workflowOrOfferingId + when (val result = workflowsConfigProvider.getWorkflow(workflowId)) { + null -> onError( + PurchasesError( + PurchasesErrorCode.UnknownError, + "Workflow '$workflowId' is unavailable from remote config.", + ), ) + else -> onSuccess(result) } } } - @Suppress("LongParameterList") - private fun fetchAndCacheWorkflow( - appUserID: String, - workflowId: String, - appInBackground: Boolean, - callbackDispatcher: Dispatcher?, - persistEnvelopeOnResolve: Boolean, - offeringIdToRecordOnResolve: String?, - onSuccess: (WorkflowDataResult) -> Unit, - onError: (PurchasesError) -> Unit, - ) { - val onSuccessHandler: (WorkflowDetailResponse) -> Unit = { response -> - scope.launch { - // resolve() can fail in several ways (missing inline data, CDN fetch/parse failures, - // signature verification), so surface any of them through onError. Cancellation is not - // a failure: let it propagate so the coroutine unwinds normally. - val result = try { - workflowDetailResolver.resolve(response) - } catch (e: CancellationException) { - throw e - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - onError(e.toPurchasesError()) - return@launch - } - cacheResolvedWorkflow(result, response, persistEnvelopeOnResolve, offeringIdToRecordOnResolve) - scope.launch { - runCatching { workflowAssetPreDownloader.preDownloadWorkflowAssets(result.workflow) } - .onFailure { errorLog(it) { "Failed to pre-download workflow assets" } } - } - onSuccess(result) - } - } - val onErrorWithFallback: (PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit = - { error, behavior -> - if (behavior == GetWorkflowsErrorHandlingBehavior.SHOULD_NOT_FALLBACK) { - // 4xx: the server intentionally changed/removed this workflow, so don't serve the - // saved copy. Invalidate the in-memory entry so the next call retries rather than - // serving a still-cached value — mirrors the list's invalidateWorkflowsListTimestamp - // and OfferingsManager's forceCacheStale on 4xx. - workflowsCache.invalidateWorkflowTimestamp(workflowId) - onError(error) - } else { - // Transport error / 5xx / malformed body: the backend is unavailable, not refusing. - // Recover from the persisted envelope if we have one, matching OfferingsManager's - // disk fallback. Applies to every caller, including the SWR background refresh. - scope.launch { resolveDiskFallback(workflowId, error, onSuccess, onError) } - } - } - if (callbackDispatcher != null) { - backend.getWorkflow( - appUserID = appUserID, - workflowId = workflowId, - appInBackground = appInBackground, - callbackDispatcher = callbackDispatcher, - onSuccess = onSuccessHandler, - onError = onErrorWithFallback, - ) - } else { - backend.getWorkflow( - appUserID = appUserID, - workflowId = workflowId, - appInBackground = appInBackground, - onSuccess = onSuccessHandler, - onError = onErrorWithFallback, - ) - } - } - - /** - * Caches [result] under the resolved workflow's own ID — the canonical key every later lookup - * uses. On the lazy-conversion path the backend is called with an offering ID, so recording the - * discovered offeringId → workflowId mapping makes the next lookup resolve to this entry instead - * of missing. Callers that already have a canonical workflow ID pass null for - * [offeringIdToRecordOnResolve], so mismatched resolved IDs cannot create bogus workflowId → - * workflowId map entries. A workflow with no ID can't be keyed (looked up, invalidated, or mapped), - * so a malformed response is delivered for the current render but not cached under a wrong key — - * the next render refetches. - */ - private fun cacheResolvedWorkflow( - result: WorkflowDataResult, - response: WorkflowDetailResponse, - persistEnvelopeOnResolve: Boolean, - offeringIdToRecordOnResolve: String?, - ) { - val resolvedWorkflowId = result.workflow.id - if (resolvedWorkflowId.isEmpty()) { - errorLog { "Workflow resolved with an empty id; serving it without caching." } - return - } - workflowsCache.cacheWorkflow(resolvedWorkflowId, result) - if (offeringIdToRecordOnResolve != null && resolvedWorkflowId != offeringIdToRecordOnResolve) { - workflowsCache.recordWorkflowIdForOfferingId( - offeringId = offeringIdToRecordOnResolve, - workflowId = resolvedWorkflowId, - ) - } - if (persistEnvelopeOnResolve) { - runCatching { workflowsCache.cacheWorkflowDetailEnvelope(resolvedWorkflowId, response) } - .onFailure { errorLog(it) { "Failed to persist workflow detail envelope for $resolvedWorkflowId" } } - } - } - - /** - * Attempts to re-resolve the persisted disk envelope for [workflowId] after a - * fallback-eligible backend error. If no envelope is present, or if re-resolution fails, - * the in-memory entry is invalidated and the original [networkError] is forwarded through - * [onError] so the caller is never left without a response and the next call retries instead of - * serving a stale value — mirroring how the list and offerings invalidate when a fallback - * yields nothing fresh. On success the result is cached under its resolved workflow id (re-stamped - * fresh, no envelope re-persist since it came from disk) and delivered via [onSuccess]. - */ - private suspend fun resolveDiskFallback( - workflowId: String, - networkError: PurchasesError, - onSuccess: (WorkflowDataResult) -> Unit, - onError: (PurchasesError) -> Unit, - ) { - val envelope = workflowsCache.cachedWorkflowDetailEnvelopeFromDisk(workflowId) - if (envelope == null) { - workflowsCache.invalidateWorkflowTimestamp(workflowId) - onError(networkError) - return - } - val result = try { - workflowDetailResolver.resolve(envelope) - } catch (e: CancellationException) { - throw e - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - errorLog(e) { "Failed to re-resolve disk envelope for $workflowId during fallback" } - workflowsCache.invalidateWorkflowTimestamp(workflowId) - onError(networkError) - return - } - cacheResolvedWorkflow( - result = result, - response = envelope, - persistEnvelopeOnResolve = false, - offeringIdToRecordOnResolve = null, - ) - onSuccess(result) - } - - /** - * Fetches the workflows list, then prefetches all entries marked `prefetch = true`. - * - * [forceRefresh] fetches a fresh list even when the cached one is still within its TTL. It is set - * after a fresh offerings network response so the offeringId → workflowId map realigns with the - * offerings the caller just received, since the workflows list otherwise tracks only a time TTL. - * - * Offerings delivery is gated on [onComplete] (see - * [com.revenuecat.purchases.common.offerings.OfferingsManager]): it is invoked once the whole - * sequence settles — list fetched, every prefetch finished or failed — so it must always fire - * exactly once, otherwise offerings would never be delivered to the caller. - * - * Concurrent callers for the same [appUserID] while a request is in-flight are deduplicated: the - * second call queues its [onComplete] and returns without a new network request, then all pending - * callbacks for that user fire together when the in-flight sequence finishes. A call for a - * different user starts its own fetch rather than joining the in-flight one. Note this join - * ignores [forceRefresh]: an in-flight batch is not interrupted, so a forced refresh that arrives - * while a batch is running joins it instead of starting a new fetch. That window self-heals on the - * next fetch. - */ - fun getWorkflowsList( - appUserID: String, - appInBackground: Boolean, - forceRefresh: Boolean = false, - onComplete: () -> Unit = {}, - ) { - // Decide under the lock, act outside it so onComplete never fires while holding it. - val startFetch = synchronized(callbackLock) { - val inFlight = pendingCompletionCallbacks[appUserID] - if (inFlight != null) { - // Already fetching for this user: queue onto it. Joining ignores cache freshness and - // forceRefresh on purpose — the list response stamps the cache fresh mid-sequence, - // before its prefetch finishes, so a freshness check would let this caller complete - // early, and an in-flight batch is never interrupted. - inFlight.add(onComplete) - return - } - // Nothing in flight: open a batch and fetch when forced or when the cached list is stale. - pendingCompletionCallbacks[appUserID] = mutableListOf(onComplete) - forceRefresh || workflowsCache.isWorkflowsListCacheStale(appInBackground) - } - - // Fresh cache and nothing in flight: complete now. Otherwise the request's onSuccess/onError - // fire the queued callbacks when it settles. - if (!startFetch) { - completePendingCallbacks(appUserID) - } else { - backend.getWorkflows( - appUserID = appUserID, - appInBackground = appInBackground, - type = "paywall", - onSuccess = { response -> - // Drop workflows without an offeringId: they can't be reached via - // workflowIdForOfferingId, so caching or prefetching them is wasted work. - val filtered = response.onlyWorkflowsWithOfferingId() - // Clear detail caches after a successful fetch so the prefetch loop below is a - // guaranteed cache miss and always populates fresh data. Cleared here rather than - // before the network call so a failed fetch leaves in-memory details intact. - if (forceRefresh) workflowsCache.clearWorkflowDetailCaches() - workflowsCache.cacheWorkflowsList(filtered, buildOfferingIdMap(filtered.workflows)) - - val prefetchWorkflows = filtered.workflows.filter { it.prefetch } - scope.launch { - // Wait for all prefetches before completing so onComplete fires once the whole - // sequence settles. A failed prefetch is logged and does not fail the others. - // Concurrency is bounded downstream, not here: detail fetches by prefetchDispatcher's - // thread pool, CDN downloads by the workflows FileRepository's limited scope. - coroutineScope { - prefetchWorkflows.forEach { summary -> - launch { - prefetchWorkflow(appUserID, summary.id, appInBackground) - } - } - } - completePendingCallbacks(appUserID) - } - }, - onError = { error, behavior -> - errorLog { "Failed to fetch workflows list: ${error.underlyingErrorMessage}" } - if (behavior == GetWorkflowsErrorHandlingBehavior.SHOULD_NOT_FALLBACK) { - // A 4xx means the server intentionally changed/removed these workflows. Don't - // resurrect a stale list from disk; just settle the callbacks so offerings - // delivery isn't stranded. Invalidate the timestamp so the next non-forced call - // retries rather than serving a still-fresh in-memory list — mirrors - // OfferingsManager.handleErrorFetchingOfferings calling forceCacheStale(). - workflowsCache.invalidateWorkflowsListTimestamp() - completePendingCallbacks(appUserID) - } else { - restoreWorkflowsListFromDisk(appUserID) - } - }, - ) - } - } - - /** - * Restores the workflows list and persisted detail envelopes from disk after a fallback-eligible - * fetch failure (transport error, 5xx, or malformed body), then settles pending callbacks. The - * in-memory cache is rewritten from disk without re-persisting it — disk already holds this payload. - * [completePendingCallbacks] always fires exactly once. - */ - private fun restoreWorkflowsListFromDisk(appUserID: String) { - val restoredFromDisk = workflowsCache.cachedWorkflowsListResponseFromDisk()?.let { response -> - val filtered = response.onlyWorkflowsWithOfferingId() - workflowsCache.cacheWorkflowsListInMemory(filtered, buildOfferingIdMap(filtered.workflows)) - } != null - // Mirror OfferingsManager.handleErrorFetchingOfferings: when there is no disk cache to fall - // back on, force the list stale so the next call retries. When a disk restore did succeed, - // cacheWorkflowsListInMemory already stamped a fresh timestamp, so we leave it alone — same as - // offerings leaving the cache fresh after createAndCacheOfferings runs on the disk-fallback path. - if (!restoredFromDisk) { - workflowsCache.invalidateWorkflowsListTimestamp() - } - val envelopes = workflowsCache.cachedWorkflowDetailEnvelopesFromDisk().orEmpty() - if (envelopes.isEmpty()) { - completePendingCallbacks(appUserID) - } else { - scope.launch { - // Re-resolve persisted envelopes into the in-memory cache, mirroring the success-path - // prefetch loop. A failed re-resolve is logged and does not fail its siblings. - // completePendingCallbacks still fires exactly once. - coroutineScope { - envelopes.forEach { (workflowId, envelope) -> - launch { restoreWorkflowFromEnvelope(workflowId, envelope) } - } - } - completePendingCallbacks(appUserID) - } - } - } - - /** - * Suspends until [getWorkflow] for [workflowId] resolves. Prefetch is best-effort, so a failure - * is logged and the coroutine resumes normally instead of throwing, keeping one failed prefetch - * from cancelling its siblings in the surrounding [coroutineScope]. - */ - private suspend fun prefetchWorkflow(appUserID: String, workflowId: String, appInBackground: Boolean) { - suspendCancellableCoroutine { continuation -> - getWorkflow( - appUserID = appUserID, - workflowOrOfferingId = workflowId, - appInBackground = appInBackground, - callbackDispatcher = prefetchDispatcher, - persistEnvelopeOnResolve = true, - staleWhileRevalidate = false, - recordOfferingIdMappingOnResolve = false, - onSuccess = { continuation.safeResume(Unit) }, - onError = { error -> - errorLog { "Failed to prefetch workflow $workflowId: ${error.underlyingErrorMessage}" } - continuation.safeResume(Unit) - }, - ) - } - } - - /** - * Re-resolves a persisted [envelope] into the in-memory cache during backend-down recovery. For - * USE_CDN this avoids a backend call: when the CDN file is still cached locally (it was - * pre-downloaded during the original prefetch) re-resolution needs no network. If that file was - * evicted, resolution may need the CDN and can fail while the backend is down; the failure is - * logged and swallowed so one bad envelope doesn't fail its siblings in the surrounding [coroutineScope]. - */ - private suspend fun restoreWorkflowFromEnvelope(workflowId: String, envelope: WorkflowDetailResponse) { - val result = try { - workflowDetailResolver.resolve(envelope) - } catch (e: CancellationException) { - throw e - } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { - errorLog(e) { "Failed to restore workflow $workflowId from disk cache" } - return - } - workflowsCache.cacheWorkflow(workflowId, result) - } - - fun workflowIdForOfferingId(offeringId: String): String? = - workflowsCache.workflowIdForOfferingId(offeringId) - - private fun completePendingCallbacks(appUserID: String) { - val callbacks = synchronized(callbackLock) { - pendingCompletionCallbacks.remove(appUserID).orEmpty() - } - callbacks.forEach { it() } - } - - private fun WorkflowsListResponse.onlyWorkflowsWithOfferingId(): WorkflowsListResponse = - copy(workflows = workflows.filter { it.offeringId != null }) - - private fun buildOfferingIdMap(workflows: List): Map { - val pairs = workflows.mapNotNull { summary -> summary.offeringId?.let { it to summary.id } } - return pairs - .groupBy { it.first } - .also { grouped -> - grouped.filter { it.value.size > 1 }.keys.forEach { duplicateOfferingId -> - warnLog { "Duplicate offeringId in workflows response: $duplicateOfferingId" } - } - } - .mapValues { it.value.last().second } - } + suspend fun workflowIdForOfferingId(offeringId: String): String? = + workflowsConfigProvider.workflowIdForOfferingId(offeringId) } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt index c1d932eaed..ff14ac3523 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt @@ -141,7 +141,9 @@ public data class PublishedWorkflow( @SerialName("initial_step_id") val initialStepId: String, val steps: Map, val screens: Map, - @SerialName("ui_config") val uiConfig: UiConfig, + // Not sent by the config-topic path (ui_config is its own topic); defaults rather than failing the whole + // workflow, and the reader swaps the resolved UiConfig in. + @SerialName("ui_config") val uiConfig: UiConfig = UiConfig(), @Serializable(with = JsonObjectToMapSerializer::class) val metadata: Map = emptyMap(), val hash: String? = null, diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowsConfigProvider.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowsConfigProvider.kt new file mode 100644 index 0000000000..f6363009ac --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowsConfigProvider.kt @@ -0,0 +1,73 @@ +@file:OptIn(InternalRevenueCatAPI::class) + +package com.revenuecat.purchases.common.workflows + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.common.debugLog +import com.revenuecat.purchases.common.errorLog +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigTopic +import com.revenuecat.purchases.common.remoteconfig.UiConfigProvider +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * The topic-specific front door for workflows — the config-endpoint replacement for `WorkflowManager`'s read + * path. It knows only the `workflows` topic, that an item's `offering_identifier` lives in its inline + * content, and how to parse a [PublishedWorkflow]. Everything else — reading metadata, waiting on an in-flight + * sync, resolving inline vs `blob_ref`, downloading and reading the body — it delegates to [RemoteConfigManager] + * through `topic()` / `blobData()`. It never sees the blob store, the fetcher, or the disk cache. + */ +internal class WorkflowsConfigProvider( + private val manager: RemoteConfigManager, + private val uiConfigProvider: UiConfigProvider = UiConfigProvider(manager), +) { + + suspend fun workflowIdForOfferingId(offeringId: String): String? { + val topic = manager.topic(RemoteConfigTopic.Workflows) + debugLog { "workflows topic ${if (topic == null) "is absent" else "has ${topic.size} item(s)"}" } + val workflowId = topic + ?.entries + ?.firstOrNull { (_, item) -> item.metadata.stringOrNull(KEY_OFFERING_IDENTIFIER) == offeringId } + ?.key + debugLog { + if (workflowId != null) { + "Resolved offering '$offeringId' to workflow '$workflowId'" + } else { + "No workflow found for offering '$offeringId'" + } + } + return workflowId + } + + /** + * Resolves [workflowId] into a [WorkflowDataResult], or `null` when the item is unknown, its body can be + * neither read nor downloaded, or the body fails to parse. + */ + suspend fun getWorkflow(workflowId: String): WorkflowDataResult? { + val body = manager.blobData(RemoteConfigTopic.Workflows, workflowId) { it } ?: run { + errorLog { "Workflow '$workflowId' is unavailable from remote config." } + return null + } + return try { + // ui_config is its own topic (WFL-374), no longer embedded in the workflow body; the parsed + // uiConfig defaults to an empty UiConfig() until it's resolved and swapped in here. + val workflow = WorkflowJsonParser.parsePublishedWorkflow(body.decodeToString()) + .copy(uiConfig = uiConfigProvider.getUiConfig()) + debugLog { "Parsed workflow '$workflowId' (${workflow.steps.size} step(s))" } + // enrolled_variants is out of scope for this spike; it does not fit the topic-dedup model and is + // being designed separately. + WorkflowDataResult(workflow = workflow, enrolledVariants = null) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + errorLog(e) { "Failed to parse workflow '$workflowId' body." } + null + } + } + + private companion object { + private const val KEY_OFFERING_IDENTIFIER = "offering_identifier" + + private fun JsonObject.stringOrNull(key: String): String? = + (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt index 602b35b2c8..72d009d0cb 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt @@ -2841,12 +2841,9 @@ internal class PurchasesCommonTest: BasePurchasesTest() { val successSlot = slot<(WorkflowDataResult) -> Unit>() every { mockWorkflowManager.getWorkflow( - appUserID = appUserId, workflowOrOfferingId = "wf_1", - appInBackground = any(), onSuccess = capture(successSlot), onError = any(), - callbackDispatcher = any(), ) } answers { successSlot.captured(expected) } @@ -2869,12 +2866,9 @@ internal class PurchasesCommonTest: BasePurchasesTest() { val errorSlot = slot<(PurchasesError) -> Unit>() every { mockWorkflowManager.getWorkflow( - appUserID = appUserId, workflowOrOfferingId = "wf_1", - appInBackground = any(), onSuccess = any(), onError = capture(errorSlot), - callbackDispatcher = any(), ) } answers { errorSlot.captured(expectedError) } @@ -2907,7 +2901,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { assertThat(receivedError!!.code).isEqualTo(PurchasesErrorCode.ConfigurationError) assertThat(received).isNull() verify(exactly = 0) { - mockWorkflowManager.getWorkflow(any(), any(), any(), any(), any(), any()) + mockWorkflowManager.getWorkflow(any(), any(), any()) } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsManagerTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsManagerTest.kt index cae876b514..d10d2318fe 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsManagerTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/offerings/OfferingsManagerTest.kt @@ -9,7 +9,6 @@ import com.revenuecat.purchases.common.Backend import com.revenuecat.purchases.common.GetOfferingsErrorHandlingBehavior import com.revenuecat.purchases.common.HTTPResponseOriginalSource import com.revenuecat.purchases.common.diagnostics.DiagnosticsTracker -import com.revenuecat.purchases.common.workflows.WorkflowManager import com.revenuecat.purchases.paywalls.OfferingFontPreDownloader import com.revenuecat.purchases.utils.ONE_OFFERINGS_RESPONSE import com.revenuecat.purchases.utils.OfferingImagePreDownloader @@ -21,7 +20,6 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs -import io.mockk.slot import io.mockk.verify import org.assertj.core.api.Assertions.assertThat import org.assertj.core.api.Assertions.fail @@ -47,7 +45,6 @@ class OfferingsManagerTest { private lateinit var offeringImagePreDownloader: OfferingImagePreDownloader private lateinit var mockDiagnosticsTracker: DiagnosticsTracker private lateinit var mockOfferingFontPreDownloader: OfferingFontPreDownloader - private lateinit var mockWorkflowManager: WorkflowManager private lateinit var offeringsManager: OfferingsManager @@ -63,10 +60,6 @@ class OfferingsManagerTest { mockOfferingFontPreDownloader = mockk().apply { every { preDownloadOfferingFontsIfNeeded(any()) } just Runs } - mockWorkflowManager = mockk(relaxed = true) - every { mockWorkflowManager.getWorkflowsList(any(), any(), any(), any()) } answers { - lastArg<() -> Unit>().invoke() - } mockBackendResponseSuccess() mockDiagnosticsTracker() @@ -78,7 +71,6 @@ class OfferingsManagerTest { offeringImagePreDownloader = offeringImagePreDownloader, diagnosticsTrackerIfEnabled = mockDiagnosticsTracker, offeringFontPreDownloader = mockOfferingFontPreDownloader, - workflowManager = mockWorkflowManager, ) } @@ -121,23 +113,6 @@ class OfferingsManagerTest { } } - @Test - fun `onAppForeground triggers getWorkflowsList when offerings are stale`() { - mockCacheStale(offeringsStale = true) - mockDeviceCache() - mockOfferingsFactory() - offeringsManager.onAppForeground(appUserID = "user_1") - // Foreground refresh is a network fetch, so it forces the workflows list to refetch. - verify(exactly = 1) { - mockWorkflowManager.getWorkflowsList( - "user_1", - appInBackground = false, - forceRefresh = true, - onComplete = any(), - ) - } - } - // endregion onAppForeground // region getOfferings @@ -445,32 +420,6 @@ class OfferingsManagerTest { verify(exactly = 1) { cache.forceCacheStale() } } - @Test - fun `getOfferings succeeds without NPE when workflowManager is null`() { - val managerWithNoWorkflows = OfferingsManager( - offeringsCache = cache, - backend = backend, - offeringsFactory = offeringsFactory, - offeringImagePreDownloader = offeringImagePreDownloader, - diagnosticsTrackerIfEnabled = mockDiagnosticsTracker, - offeringFontPreDownloader = mockOfferingFontPreDownloader, - workflowManager = null, - ) - every { cache.cachedOfferings } returns null - mockOfferingsFactory() - mockDeviceCache() - - var receivedOfferings: Offerings? = null - managerWithNoWorkflows.getOfferings( - appUserId, - appInBackground = false, - onError = { fail("Expected success but got error: $it") }, - onSuccess = { receivedOfferings = it }, - ) - - assertThat(receivedOfferings).isEqualTo(testOfferings) - } - // This situation shouldn't happen normally since we only cache when we have loaded the offerings at least once, // but it's possible something changed in the store. So better to handle it. @Test @@ -569,85 +518,6 @@ class OfferingsManagerTest { } } - @Test - fun `getOfferings calls getWorkflowsList after offerings are fetched`() { - every { cache.cachedOfferings } returns null - mockOfferingsFactory() - mockDeviceCache() - - offeringsManager.getOfferings( - appUserId, - appInBackground = false, - onError = { fail("should be a success") }, - onSuccess = {}, - ) - - // Offerings came fresh from the network, so the workflows list is forced to refetch (realigned) - // rather than skipped on its own TTL. - verify(exactly = 1) { - mockWorkflowManager.getWorkflowsList(appUserId, false, forceRefresh = true, onComplete = any()) - } - } - - @Test - fun `getOfferings does not force workflows list refetch on disk-cache fallback`() { - every { cache.cachedOfferings } returns null - every { cache.cacheOfferings(any(), any()) } just Runs - mockBackendResponseError() - every { cache.cachedOfferingsResponse } returns JSONObject(ONE_OFFERINGS_RESPONSE) - mockDeviceCache(wasSuccessful = false) - mockOfferingsFactory() - - offeringsManager.getOfferings( - appUserId, - appInBackground = false, - onError = { fail("Should be success") }, - onSuccess = {}, - ) - - // Offerings were restored from disk (no real change, backend likely down), so the workflows - // list is not forced to refetch — it follows its own TTL. - verify(exactly = 1) { - mockWorkflowManager.getWorkflowsList(appUserId, false, forceRefresh = false, onComplete = any()) - } - } - - @Test - fun `getOfferings calls getWorkflowsList with appInBackground true when app is in background`() { - every { cache.cachedOfferings } returns null - mockOfferingsFactory() - mockDeviceCache() - - offeringsManager.getOfferings( - appUserId, - appInBackground = true, - onError = { fail("should be a success") }, - onSuccess = {}, - ) - - verify(exactly = 1) { - mockWorkflowManager.getWorkflowsList(appUserId, true, forceRefresh = true, onComplete = any()) - } - } - - @Test - fun `getOfferings calls getWorkflowsList even when current offering is null`() { - every { cache.cachedOfferings } returns null - mockOfferingsFactory(testOfferings.copy(current = null)) - mockDeviceCache() - - offeringsManager.getOfferings( - appUserId, - appInBackground = false, - onError = { fail("should be a success") }, - onSuccess = {}, - ) - - verify(exactly = 1) { - mockWorkflowManager.getWorkflowsList(appUserId, false, forceRefresh = true, onComplete = any()) - } - } - // endregion pre download offering images // region pre download font files @@ -1060,119 +930,4 @@ class OfferingsManagerTest { all = this.all, ) - // region workflowManager onComplete integration - - @Test - fun `getOfferings does not call onSuccess until getWorkflowsList completes`() { - val mockWorkflowManager = mockk() - val onCompleteSlot = slot<() -> Unit>() - every { - mockWorkflowManager.getWorkflowsList( - appUserID = appUserId, - appInBackground = false, - forceRefresh = any(), - onComplete = capture(onCompleteSlot), - ) - } just Runs - - val managerWithWorkflow = OfferingsManager( - offeringsCache = cache, - backend = backend, - offeringsFactory = offeringsFactory, - offeringImagePreDownloader = offeringImagePreDownloader, - diagnosticsTrackerIfEnabled = mockDiagnosticsTracker, - offeringFontPreDownloader = mockOfferingFontPreDownloader, - workflowManager = mockWorkflowManager, - ) - - every { cache.cachedOfferings } returns null - mockOfferingsFactory() - mockDeviceCache() - - var receivedOfferings: Offerings? = null - managerWithWorkflow.getOfferings( - appUserId, - appInBackground = false, - onError = { fail("should be success") }, - onSuccess = { receivedOfferings = it }, - ) - - assertThat(receivedOfferings).isNull() - - onCompleteSlot.captured.invoke() - - assertThat(receivedOfferings).isNotNull() - } - - @Test - fun `getOfferings calls onSuccess immediately when workflowManager is null`() { - val managerWithoutWorkflow = OfferingsManager( - offeringsCache = cache, - backend = backend, - offeringsFactory = offeringsFactory, - offeringImagePreDownloader = offeringImagePreDownloader, - diagnosticsTrackerIfEnabled = mockDiagnosticsTracker, - offeringFontPreDownloader = mockOfferingFontPreDownloader, - workflowManager = null, - ) - - every { cache.cachedOfferings } returns null - mockOfferingsFactory() - mockDeviceCache() - - var receivedOfferings: Offerings? = null - managerWithoutWorkflow.getOfferings( - appUserId, - appInBackground = false, - onError = { fail("should be success") }, - onSuccess = { receivedOfferings = it }, - ) - - assertThat(receivedOfferings).isNotNull() - } - - @Test - fun `getOfferings from valid cache does not call onSuccess until getWorkflowsList completes`() { - val mockWorkflowManager = mockk() - val onCompleteSlot = slot<() -> Unit>() - every { - mockWorkflowManager.getWorkflowsList( - appUserID = appUserId, - appInBackground = false, - forceRefresh = any(), - onComplete = capture(onCompleteSlot), - ) - } just Runs - - val managerWithWorkflow = OfferingsManager( - offeringsCache = cache, - backend = backend, - offeringsFactory = offeringsFactory, - offeringImagePreDownloader = offeringImagePreDownloader, - diagnosticsTrackerIfEnabled = mockDiagnosticsTracker, - offeringFontPreDownloader = mockOfferingFontPreDownloader, - workflowManager = mockWorkflowManager, - ) - - every { cache.cachedOfferings } returns testOfferings - mockDeviceCache() - mockCacheStale(offeringsStale = false) - - var receivedOfferings: Offerings? = null - managerWithWorkflow.getOfferings( - appUserId, - appInBackground = false, - onError = { fail("should be success") }, - onSuccess = { receivedOfferings = it }, - ) - - // Cache hit still waits for the workflows list so the offeringId map is populated on success. - assertThat(receivedOfferings).isNull() - - onCompleteSlot.captured.invoke() - - assertThat(receivedOfferings).isNotNull() - } - - // endregion workflowManager onComplete integration } diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowManagerTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowManagerTest.kt index 2469830fdf..edc4488866 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowManagerTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowManagerTest.kt @@ -1,2390 +1,78 @@ package com.revenuecat.purchases.common.workflows -import com.revenuecat.purchases.LogHandler -import com.revenuecat.purchases.NoOpLogHandler +import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.PurchasesError -import com.revenuecat.purchases.PurchasesErrorCode -import com.revenuecat.purchases.common.Backend -import com.revenuecat.purchases.common.DateProvider -import com.revenuecat.purchases.common.Dispatcher -import com.revenuecat.purchases.common.GetWorkflowsErrorHandlingBehavior -import com.revenuecat.purchases.common.caching.DeviceCache -import com.revenuecat.purchases.common.currentLogHandler -import com.revenuecat.purchases.utils.WorkflowAssetPreDownloader -import io.mockk.Runs +import com.revenuecat.purchases.UiConfig import io.mockk.coEvery -import io.mockk.coVerify -import io.mockk.every -import io.mockk.just import io.mockk.mockk -import io.mockk.slot -import io.mockk.spyk -import io.mockk.verify -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.serialization.SerializationException +import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.Assertions.fail -import org.junit.After -import org.junit.Before import org.junit.Test -import java.io.IOException -import java.util.Date +/** + * WorkflowManager is now a thin adapter over [WorkflowsConfigProvider]; these cover the adapter contract + * (offering-id resolution, success/error delivery, delegation). The end-to-end config behavior lives in + * [WorkflowsConfigIntegrationTest]. + */ +@OptIn(InternalRevenueCatAPI::class, ExperimentalCoroutinesApi::class) class WorkflowManagerTest { - private val mockBackend: Backend = mockk(relaxed = true) - private val mockResolver: WorkflowDetailResolver = mockk() - private val mockAssetPreDownloader: WorkflowAssetPreDownloader = mockk(relaxed = true) - private val mockDeviceCache: DeviceCache = mockk(relaxed = true) - private val mockDateProvider: DateProvider = mockk() - private val mockPrefetchDispatcher: Dispatcher = mockk(relaxed = true) - private lateinit var workflowsCache: WorkflowsCache - private lateinit var workflowManager: WorkflowManager - private lateinit var originalLogHandler: LogHandler - - @Before - fun setUp() { - originalLogHandler = currentLogHandler - currentLogHandler = NoOpLogHandler - every { mockDateProvider.now } returns Date(0) // ensures cache is always stale by default - workflowsCache = spyk(WorkflowsCache(deviceCache = mockDeviceCache, dateProvider = mockDateProvider)) - workflowManager = WorkflowManager( - backend = mockBackend, - workflowDetailResolver = mockResolver, - workflowAssetPreDownloader = mockAssetPreDownloader, - workflowsCache = workflowsCache, - prefetchDispatcher = mockPrefetchDispatcher, - scope = CoroutineScope(UnconfinedTestDispatcher()), - ) - } - - @After - fun tearDown() { - currentLogHandler = originalLogHandler - } - - // A resolved workflow always carries its own id, and in the non-lazy case it equals the id the - // backend was asked for. The cache keys off that id, so the mock must expose a non-empty one. - private fun workflowMock(id: String = "wf_1"): PublishedWorkflow { - val workflow = mockk(relaxed = true) - every { workflow.id } returns id - return workflow - } + private val provider = mockk() + // Unconfined so the launched read runs eagerly and the callback fires before the call returns. + private val manager = WorkflowManager(provider, CoroutineScope(UnconfinedTestDispatcher())) @Test - fun `getWorkflow resolves inline response into WorkflowResult`() { - val response = WorkflowDetailResponse( - action = WorkflowResponseAction.INLINE, - data = mockk(relaxed = true), - ) - val expectedResult = WorkflowDataResult( - workflow = response.data!!, - enrolledVariants = null, - ) - coEvery { mockResolver.resolve(response) } returns expectedResult - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { - successSlot.captured(response) - } + fun `getWorkflow delivers the resolved workflow on success`() { + coEvery { provider.workflowIdForOfferingId("wf-1") } returns null + coEvery { provider.getWorkflow("wf-1") } returns workflow("wf-1") - var result: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { result = it }, - onError = { fail("unexpected error $it") }, - ) - assertThat(result).isEqualTo(expectedResult) - verify(exactly = 1) { mockAssetPreDownloader.preDownloadWorkflowAssets(expectedResult.workflow) } - } - - @Test - fun `getWorkflow propagates backend errors`() { - val expectedError = PurchasesError(PurchasesErrorCode.NetworkError, "network error") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_missing", - appInBackground = false, - onSuccess = any(), - onError = capture(errorSlot), - ) - } answers { - errorSlot.captured(expectedError, GetWorkflowsErrorHandlingBehavior.SHOULD_NOT_FALLBACK) - } + var delivered: WorkflowDataResult? = null + manager.getWorkflow("wf-1", onSuccess = { delivered = it }, onError = { }) - var error: PurchasesError? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_missing", - appInBackground = false, - onSuccess = { fail("expected error") }, - onError = { error = it }, - ) - assertThat(error).isEqualTo(expectedError) + assertThat(delivered?.workflow?.id).isEqualTo("wf-1") } @Test - fun `getWorkflow calls onError when resolver throws`() { - val response = WorkflowDetailResponse( - action = WorkflowResponseAction.INLINE, - data = null, - ) - coEvery { mockResolver.resolve(response) } throws IllegalStateException("missing data") + fun `getWorkflow resolves an offering id to its workflow id before fetching`() { + coEvery { provider.workflowIdForOfferingId("premium") } returns "wf-9" + coEvery { provider.getWorkflow("wf-9") } returns workflow("wf-9") - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { - successSlot.captured(response) - } + var delivered: WorkflowDataResult? = null + manager.getWorkflow("premium", onSuccess = { delivered = it }, onError = { }) - var error: PurchasesError? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { fail("expected error") }, - onError = { error = it }, - ) - assertThat(error).isNotNull - assertThat(error!!.code).isEqualTo(PurchasesErrorCode.UnknownError) + assertThat(delivered?.workflow?.id).isEqualTo("wf-9") } @Test - fun `getWorkflow calls onError when resolver throws SerializationException`() { - val response = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn.example.com/workflow.json", - ) - coEvery { mockResolver.resolve(response) } throws SerializationException("malformed compiled workflow json") - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { - successSlot.captured(response) - } + fun `getWorkflow calls onError when the workflow is unavailable`() { + coEvery { provider.workflowIdForOfferingId("missing") } returns null + coEvery { provider.getWorkflow("missing") } returns null var error: PurchasesError? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { fail("expected error") }, - onError = { error = it }, - ) - assertThat(error).isNotNull - assertThat(error!!.code).isEqualTo(PurchasesErrorCode.UnknownError) - } - - @Test - fun `getWorkflow does not report cancellation as an error`() { - val response = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn.example.com/workflow.json", - ) - coEvery { mockResolver.resolve(response) } throws CancellationException("scope cancelled") - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { - successSlot.captured(response) - } - - var error: PurchasesError? = null - var result: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { result = it }, - onError = { error = it }, - ) - assertThat(error).isNull() - assertThat(result).isNull() - } - - @Test - fun `getWorkflow still delivers result when pre-download throws`() { - val response = WorkflowDetailResponse( - action = WorkflowResponseAction.INLINE, - data = mockk(relaxed = true), - ) - val expectedResult = WorkflowDataResult( - workflow = response.data!!, - enrolledVariants = null, - ) - coEvery { mockResolver.resolve(response) } returns expectedResult - every { - mockAssetPreDownloader.preDownloadWorkflowAssets(any()) - } throws NullPointerException("malformed component data") - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { - successSlot.captured(response) - } - - var result: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { result = it }, - onError = { fail("pre-download failure must not prevent delivering the result") }, - ) - assertThat(result).isEqualTo(expectedResult) - } + manager.getWorkflow("missing", onSuccess = { }, onError = { error = it }) - @Test - fun `getWorkflow calls onError when resolver throws IOException`() { - val response = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn.example.com/workflow.json", - ) - coEvery { mockResolver.resolve(response) } throws IOException("CDN fetch failed") - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { - successSlot.captured(response) - } - - var error: PurchasesError? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { fail("expected error") }, - onError = { error = it }, - ) assertThat(error).isNotNull - assertThat(error!!.code).isEqualTo(PurchasesErrorCode.NetworkError) - } - - // region detail fetch status-based fallback - - @Test - fun `getWorkflow falls back to disk envelope on 5xx and delivers success`() { - val envelope = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn/wf_1.json", - hash = "h", - ) - val expectedResult = WorkflowDataResult(workflow = workflowMock(), enrolledVariants = null) - coEvery { mockResolver.resolve(envelope) } returns expectedResult - - // Disk holds one envelope for wf_1 - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns - """{"wf_1":{"action":"use_cdn","url":"https://cdn/wf_1.json","hash":"h"}}""" - - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = capture(errorSlot), - ) - } answers { - errorSlot.captured( - PurchasesError(PurchasesErrorCode.NetworkError, "server error"), - GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS, - ) - } - - var successResult: WorkflowDataResult? = null - var errorCalled = false - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { successResult = it }, - onError = { errorCalled = true }, - ) - - assertThat(successResult).isEqualTo(expectedResult) - assertThat(errorCalled).isFalse() - // Result was cached in memory after re-resolution - assertThat(workflowsCache.cachedWorkflow("wf_1")).isEqualTo(expectedResult) - } - - @Test - fun `getWorkflow disk fallback caches the recovered envelope under its resolved workflow id`() { - // The disk envelope is keyed by the real workflow id, and the recovered result is cached under - // the resolved workflow's own id (not the requested id), keeping the disk-fallback path keyed - // the same way as a normal resolve. A request resolving to a different id (here the mapping - // resolves the offering id to the workflow id) still re-pins under the canonical key. - val offeringId = "off_1" - val workflowId = "wfl-real-id" - workflowsCache.cacheWorkflowsListInMemory( - WorkflowsListResponse(workflows = emptyList()), - mapOf(offeringId to workflowId), - ) - val envelope = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn/wf.json", - hash = "h", - ) - val recovered = WorkflowDataResult(workflow = workflowMock(workflowId), enrolledVariants = null) - coEvery { mockResolver.resolve(envelope) } returns recovered - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns - """{"$workflowId":{"action":"use_cdn","url":"https://cdn/wf.json","hash":"h"}}""" - - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = workflowId, - appInBackground = false, - onSuccess = any(), - onError = capture(errorSlot), - ) - } answers { - errorSlot.captured( - PurchasesError(PurchasesErrorCode.NetworkError, "server error"), - GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS, - ) - } - - var served: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = offeringId, - appInBackground = false, - onSuccess = { served = it }, - onError = { fail("unexpected error $it") }, - ) - - assertThat(served).isSameAs(recovered) - assertThat(workflowsCache.cachedWorkflow(workflowId)).isSameAs(recovered) - } - - @Test - fun `getWorkflow disk fallback does not record requested workflow id as an offering id`() { - val requestedWorkflowId = "wf_1" - val resolvedWorkflowId = "wf_2" - val envelope = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn/wf.json", - hash = "h", - ) - val recovered = WorkflowDataResult(workflow = workflowMock(resolvedWorkflowId), enrolledVariants = null) - coEvery { mockResolver.resolve(envelope) } returns recovered - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns - """{"$requestedWorkflowId":{"action":"use_cdn","url":"https://cdn/wf.json","hash":"h"}}""" - - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = requestedWorkflowId, - appInBackground = false, - onSuccess = any(), - onError = capture(errorSlot), - ) - } answers { - errorSlot.captured( - PurchasesError(PurchasesErrorCode.NetworkError, "server error"), - GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS, - ) - } - - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = requestedWorkflowId, - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - assertThat(workflowsCache.cachedWorkflow(resolvedWorkflowId)).isSameAs(recovered) - assertThat(workflowsCache.workflowIdForOfferingId(requestedWorkflowId)).isNull() - } - - @Test - fun `getWorkflow does not fall back to disk envelope on 4xx`() { - val expectedError = PurchasesError(PurchasesErrorCode.UnknownBackendError, "not found") - - // Disk holds an envelope, but we must not serve it on 4xx - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns - """{"wf_1":{"action":"use_cdn","url":"https://cdn/wf_1.json","hash":"h"}}""" - - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = capture(errorSlot), - ) - } answers { - errorSlot.captured(expectedError, GetWorkflowsErrorHandlingBehavior.SHOULD_NOT_FALLBACK) - } - - var receivedError: PurchasesError? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { fail("unexpected success") }, - onError = { receivedError = it }, - ) - - assertThat(receivedError).isEqualTo(expectedError) - coVerify(exactly = 0) { mockResolver.resolve(any()) } - verify(exactly = 0) { mockDeviceCache.getWorkflowDetailEnvelopesCache() } - // Invalidate the in-memory entry so the next call retries rather than serving a cached value, - // mirroring the list/offerings 4xx policy. - verify { workflowsCache.invalidateWorkflowTimestamp("wf_1") } - } - - @Test - fun `getWorkflow surfaces error when fallback eligible but no envelope on disk`() { - val expectedError = PurchasesError(PurchasesErrorCode.NetworkError, "transport error") - - // No envelope on disk - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns null - - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = capture(errorSlot), - ) - } answers { - errorSlot.captured( - expectedError, - GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS, - ) - } - - var receivedError: PurchasesError? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { fail("unexpected success") }, - onError = { receivedError = it }, - ) - - assertThat(receivedError).isEqualTo(expectedError) - } - - @Test - fun `getWorkflow surfaces original error when fallback resolve throws`() { - val originalError = PurchasesError(PurchasesErrorCode.NetworkError, "server error") - val envelope = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn/wf_1.json", - hash = "h", - ) - coEvery { mockResolver.resolve(envelope) } throws IllegalStateException("cdn unavailable") - - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns - """{"wf_1":{"action":"use_cdn","url":"https://cdn/wf_1.json","hash":"h"}}""" - - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = capture(errorSlot), - ) - } answers { - errorSlot.captured( - originalError, - GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS, - ) - } - - var receivedError: PurchasesError? = null - var successCalled = false - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { successCalled = true }, - onError = { receivedError = it }, - ) - - assertThat(receivedError).isEqualTo(originalError) - assertThat(successCalled).isFalse() - } - - // endregion detail fetch status-based fallback - - // region getWorkflow cache - - @Test - fun `getWorkflow caches result on success`() { - val response = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = workflowMock()) - val expectedResult = WorkflowDataResult(workflow = response.data!!, enrolledVariants = null) - coEvery { mockResolver.resolve(response) } returns expectedResult - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { successSlot.captured(response) } - - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - assertThat(workflowsCache.cachedWorkflow("wf_1")).isSameAs(expectedResult) - } - - @Test - fun `getWorkflow returns cached result without calling backend when cache is fresh`() { - val response = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = workflowMock()) - val expectedResult = WorkflowDataResult(workflow = response.data!!, enrolledVariants = null) - coEvery { mockResolver.resolve(response) } returns expectedResult - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { successSlot.captured(response) } - - // First call populates the cache (stamped at t=0). - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - // Second call within TTL should hit the cache and skip the backend. - var secondResult: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { secondResult = it }, - onError = { fail("unexpected error $it") }, - ) - - assertThat(secondResult).isSameAs(expectedResult) - verify(exactly = 1) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = any(), - ) - } - } - - @Test - fun `getWorkflow refreshes in the background and still re-fetches when cache is stale`() { - val response = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = workflowMock()) - val firstResult = WorkflowDataResult(workflow = response.data!!, enrolledVariants = null) - val secondResult = WorkflowDataResult(workflow = response.data!!, enrolledVariants = null) - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { successSlot.captured(response) } - - // First call at t=0 populates the cache. - every { mockDateProvider.now } returns Date(0) - coEvery { mockResolver.resolve(response) } returns firstResult - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - // Second call past the 5-minute foreground TTL serves the stale value and refetches. - every { mockDateProvider.now } returns Date(6L * 60 * 1000) - coEvery { mockResolver.resolve(response) } returns secondResult - var served: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { served = it }, - onError = { fail("unexpected error $it") }, - ) - - // SWR: the caller gets the stale value, and the background refresh still hits the backend. - assertThat(served).isSameAs(firstResult) - verify(exactly = 2) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = any(), - ) - } - } - - @Test - fun `getWorkflow stale hit serves the cached value and refreshes the cache in the background`() { - val response = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = workflowMock()) - val staleResult = WorkflowDataResult(workflow = response.data!!, enrolledVariants = null) - val refreshedResult = WorkflowDataResult(workflow = response.data!!, enrolledVariants = null) - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { successSlot.captured(response) } - - // First call at t=0 populates the cache with the stale result. - every { mockDateProvider.now } returns Date(0) - coEvery { mockResolver.resolve(response) } returns staleResult - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - // Move past the 5-minute foreground TTL and make the next resolve produce a different result. - every { mockDateProvider.now } returns Date(6L * 60 * 1000) - coEvery { mockResolver.resolve(response) } returns refreshedResult - - var served: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { served = it }, - onError = { fail("unexpected error $it") }, - ) - - // Caller receives the stale cached value, not the background-refreshed one. - assertThat(served).isSameAs(staleResult) - // The background refresh ran and updated the cache to the fresh value. - assertThat(workflowsCache.cachedWorkflow("wf_1")).isSameAs(refreshedResult) - verify(exactly = 2) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = any(), - ) - } - } - - @Test - fun `getWorkflow stale hit does not surface a failing background refresh to the caller`() { - val response = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = workflowMock()) - val staleResult = WorkflowDataResult(workflow = response.data!!, enrolledVariants = null) - coEvery { mockResolver.resolve(response) } returns staleResult - - // First backend call succeeds (populates the cache); the second fails (background refresh). - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - var call = 0 - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = capture(errorSlot), - ) - } answers { - call++ - if (call == 1) { - successSlot.captured(response) - } else { - errorSlot.captured( - PurchasesError(PurchasesErrorCode.NetworkError, "boom"), - GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS, - ) - } - } - - // First call at t=0 populates the cache. - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - // Stale call whose background refresh fails: caller still gets the stale value, no error. - every { mockDateProvider.now } returns Date(6L * 60 * 1000) - var served: WorkflowDataResult? = null - var erroredWith: PurchasesError? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { served = it }, - onError = { erroredWith = it }, - ) - - assertThat(served).isSameAs(staleResult) - assertThat(erroredWith).isNull() - } - - @Test - fun `getWorkflow stale hit re-pins from the persisted envelope when the background refresh fails`() { - // Matches OfferingsManager: a fallback-eligible background-refresh failure recovers the - // persisted envelope and re-stamps the cache fresh. Known gap vs offerings (re-pinning an - // older prefetched envelope over a newer on-demand value) is bounded and self-healing, closed - // by the on-demand envelope persistence + LRU follow-up. - val inlineResponse = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = workflowMock()) - val staleResult = WorkflowDataResult(workflow = inlineResponse.data!!, enrolledVariants = null) - coEvery { mockResolver.resolve(inlineResponse) } returns staleResult - - // A distinct value persisted on disk, which the failed refresh should recover and re-pin. - val diskEnvelope = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn/wf_1.json", - hash = "h", - ) - val diskResult = WorkflowDataResult(workflow = workflowMock(), enrolledVariants = null) - coEvery { mockResolver.resolve(diskEnvelope) } returns diskResult - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns - """{"wf_1":{"action":"use_cdn","url":"https://cdn/wf_1.json","hash":"h"}}""" - - // First backend call succeeds (populates the cache); the second fails fallback-eligibly. - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - var call = 0 - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = capture(errorSlot), - ) - } answers { - call++ - if (call == 1) { - successSlot.captured(inlineResponse) - } else { - errorSlot.captured( - PurchasesError(PurchasesErrorCode.NetworkError, "boom"), - GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS, - ) - } - } - - // Populate at t=0 with the in-memory (INLINE) value. - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - // Stale call at t=6min: serves stale immediately, the background refresh then fails. - every { mockDateProvider.now } returns Date(6L * 60 * 1000) - var served: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { served = it }, - onError = { fail("unexpected error $it") }, - ) - - // Caller got the stale in-memory value immediately... - assertThat(served).isSameAs(staleResult) - // ...and the failed refresh recovered the disk envelope, re-pinned it, and re-stamped fresh. - assertThat(workflowsCache.cachedWorkflow("wf_1")).isSameAs(diskResult) - assertThat(workflowsCache.isWorkflowCacheStale("wf_1", appInBackground = false)).isFalse - - // Consequence: the next render serves the recovered disk value with no further backend call. - var servedAgain: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { servedAgain = it }, - onError = { fail("unexpected error $it") }, - ) - assertThat(servedAgain).isSameAs(diskResult) - verify(exactly = 2) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = any(), - ) - } - } - - @Test - fun `getWorkflow with staleWhileRevalidate false blocks on the refetch instead of serving stale`() { - val response = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = workflowMock()) - val staleResult = WorkflowDataResult(workflow = response.data!!, enrolledVariants = null) - val refreshedResult = WorkflowDataResult(workflow = response.data!!, enrolledVariants = null) - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { successSlot.captured(response) } - - // First call at t=0 populates the cache. - every { mockDateProvider.now } returns Date(0) - coEvery { mockResolver.resolve(response) } returns staleResult - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - // Stale call with SWR disabled: must deliver the freshly-fetched value, not the stale one. - every { mockDateProvider.now } returns Date(6L * 60 * 1000) - coEvery { mockResolver.resolve(response) } returns refreshedResult - var served: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { served = it }, - onError = { fail("unexpected error $it") }, - staleWhileRevalidate = false, - ) - - assertThat(served).isSameAs(refreshedResult) - // The blocking fetch also wrote the refreshed value through to the cache. - assertThat(workflowsCache.cachedWorkflow("wf_1")).isSameAs(refreshedResult) - } - - @Test - fun `getWorkflow on the lazy-conversion path caches under the resolved id and records the offering mapping`() { - // Simulates the lazy-conversion path: the VM calls getWorkflow with the offering ID - // (no map entry yet), and the backend returns a workflow whose own id is the real workflow id. - // We cache under the resolved id only (one entry) and record offeringId → workflowId so the - // next workflowIdForOfferingId lookup resolves to that same cache entry. - val offeringId = "my-offering" - val realWorkflowId = "wfl-real-id" - val mockWorkflow = mockk() - every { mockWorkflow.id } returns realWorkflowId - val response = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = mockWorkflow) - val expectedResult = WorkflowDataResult(workflow = mockWorkflow, enrolledVariants = null) - coEvery { mockResolver.resolve(response) } returns expectedResult - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = offeringId, - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { successSlot.captured(response) } - - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = offeringId, - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - // One entry, keyed by the resolved id — not duplicated under the offering id. - assertThat(workflowsCache.cachedWorkflow(realWorkflowId)).isSameAs(expectedResult) - assertThat(workflowsCache.cachedWorkflow(offeringId)).isNull() - // The discovered mapping is recorded so the next ask by offering id resolves to the real id. - assertThat(workflowsCache.workflowIdForOfferingId(offeringId)).isEqualTo(realWorkflowId) - - // Business consequence: the next render asks by offering id *again* (the render path always - // does). getWorkflow resolves it through the recorded mapping to realWorkflowId and serves the - // cached entry — no second backend call. The cache was stamped at Date(0) and mockDateProvider.now - // is Date(0), so 0 - 0 = 0 < 5min → fresh. - var secondResult: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = offeringId, - appInBackground = false, - onSuccess = { secondResult = it }, - onError = { fail("unexpected error $it") }, - ) - assertThat(secondResult).isSameAs(expectedResult) - verify(exactly = 1) { - mockBackend.getWorkflow( - appUserID = any(), - workflowId = any(), - appInBackground = any(), - onSuccess = any(), - onError = any(), - ) - } - } - - @Test - fun `getWorkflow asked by offering id serves the workflow prefetched under its workflow id`() { - // The headline #3598 bug: a workflow is prefetched/cached under its workflow id (and the list - // gives us the offeringId → workflowId mapping), but the render path asks by offering id. The - // ask must resolve through the map and hit the cached entry instead of firing a fresh fetch. - val offeringId = "my-offering" - val workflowId = "wfl-real-id" - val prefetched = WorkflowDataResult(workflow = mockk(relaxed = true), enrolledVariants = null) - workflowsCache.cacheWorkflowsListInMemory( - WorkflowsListResponse(workflows = emptyList()), - mapOf(offeringId to workflowId), - ) - workflowsCache.cacheWorkflow(workflowId, prefetched) - - var served: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = offeringId, - appInBackground = false, - onSuccess = { served = it }, - onError = { fail("unexpected error $it") }, - ) - - assertThat(served).isSameAs(prefetched) - verify(exactly = 0) { - mockBackend.getWorkflow(any(), any(), any(), any(), any()) - } - } - - @Test - fun `getWorkflow mapped lookup does not record requested workflow id as an offering id on resolved id mismatch`() { - val offeringId = "my-offering" - val requestedWorkflowId = "wf_1" - val resolvedWorkflowId = "wf_2" - workflowsCache.cacheWorkflowsListInMemory( - WorkflowsListResponse(workflows = emptyList()), - mapOf(offeringId to requestedWorkflowId), - ) - - val response = WorkflowDetailResponse( - action = WorkflowResponseAction.INLINE, - data = workflowMock(resolvedWorkflowId), - ) - coEvery { mockResolver.resolve(response) } returns - WorkflowDataResult(workflow = response.data!!, enrolledVariants = null) - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = requestedWorkflowId, - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { successSlot.captured(response) } - - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = offeringId, - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - assertThat(workflowsCache.cachedWorkflow(resolvedWorkflowId)).isNotNull - assertThat(workflowsCache.cachedWorkflow(requestedWorkflowId)).isNull() - assertThat(workflowsCache.workflowIdForOfferingId(offeringId)).isEqualTo(requestedWorkflowId) - assertThat(workflowsCache.workflowIdForOfferingId(requestedWorkflowId)).isNull() - - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = requestedWorkflowId, - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - verify(exactly = 2) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = requestedWorkflowId, - appInBackground = false, - onSuccess = any(), - onError = any(), - ) - } - } - - @Test - fun `getWorkflow delivers but does not cache a workflow resolved with an empty id`() { - // A malformed response (empty workflow id) can't be keyed in the cache, so it must not be - // cached under the requesting id — that would reintroduce the wrong-key problem. It is still - // delivered for this render; the next render refetches. - val offeringId = "my-offering" - val mockWorkflow = mockk() - every { mockWorkflow.id } returns "" - val response = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = mockWorkflow) - val expectedResult = WorkflowDataResult(workflow = mockWorkflow, enrolledVariants = null) - coEvery { mockResolver.resolve(response) } returns expectedResult - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = offeringId, - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { successSlot.captured(response) } - - var served: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = offeringId, - appInBackground = false, - onSuccess = { served = it }, - onError = { fail("unexpected error $it") }, - ) - - assertThat(served).isSameAs(expectedResult) - assertThat(workflowsCache.cachedWorkflow(offeringId)).isNull() - assertThat(workflowsCache.cachedWorkflow("")).isNull() - assertThat(workflowsCache.workflowIdForOfferingId(offeringId)).isNull() - } - - // endregion getWorkflow cache - - // region getWorkflowsList - - @Test - fun `getWorkflowsList calls backend and caches payload in DeviceCache`() { - val response = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_1", displayName = "Flow A", offeringId = "default", prefetch = false), - ), - ) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows( - appUserID = "user_1", - appInBackground = false, - type = "paywall", - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { successSlot.captured(response) } - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - verify(exactly = 1) { - mockBackend.getWorkflows(appUserID = "user_1", appInBackground = false, type = "paywall", onSuccess = any(), onError = any()) - } - verify(exactly = 1) { mockDeviceCache.cacheWorkflowsListResponse(any()) } - verify(exactly = 0) { mockBackend.getWorkflow(any(), any(), any(), any(), any()) } - } - - @Test - fun `getWorkflowsList skips network call when in-memory cache is fresh`() { - val response = WorkflowsListResponse(workflows = emptyList()) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { successSlot.captured(response) } - // First call at t=0 - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - // Second call at t=1ms — still fresh (threshold is 5 minutes) - every { mockDateProvider.now } returns Date(1) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - verify(exactly = 1) { mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = any()) } - } - - @Test - fun `getWorkflowsList with forceRefresh fetches even when the in-memory cache is fresh`() { - val response = WorkflowsListResponse(workflows = emptyList()) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { successSlot.captured(response) } - // First call at t=0 populates a fresh cache. - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - // Second call at t=1ms would normally be skipped as fresh, but forceRefresh overrides the TTL. - every { mockDateProvider.now } returns Date(1) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false, forceRefresh = true) - - verify(exactly = 2) { mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = any()) } - } - - @Test - fun `getWorkflowsList with forceRefresh re-fetches workflow details even when within TTL`() { - val listResponse = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_1", displayName = "Flow", offeringId = "default", prefetch = true), - ), - ) - val envelope = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = mockk(relaxed = true)) - coEvery { mockResolver.resolve(envelope) } returns - WorkflowDataResult(workflow = envelope.data!!, enrolledVariants = null) - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = any()) - } answers { - @Suppress("UNCHECKED_CAST") - (args[3] as (WorkflowsListResponse) -> Unit).invoke(listResponse) - } - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } answers { - @Suppress("UNCHECKED_CAST") - (args[3] as (WorkflowDetailResponse) -> Unit).invoke(envelope) - } - - // Initial load at t=0: list + detail fetched and cached (detail is fresh within TTL). - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - // forceRefresh at t=1ms: still within 5-min detail TTL, but forceRefresh clears detail - // caches so the prefetch is a guaranteed cache miss and goes to the backend. - every { mockDateProvider.now } returns Date(1) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false, forceRefresh = true) - - verify(exactly = 2) { mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = any()) } - verify(exactly = 2) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } - } - - @Test - fun `getWorkflowsList with forceRefresh preserves in-memory detail caches when the fetch fails`() { - val listResponse = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_1", displayName = "Flow", offeringId = "default", prefetch = true), - ), - ) - val envelope = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = workflowMock()) - val expectedResult = WorkflowDataResult(workflow = envelope.data!!, enrolledVariants = null) - coEvery { mockResolver.resolve(envelope) } returns expectedResult - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } answers { - @Suppress("UNCHECKED_CAST") - (args[3] as (WorkflowDetailResponse) -> Unit).invoke(envelope) - } - - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - var listCalls = 0 - every { - mockBackend.getWorkflows( - any(), - any(), - type = any(), - onSuccess = capture(successSlot), - onError = capture(errorSlot), - ) - } answers { - listCalls += 1 - when (listCalls) { - 1 -> successSlot.captured(listResponse) - else -> errorSlot.captured( - PurchasesError(PurchasesErrorCode.NetworkError, "fail"), - GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS, - ) - } - } - every { mockDeviceCache.getWorkflowsListResponseCache() } returns null - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns null - - // Initial load at t=0: list + detail fetched and cached. - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - // forceRefresh at t=1ms fails — detail cache must survive intact. - every { mockDateProvider.now } returns Date(1) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false, forceRefresh = true) - - // getWorkflow should return the cached result without a new backend call. - var result: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { result = it }, - onError = { fail("unexpected error: $it") }, - ) - - assertThat(result).isEqualTo(expectedResult) - verify(exactly = 1) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } - verify(exactly = 0) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = any(), - ) - } - } - - @Test - fun `getWorkflowsList triggers getWorkflow for each prefetch=true entry only`() { - val response = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_prefetch", displayName = "A", offeringId = "default", prefetch = true), - WorkflowSummary(id = "wf_skip", displayName = "B", offeringId = "premium", prefetch = false), - WorkflowSummary(id = "wf_also_prefetch", displayName = "C", offeringId = "pro", prefetch = true), - ), - ) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { successSlot.captured(response) } - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - verify(exactly = 1) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_prefetch", - any(), - any(), - any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } - verify(exactly = 0) { - mockBackend.getWorkflow(appUserID = "user_1", workflowId = "wf_skip", any(), any(), any(), any()) - } - verify(exactly = 1) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_also_prefetch", - any(), - any(), - any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } - } - - @Test - fun `getWorkflowsList does not prefetch workflows without an offeringId`() { - val response = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_no_offering", displayName = "A", offeringId = null, prefetch = true), - WorkflowSummary(id = "wf_with_offering", displayName = "B", offeringId = "default", prefetch = true), - ), - ) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { successSlot.captured(response) } - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - verify(exactly = 0) { - mockBackend.getWorkflow(appUserID = "user_1", workflowId = "wf_no_offering", any(), any(), any(), any()) - } - verify(exactly = 1) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_with_offering", - any(), - any(), - any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } - } - - @Test - fun `getWorkflowsList does not cache workflows without an offeringId`() { - val response = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_no_offering", displayName = "A", offeringId = null, prefetch = false), - WorkflowSummary(id = "wf_with_offering", displayName = "B", offeringId = "default", prefetch = false), - ), - ) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { successSlot.captured(response) } - val cachedJson = slot() - every { mockDeviceCache.cacheWorkflowsListResponse(capture(cachedJson)) } just Runs - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - assertThat(cachedJson.captured).contains("wf_with_offering") - assertThat(cachedJson.captured).doesNotContain("wf_no_offering") - } - - @Test - fun `getWorkflowsList fetches every prefetch=true workflow`() { - val workflows = (0 until 10).map { - WorkflowSummary(id = "wf_$it", displayName = "W$it", offeringId = "off_$it", prefetch = true) - } - val response = WorkflowsListResponse(workflows = workflows) - val listSuccessSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(listSuccessSlot), onError = any()) - } answers { listSuccessSlot.captured(response) } - - // Hold every prefetch in flight by never invoking its callback. The manager does not cap - // concurrency itself: detail fetches are bounded by prefetchDispatcher's thread pool and CDN - // downloads by the workflows FileRepository scope, so every prefetch=true workflow is launched. - every { mockBackend.getWorkflow(any(), any(), any(), any(), any(), any()) } answers { } - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - verify(exactly = 10) { mockBackend.getWorkflow(any(), any(), any(), any(), any(), any()) } - } - - @Test - fun `on-demand getWorkflow runs on the default dispatcher, not the prefetch one`() { - every { mockBackend.getWorkflow(any(), any(), any(), any(), any(), any()) } answers { } - - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = {}, - onError = {}, - ) - - verify(exactly = 1) { - mockBackend.getWorkflow("user_1", "wf_1", false, any(), any()) - } - } - - @Test - fun `getWorkflowsList silently logs error on backend failure`() { - val error = PurchasesError(PurchasesErrorCode.NetworkError, "network error") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS) } - - // Should not throw - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - verify(exactly = 0) { mockDeviceCache.cacheWorkflowsListResponse(any()) } - } - - @Test - fun `getWorkflowsList restores offeringId map from disk cache on backend failure`() { - val cachedJson = """{"workflows":[{"id":"wf_1","display_name":"Flow","offering_id":"default","prefetch":false}]}""" - val error = PurchasesError(PurchasesErrorCode.NetworkError, "network error") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS) } - every { mockDeviceCache.getWorkflowsListResponseCache() } returns cachedJson - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - assertThat(workflowManager.workflowIdForOfferingId("default")).isEqualTo("wf_1") - // The disk copy is the source we restored from, so the recovery path must not rewrite it. - verify(exactly = 0) { mockDeviceCache.cacheWorkflowsListResponse(any()) } - } - - @Test - fun `getWorkflowsList silently ignores corrupt disk cache on backend failure`() { - val error = PurchasesError(PurchasesErrorCode.NetworkError, "network error") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS) } - every { mockDeviceCache.getWorkflowsListResponseCache() } returns "not valid json" - - // Should not throw - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - assertThat(workflowManager.workflowIdForOfferingId("default")).isNull() - } - - // endregion getWorkflowsList - - // region workflowIdForOfferingId - - @Test - fun `workflowIdForOfferingId returns null before list is fetched`() { - assertThat(workflowManager.workflowIdForOfferingId("default")).isNull() - } - - @Test - fun `workflowIdForOfferingId returns workflow id after list is fetched`() { - val response = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_abc", displayName = "Flow", offeringId = "default", prefetch = false), - ), - ) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { successSlot.captured(response) } - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - assertThat(workflowManager.workflowIdForOfferingId("default")).isEqualTo("wf_abc") - assertThat(workflowManager.workflowIdForOfferingId("premium")).isNull() - } - - @Test - fun `workflowIdForOfferingId returns null for workflow with null offering_id`() { - val response = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_no_offering", displayName = "Flow", offeringId = null, prefetch = false), - ), - ) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { successSlot.captured(response) } - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - assertThat(workflowManager.workflowIdForOfferingId("default")).isNull() - } - - // endregion workflowIdForOfferingId - - // region issue fixes - - // Test A — disk-cache fallback stamps in-memory cache so re-fetch after TTL still fires once more - @Test - fun `getWorkflowsList after disk-cache restore does not re-fetch before TTL expires`() { - val cachedJson = """{"workflows":[{"id":"wf_1","display_name":"Flow","offering_id":"default","prefetch":false}]}""" - val error = PurchasesError(PurchasesErrorCode.NetworkError, "network error") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS) } - every { mockDeviceCache.getWorkflowsListResponseCache() } returns cachedJson - - // First call fails → restores from disk - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - assertThat(workflowManager.workflowIdForOfferingId("default")).isEqualTo("wf_1") - - // Second call immediately after — in-memory cache should be considered fresh (t=1ms) - every { mockDateProvider.now } returns Date(1) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - // Only one backend call total (the first one that failed) - verify(exactly = 1) { mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = any()) } - } - - // Test A (continued) — re-fetch fires again after TTL expires - @Test - fun `getWorkflowsList re-fetches after TTL expiry following disk-cache restore`() { - val cachedJson = """{"workflows":[{"id":"wf_1","display_name":"Flow","offering_id":"default","prefetch":false}]}""" - val error = PurchasesError(PurchasesErrorCode.NetworkError, "network error") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS) } - every { mockDeviceCache.getWorkflowsListResponseCache() } returns cachedJson - - // First call at t=0 — fails, restores from disk, stamps in-memory cache at t=0 - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - // Second call well past TTL (6 minutes = 360_000ms) — should fire a new network request - val sixMinutesMs = 6L * 60 * 1000 - every { mockDateProvider.now } returns Date(sixMinutesMs) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - // Two backend calls total: first failure + re-fetch after TTL - verify(exactly = 2) { mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = any()) } - } - - // Test B — concurrent calls only fire one network request - @Test - fun `getWorkflowsList concurrent calls only fire one network request`() { - // Hold the backend call without resolving it so the first call stays in-flight - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { /* intentionally do not call successSlot to simulate in-flight */ } - - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - // Second call while first is still in-flight - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - verify(exactly = 1) { mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = any()) } - } - - // Test C — duplicate offeringId: last value wins, no crash - @Test - fun `getWorkflowsList with duplicate offeringId keeps last entry`() { - val response = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_first", displayName = "First", offeringId = "shared", prefetch = false), - WorkflowSummary(id = "wf_last", displayName = "Last", offeringId = "shared", prefetch = false), - ), - ) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { successSlot.captured(response) } - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - assertThat(workflowManager.workflowIdForOfferingId("shared")).isEqualTo("wf_last") - } - - // Test C (disk-cache path) — duplicate offeringId in restored disk cache: last value wins - @Test - fun `getWorkflowsList disk-cache restore with duplicate offeringId keeps last entry`() { - val cachedJson = """{"workflows":[ - {"id":"wf_first","display_name":"First","offering_id":"shared","prefetch":false}, - {"id":"wf_last","display_name":"Last","offering_id":"shared","prefetch":false} - ]}""" - val error = PurchasesError(PurchasesErrorCode.NetworkError, "network error") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS) } - every { mockDeviceCache.getWorkflowsListResponseCache() } returns cachedJson - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - assertThat(workflowManager.workflowIdForOfferingId("shared")).isEqualTo("wf_last") - } - - // Clearing the workflows cache (e.g. on user switch) must drop the in-memory list cache and - // offeringId map so the next call re-fetches instead of serving the previous user's workflows. - @Test - fun `clearing workflows cache drops offeringId map and forces re-fetch within TTL`() { - val response = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_1", displayName = "Flow", offeringId = "default", prefetch = false), - ), - ) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { successSlot.captured(response) } - - // First fetch at t=0 populates the list cache + offeringId map. - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - assertThat(workflowManager.workflowIdForOfferingId("default")).isEqualTo("wf_1") - - // Simulate a user switch clearing the cache. - workflowsCache.clearCache() - - // The offeringId map must be gone. - assertThat(workflowManager.workflowIdForOfferingId("default")).isNull() - - // The next call within TTL must re-fetch because the in-memory list cache was cleared. - every { mockDateProvider.now } returns Date(1) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - verify(exactly = 2) { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = any()) - } - } - - // endregion issue fixes - - // region onComplete callback - - @Test - fun `getWorkflowsList calls onComplete after backend success with no prefetch workflows`() { - val response = WorkflowsListResponse(workflows = listOf( - WorkflowSummary(id = "wf_1", displayName = "Flow", offeringId = "default", prefetch = false), - )) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { successSlot.captured(response) } - - var completed = false - workflowManager.getWorkflowsList("user_1", false) { completed = true } - - assertThat(completed).isTrue() - } - - @Test - fun `getWorkflowsList calls onComplete immediately when cache is fresh`() { - val response = WorkflowsListResponse(workflows = emptyList()) - val successSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(successSlot), onError = any()) - } answers { successSlot.captured(response) } - - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflowsList("user_1", false) - - every { mockDateProvider.now } returns Date(1) // still within TTL - var completed = false - workflowManager.getWorkflowsList("user_1", false) { completed = true } - - assertThat(completed).isTrue() - verify(exactly = 1) { mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = any()) } - } - - @Test - fun `getWorkflowsList calls onComplete only after all prefetch workflows complete`() { - val response = WorkflowsListResponse(workflows = listOf( - WorkflowSummary(id = "wf_a", displayName = "A", offeringId = "default", prefetch = true), - WorkflowSummary(id = "wf_b", displayName = "B", offeringId = "premium", prefetch = true), - )) - val listSuccessSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(listSuccessSlot), onError = any()) - } answers { listSuccessSlot.captured(response) } - - val detailSuccessA = slot<(WorkflowDetailResponse) -> Unit>() - val detailSuccessB = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow("user_1", "wf_a", false, capture(detailSuccessA), any(), any()) - } just Runs - every { - mockBackend.getWorkflow("user_1", "wf_b", false, capture(detailSuccessB), any(), any()) - } just Runs - coEvery { mockResolver.resolve(any()) } returns mockk(relaxed = true) - - var completed = false - workflowManager.getWorkflowsList("user_1", false) { completed = true } - - assertThat(completed).isFalse() - - val detailResponse = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = mockk(relaxed = true)) - detailSuccessA.captured(detailResponse) - assertThat(completed).isFalse() - - detailSuccessB.captured(detailResponse) - assertThat(completed).isTrue() - } - - @Test - fun `getWorkflowsList second caller waits for in-flight prefetch when list cache is fresh`() { - val response = WorkflowsListResponse(workflows = listOf( - WorkflowSummary(id = "wf_a", displayName = "A", offeringId = "default", prefetch = true), - )) - val listSuccessSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(listSuccessSlot), onError = any()) - } answers { listSuccessSlot.captured(response) } - - val detailSuccess = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow("user_1", "wf_a", false, capture(detailSuccess), any(), any()) - } just Runs - coEvery { mockResolver.resolve(any()) } returns mockk(relaxed = true) - - var firstCompleted = false - var secondCompleted = false - workflowManager.getWorkflowsList("user_1", false) { firstCompleted = true } - - assertThat(firstCompleted).isFalse() - - workflowManager.getWorkflowsList("user_1", false) { secondCompleted = true } - - assertThat(secondCompleted).isFalse() - - detailSuccess.captured(WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = mockk(relaxed = true))) - - assertThat(firstCompleted).isTrue() - assertThat(secondCompleted).isTrue() - } - - @Test - fun `getWorkflowsList calls onComplete even if a prefetch workflow fails`() { - val response = WorkflowsListResponse(workflows = listOf( - WorkflowSummary(id = "wf_a", displayName = "A", offeringId = "default", prefetch = true), - WorkflowSummary(id = "wf_b", displayName = "B", offeringId = "premium", prefetch = true), - )) - val listSuccessSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(listSuccessSlot), onError = any()) - } answers { listSuccessSlot.captured(response) } - - val detailSuccessA = slot<(WorkflowDetailResponse) -> Unit>() - val detailErrorB = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflow("user_1", "wf_a", false, capture(detailSuccessA), any(), any()) - } just Runs - every { - mockBackend.getWorkflow("user_1", "wf_b", false, any(), capture(detailErrorB), any()) - } just Runs - coEvery { mockResolver.resolve(any()) } returns mockk(relaxed = true) - - var completed = false - workflowManager.getWorkflowsList("user_1", false) { completed = true } - - assertThat(completed).isFalse() - - detailSuccessA.captured(WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = mockk(relaxed = true))) - assertThat(completed).isFalse() - - detailErrorB.captured( - PurchasesError(PurchasesErrorCode.NetworkError, "fail"), - GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS, - ) - assertThat(completed).isTrue() - } - - @Test - fun `getWorkflowsList calls onComplete when a prefetch workflow resolve throws unexpected exception`() { - val response = WorkflowsListResponse(workflows = listOf( - WorkflowSummary(id = "wf_a", displayName = "A", offeringId = "default", prefetch = true), - )) - val listSuccessSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(listSuccessSlot), onError = any()) - } answers { listSuccessSlot.captured(response) } - - val detailSuccessA = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow("user_1", "wf_a", false, capture(detailSuccessA), any(), any()) - } just Runs - // resolve throws an exception type not in the explicit catch list (e.g. malformed CDN json) - coEvery { mockResolver.resolve(any()) } throws SerializationException("malformed compiled workflow json") - - var completed = false - workflowManager.getWorkflowsList("user_1", false) { completed = true } - - detailSuccessA.captured(WorkflowDetailResponse(action = WorkflowResponseAction.USE_CDN, url = "https://cdn.example.com/wf.json")) - - assertThat(completed).isTrue() - } - - @Test - fun `getWorkflowsList calls onComplete after network error`() { - val error = PurchasesError(PurchasesErrorCode.NetworkError, "fail") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS) } - - var completed = false - workflowManager.getWorkflowsList("user_1", false) { completed = true } - - assertThat(completed).isTrue() - } - - @Test - fun `getWorkflowsList concurrent in-flight calls both receive onComplete`() { - val listSuccessSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(listSuccessSlot), onError = any()) - } just Runs // hold the request in-flight - - var completed1 = false - var completed2 = false - workflowManager.getWorkflowsList("user_1", false) { completed1 = true } - workflowManager.getWorkflowsList("user_1", false) { completed2 = true } - - assertThat(completed1).isFalse() - assertThat(completed2).isFalse() - - listSuccessSlot.captured(WorkflowsListResponse(workflows = emptyList())) - - assertThat(completed1).isTrue() - assertThat(completed2).isTrue() - verify(exactly = 1) { mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = any()) } - } - - @Test - fun `getWorkflowsList for a different user does not join an in-flight fetch for the previous user`() { - val user1Success = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows("user_1", any(), type = any(), onSuccess = capture(user1Success), onError = any()) - } just Runs // hold user_1 in-flight - - val user2Success = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows("user_2", any(), type = any(), onSuccess = capture(user2Success), onError = any()) - } just Runs // hold user_2 in-flight - - var user1Completed = false - var user2Completed = false - workflowManager.getWorkflowsList("user_1", false) { user1Completed = true } - workflowManager.getWorkflowsList("user_2", false) { user2Completed = true } - - // user_2 must start its own fetch rather than join user_1's in-flight request. - verify(exactly = 1) { - mockBackend.getWorkflows("user_2", any(), type = any(), onSuccess = any(), onError = any()) - } - assertThat(user1Completed).isFalse() - assertThat(user2Completed).isFalse() - - // user_2's fetch landing completes only user_2, not user_1. - user2Success.captured(WorkflowsListResponse(workflows = emptyList())) - assertThat(user2Completed).isTrue() - assertThat(user1Completed).isFalse() - - // user_1's fetch landing then completes user_1. - user1Success.captured(WorkflowsListResponse(workflows = emptyList())) - assertThat(user1Completed).isTrue() - } - - // endregion onComplete callback - - // region envelope persistence - - @Test - fun `prefetch persists the workflow detail envelope`() { - val envelope = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn/wf_1.json", - hash = "h", - ) - coEvery { mockResolver.resolve(envelope) } returns - WorkflowDataResult(workflow = workflowMock(), enrolledVariants = null) - - val listResponse = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_1", displayName = "A", offeringId = "default", prefetch = true), - ), - ) - val listSuccess = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(listSuccess), onError = any()) - } answers { listSuccess.captured(listResponse) } - - val detailSuccess = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - any(), - onSuccess = capture(detailSuccess), - onError = any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } answers { detailSuccess.captured(envelope) } - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - // WorkflowsCache.cacheWorkflowDetailEnvelope -> DeviceCache.cacheWorkflowDetailEnvelopes (the mocked layer) - val persistedSlot = slot() - verify(exactly = 1) { mockDeviceCache.cacheWorkflowDetailEnvelopes(capture(persistedSlot)) } - assertThat(persistedSlot.captured).contains("wf_1") - } - - @Test - fun `prefetch persists the envelope even when the workflow is already cached but stale`() { - // Seed the detail cache at t=0 so the workflow is present but will be stale at prefetch time. - every { mockDateProvider.now } returns Date(0) - workflowsCache.cacheWorkflow("wf_1", WorkflowDataResult(workflow = mockk(relaxed = true), enrolledVariants = null)) - - // Advance past the 5-minute TTL: the prefetch now sees a stale-but-present cache entry. - // With SWR disabled on the prefetch path it must still do a real fetch and persist, not serve stale. - every { mockDateProvider.now } returns Date(6L * 60 * 1000) - - val envelope = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn/wf_1.json", - hash = "h", - ) - coEvery { mockResolver.resolve(envelope) } returns - WorkflowDataResult(workflow = workflowMock(), enrolledVariants = null) - - val listResponse = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_1", displayName = "A", offeringId = "default", prefetch = true), - ), - ) - val listSuccess = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(listSuccess), onError = any()) - } answers { listSuccess.captured(listResponse) } - - val detailSuccess = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - any(), - onSuccess = capture(detailSuccess), - onError = any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } answers { detailSuccess.captured(envelope) } - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - // A real prefetch fetch happened (the prefetch overload with the prefetch dispatcher)... - verify(exactly = 1) { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - any(), - onSuccess = any(), - onError = any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } - // ...and the envelope was persisted, proving the stale prefetch took the blocking fetch path. - val persistedSlot = slot() - verify(exactly = 1) { mockDeviceCache.cacheWorkflowDetailEnvelopes(capture(persistedSlot)) } - assertThat(persistedSlot.captured).contains("wf_1") - } - - @Test - fun `on-demand getWorkflow does not persist the envelope`() { - val envelope = WorkflowDetailResponse(action = WorkflowResponseAction.INLINE, data = mockk(relaxed = true)) - coEvery { mockResolver.resolve(envelope) } returns - WorkflowDataResult(workflow = envelope.data!!, enrolledVariants = null) - - val successSlot = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = capture(successSlot), - onError = any(), - ) - } answers { successSlot.captured(envelope) } - - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = {}, - onError = { fail("unexpected error $it") }, - ) - - verify(exactly = 0) { mockDeviceCache.cacheWorkflowDetailEnvelopes(any()) } - } - - @Test - fun `prefetch does not persist the envelope when resolve fails`() { - val envelope = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn/wf_1.json", - hash = "h", - ) - coEvery { mockResolver.resolve(envelope) } throws IllegalStateException("boom") - - val listResponse = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_1", displayName = "A", offeringId = "default", prefetch = true), - ), - ) - val listSuccess = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(listSuccess), onError = any()) - } answers { listSuccess.captured(listResponse) } - - val detailSuccess = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - any(), - onSuccess = capture(detailSuccess), - onError = any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } answers { detailSuccess.captured(envelope) } - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - verify(exactly = 0) { mockDeviceCache.cacheWorkflowDetailEnvelopes(any()) } - } - - @Test - fun `prefetch still completes when persisting the envelope throws`() { - val envelope = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn/wf_1.json", - hash = "h", - ) - val resolved = WorkflowDataResult(workflow = workflowMock(), enrolledVariants = null) - coEvery { mockResolver.resolve(envelope) } returns resolved - // Persisting blows up at the DeviceCache layer. - every { mockDeviceCache.cacheWorkflowDetailEnvelopes(any()) } throws IOException("disk full") - - val listResponse = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_1", displayName = "A", offeringId = "default", prefetch = true), - ), - ) - val listSuccess = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(listSuccess), onError = any()) - } answers { listSuccess.captured(listResponse) } - - val detailSuccess = slot<(WorkflowDetailResponse) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - any(), - onSuccess = capture(detailSuccess), - onError = any(), - callbackDispatcher = mockPrefetchDispatcher, - ) - } answers { detailSuccess.captured(envelope) } - - var completeCount = 0 - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) { completeCount++ } - - // Persistence failure must not hang the prefetch: onComplete still fires exactly once... - assertThat(completeCount).isEqualTo(1) - // ...and the resolved workflow is still cached in memory (cacheWorkflow ran before the failed persist). - assertThat(workflowsCache.cachedWorkflow("wf_1")).isSameAs(resolved) } @Test - fun `prefetch detail fetch falls back to the persisted envelope on a fallback-eligible error`() { - // The disk fallback applies to every caller, including prefetch: this is the intended - // backend-down recovery (consistent with restoreWorkflowFromEnvelope). The persisted envelope - // is re-resolved, cached, and re-stamped fresh. - val listResponse = WorkflowsListResponse( - workflows = listOf( - WorkflowSummary(id = "wf_1", displayName = "W", offeringId = "off_1", prefetch = true), - ), - ) - val listSuccessSlot = slot<(WorkflowsListResponse) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = capture(listSuccessSlot), onError = any()) - } answers { listSuccessSlot.captured(listResponse) } - - // The persisted envelope on disk and the value it resolves to. - val diskEnvelope = WorkflowDetailResponse( - action = WorkflowResponseAction.USE_CDN, - url = "https://cdn/wf_1.json", - hash = "h", - ) - val diskResult = WorkflowDataResult(workflow = workflowMock(), enrolledVariants = null) - coEvery { mockResolver.resolve(diskEnvelope) } returns diskResult - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns - """{"wf_1":{"action":"use_cdn","url":"https://cdn/wf_1.json","hash":"h"}}""" - - // The prefetch detail fetch fails fallback-eligibly (5xx) on the prefetch dispatcher. - val detailErrorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflow( - appUserID = "user_1", - workflowId = "wf_1", - appInBackground = false, - onSuccess = any(), - onError = capture(detailErrorSlot), - callbackDispatcher = mockPrefetchDispatcher, - ) - } answers { - detailErrorSlot.captured( - PurchasesError(PurchasesErrorCode.NetworkError, "server boom"), - GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS, - ) - } - - every { mockDateProvider.now } returns Date(0) - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - // The prefetch recovered the persisted envelope and cached it in memory. - coVerify(exactly = 1) { mockResolver.resolve(diskEnvelope) } - assertThat(workflowsCache.cachedWorkflow("wf_1")).isSameAs(diskResult) - // cacheWorkflow re-stamped it fresh, so an on-demand render serves this recovered value - // without re-hitting the backend until it goes stale again. - assertThat(workflowsCache.isWorkflowCacheStale("wf_1", appInBackground = false)).isFalse - } - - // endregion envelope persistence - - // region onError envelope restore - - @Test - fun `list onError re-resolves persisted envelopes so getWorkflow is a cache hit`() { - // The resolver is mocked, so INLINE vs USE_CDN is indistinguishable here — the manager just - // hands the envelope to the resolver. This proves restore -> re-resolve -> cache-hit. - val error = PurchasesError(PurchasesErrorCode.NetworkError, "network error") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS) } - every { mockDeviceCache.getWorkflowsListResponseCache() } returns - """{"workflows":[{"id":"wf_1","display_name":"Flow","offering_id":"default","prefetch":true}]}""" - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns - """{"wf_1":{"action":"use_cdn","url":"https://cdn/wf_1.json","hash":"h"}}""" - - val restored = WorkflowDataResult(workflow = mockk(relaxed = true), enrolledVariants = mapOf("e" to "v")) - coEvery { - mockResolver.resolve( - WorkflowDetailResponse(action = WorkflowResponseAction.USE_CDN, url = "https://cdn/wf_1.json", hash = "h"), - ) - } returns restored - - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) - - assertThat(workflowManager.workflowIdForOfferingId("default")).isEqualTo("wf_1") - var result: WorkflowDataResult? = null - workflowManager.getWorkflow( - appUserID = "user_1", - workflowOrOfferingId = "wf_1", - appInBackground = false, - onSuccess = { result = it }, - onError = { fail("expected cache hit, got $it") }, - ) - assertThat(result).isSameAs(restored) - verify(exactly = 0) { mockBackend.getWorkflow(any(), any(), any(), any(), any(), any()) } - } - - @Test - fun `list onError completes once and isolates a failing envelope re-resolve`() { - val error = PurchasesError(PurchasesErrorCode.NetworkError, "network error") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS) } - every { mockDeviceCache.getWorkflowsListResponseCache() } returns - """{"workflows":[{"id":"wf_ok","display_name":"OK","offering_id":"a","prefetch":true},{"id":"wf_bad","display_name":"BAD","offering_id":"b","prefetch":true}]}""" - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns - """{"wf_ok":{"action":"use_cdn","url":"u_ok","hash":"h"},"wf_bad":{"action":"use_cdn","url":"u_bad","hash":"h"}}""" - - val okResult = WorkflowDataResult(workflow = mockk(relaxed = true), enrolledVariants = null) - coEvery { - mockResolver.resolve(WorkflowDetailResponse(action = WorkflowResponseAction.USE_CDN, url = "u_ok", hash = "h")) - } returns okResult - coEvery { - mockResolver.resolve(WorkflowDetailResponse(action = WorkflowResponseAction.USE_CDN, url = "u_bad", hash = "h")) - } throws IllegalStateException("cdn read failed") - - var completeCount = 0 - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) { completeCount++ } - - assertThat(completeCount).isEqualTo(1) - assertThat(workflowsCache.cachedWorkflow("wf_ok")).isSameAs(okResult) - assertThat(workflowsCache.cachedWorkflow("wf_bad")).isNull() - } - - @Test - fun `list onError completes immediately when no envelopes are persisted`() { - val error = PurchasesError(PurchasesErrorCode.NetworkError, "network error") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS) } - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns null - - var completeCount = 0 - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) { completeCount++ } - - assertThat(completeCount).isEqualTo(1) - coVerify(exactly = 0) { mockResolver.resolve(any()) } - } - - @Test - fun `list onError completes once when the persisted envelope payload is corrupt`() { - val error = PurchasesError(PurchasesErrorCode.NetworkError, "network error") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_FALLBACK_TO_CACHED_WORKFLOWS) } - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns "not valid json" - - var completeCount = 0 - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) { completeCount++ } - - assertThat(completeCount).isEqualTo(1) - } - - @Test - fun `getWorkflowsList does not restore from disk on a non-fallback (4xx) error`() { - val cachedJson = """{"workflows":[{"id":"wf_1","display_name":"Flow","offering_id":"default","prefetch":false}]}""" - val error = PurchasesError(PurchasesErrorCode.InvalidCredentialsError, "forbidden") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_NOT_FALLBACK) } - every { mockDeviceCache.getWorkflowsListResponseCache() } returns cachedJson - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns null - - var completeCount = 0 - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) { completeCount++ } - - // No restore: the offeringId map stays empty, and the disk read is never consulted for restore. - assertThat(workflowManager.workflowIdForOfferingId("default")).isNull() - verify(exactly = 0) { mockDeviceCache.getWorkflowsListResponseCache() } - verify(exactly = 0) { mockDeviceCache.getWorkflowDetailEnvelopesCache() } - // Offerings delivery is never stranded. - assertThat(completeCount).isEqualTo(1) - } - - @Test - fun `getWorkflowsList invalidates list timestamp on a non-fallback (4xx) error`() { - // Stamp the in-memory cache as fresh so the cache does not look stale before the 4xx. - val recentDate = Date(1_000_000) - every { mockDateProvider.now } returns recentDate - workflowsCache.cacheWorkflowsListInMemory( - WorkflowsListResponse(workflows = emptyList()), - emptyMap(), - ) - assertThat(workflowsCache.isWorkflowsListCacheStale(appInBackground = false)).isFalse() - - val error = PurchasesError(PurchasesErrorCode.InvalidCredentialsError, "forbidden") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } answers { errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_NOT_FALLBACK) } - - // forceRefresh bypasses the freshness check, matching the real caller (createAndCacheOfferings). - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false, forceRefresh = true) {} - - // Timestamp must be cleared so a subsequent non-forced call retries rather than serving a - // still-fresh in-memory list — mirrors OfferingsManager.handleErrorFetchingOfferings. - assertThat(workflowsCache.isWorkflowsListCacheStale(appInBackground = false)).isTrue() - } - - @Test - fun `getWorkflowsList completes all concurrent callers exactly once on a non-fallback (4xx) error`() { - val cachedJson = """{"workflows":[{"id":"wf_1","display_name":"Flow","offering_id":"default","prefetch":false}]}""" - val error = PurchasesError(PurchasesErrorCode.InvalidCredentialsError, "forbidden") - val errorSlot = slot<(PurchasesError, GetWorkflowsErrorHandlingBehavior) -> Unit>() - every { - mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = capture(errorSlot)) - } just Runs // hold the request in-flight so the second caller joins before it settles - every { mockDeviceCache.getWorkflowsListResponseCache() } returns cachedJson - every { mockDeviceCache.getWorkflowDetailEnvelopesCache() } returns null - - var completeCount1 = 0 - var completeCount2 = 0 - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) { completeCount1++ } - workflowManager.getWorkflowsList(appUserID = "user_1", appInBackground = false) { completeCount2++ } - - // Both callers are pending; the backend was contacted exactly once (dedup). - assertThat(completeCount1).isEqualTo(0) - assertThat(completeCount2).isEqualTo(0) - verify(exactly = 1) { mockBackend.getWorkflows(any(), any(), type = any(), onSuccess = any(), onError = any()) } - - // Settle the single in-flight request with a SHOULD_NOT_FALLBACK error. - errorSlot.captured(error, GetWorkflowsErrorHandlingBehavior.SHOULD_NOT_FALLBACK) + fun `workflowIdForOfferingId delegates to the provider`() = runTest { + coEvery { provider.workflowIdForOfferingId("premium") } returns "wf-9" - // Every caller must complete exactly once — neither stranded nor double-fired. - assertThat(completeCount1).isEqualTo(1) - assertThat(completeCount2).isEqualTo(1) - // No disk restore should have been attempted. - verify(exactly = 0) { mockDeviceCache.getWorkflowsListResponseCache() } - verify(exactly = 0) { mockDeviceCache.getWorkflowDetailEnvelopesCache() } + assertThat(manager.workflowIdForOfferingId("premium")).isEqualTo("wf-9") } - // endregion onError envelope restore + private fun workflow(id: String) = WorkflowDataResult( + workflow = PublishedWorkflow( + id = id, + displayName = id, + initialStepId = "step-1", + steps = emptyMap(), + screens = emptyMap(), + uiConfig = UiConfig(), + ), + enrolledVariants = null, + ) } diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowsConfigIntegrationTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowsConfigIntegrationTest.kt new file mode 100644 index 0000000000..28bd934808 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowsConfigIntegrationTest.kt @@ -0,0 +1,352 @@ +package com.revenuecat.purchases.common.workflows + +import android.util.Base64 +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.JsonTools +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.UiConfig +import com.revenuecat.purchases.VerificationResult +import com.revenuecat.purchases.common.Backend +import com.revenuecat.purchases.common.networking.RCContainer +import com.revenuecat.purchases.common.networking.RCElement +import com.revenuecat.purchases.common.remoteconfig.PersistedRemoteConfigurationState +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigBlobFetcher +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigBlobStore +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigDiskCache +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigSource +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigSourceHandle +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigSourceProvider +import com.revenuecat.purchases.common.remoteconfig.RemoteConfigTopic +import com.revenuecat.purchases.paywalls.components.common.LocaleId +import com.revenuecat.purchases.paywalls.components.common.VariableLocalizationKey +import com.revenuecat.purchases.utils.UrlConnection +import com.revenuecat.purchases.utils.UrlConnectionFactory +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.assertj.core.api.Assertions.assertThat +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import java.io.ByteArrayInputStream +import java.net.HttpURLConnection +import java.nio.ByteBuffer +import java.security.MessageDigest + +/** + * End-to-end: drives a fake `/v1/config` sync through the **real** [RemoteConfigManager] (the single read + * front door via `topic()`/`body()`, and the owner of the generic best-effort prefetch) + [RemoteConfigBlobStore] + * + [RemoteConfigBlobFetcher] + [WorkflowsConfigProvider]. Only the backend transport and the blob HTTP download + * are faked; the disk cache is a stateful in-memory stand-in so reads see exactly what the sync committed. + */ +@OptIn(InternalRevenueCatAPI::class, ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +class WorkflowsConfigIntegrationTest { + + private lateinit var backend: Backend + private lateinit var diskCache: RemoteConfigDiskCache + private lateinit var blobStore: RemoteConfigBlobStore + private lateinit var provider: WorkflowsConfigProvider + private lateinit var manager: RemoteConfigManager + + private lateinit var onSuccess: (RCContainer?, VerificationResult) -> Unit + + /** Stateful stand-in for the persisted config file: write stashes, read returns the latest. */ + private var persistedState: PersistedRemoteConfigurationState? = null + + /** Stand-in for the blob downloader: ref -> bytes the fake HTTP layer serves to the fetcher. */ + private val downloads = mutableMapOf() + private var downloadCount = 0 + + // One scheduler for the manager's scope AND its ioDispatcher, so reads and the sync's persist all run + // eagerly inline under runTest(testDispatcher). + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = CoroutineScope(testDispatcher) + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + backend = mockk() + diskCache = mockk() + every { diskCache.read() } answers { persistedState } + every { diskCache.write(any()) } answers { persistedState = firstArg(); true } + blobStore = RemoteConfigBlobStore(context) + blobStore.retainOnly(emptySet()) // start from a clean blob dir between runs + val fetcher = RemoteConfigBlobFetcher( + blobStore = blobStore, + sourceProvider = FakeBlobSourceProvider, + urlConnectionFactory = fakeUrlConnectionFactory(), + scope = testScope, + ) + manager = RemoteConfigManager( + backend, + diskCache, + blobStore, + blobFetcher = fetcher, + scope = testScope, + ioDispatcher = testDispatcher, + ) + provider = WorkflowsConfigProvider(manager) + + every { + backend.getRemoteConfig(any(), any(), any(), any(), any(), any(), any()) + } answers { + onSuccess = arg(5) + } + } + + @Test + fun `a prefetched inline workflow is served without any download`() = runTest(testDispatcher) { + val workflowJson = JsonTools.json.encodeToString(PublishedWorkflow.serializer(), minimalWorkflow("wf-1")) + val config = """ + { + "domain": "app", + "manifest": "v1.workflows:etag1", + "active_topics": ["workflows"], + "prefetch_blobs": ["$INLINE_REF"], + "topics": { + "workflows": { + "wf-1": { "blob_ref": "$INLINE_REF", "offering_identifier": "premium_annual", "prefetch": true } + } + } + } + """.trimIndent() + + sync(config, INLINE_REF to workflowJson) + + assertThat(provider.workflowIdForOfferingId("premium_annual")).isEqualTo("wf-1") + val result = provider.getWorkflow("wf-1") + assertThat(result).isNotNull + assertThat(result!!.workflow.id).isEqualTo("wf-1") + // enrolled_variants is intentionally out of scope for the config-topic path (see WorkflowsConfigProvider). + assertThat(result.enrolledVariants).isNull() + // The body arrived inline, so neither prefetch nor the read touches the downloader. + assertThat(downloadCount).isZero() + } + + @Test + fun `getWorkflow assembles ui_config from its own topic's three inline parts`() = runTest(testDispatcher) { + val workflowJson = JsonTools.json.encodeToString(PublishedWorkflow.serializer(), minimalWorkflow("wf-1")) + val config = """ + { + "domain": "app", + "manifest": "v1.workflows:etag1", + "active_topics": ["workflows", "ui_config"], + "prefetch_blobs": ["$INLINE_REF"], + "topics": { + "workflows": { + "wf-1": { "blob_ref": "$INLINE_REF", "offering_identifier": "premium_annual", "prefetch": true } + }, + "ui_config": { + "app": { "colors": {}, "fonts": {} }, + "localizations": { "en_US": { "day": "Day" } }, + "variable_config": { "variable_compatibility_map": {}, "function_compatibility_map": {} } + } + } + } + """.trimIndent() + + sync(config, INLINE_REF to workflowJson) + + val result = provider.getWorkflow("wf-1") + assertThat(result).isNotNull + assertThat(result!!.workflow.uiConfig.localizations) + .isEqualTo(mapOf(LocaleId("en_US") to mapOf(VariableLocalizationKey.DAY to "Day"))) + } + + @Test + fun `getWorkflow defaults ui_config when the topic is absent`() = runTest(testDispatcher) { + val workflowJson = JsonTools.json.encodeToString(PublishedWorkflow.serializer(), minimalWorkflow("wf-1")) + val config = """ + { + "domain": "app", + "manifest": "v1.workflows:etag1", + "active_topics": ["workflows"], + "prefetch_blobs": ["$INLINE_REF"], + "topics": { + "workflows": { + "wf-1": { "blob_ref": "$INLINE_REF", "offering_identifier": "premium_annual", "prefetch": true } + } + } + } + """.trimIndent() + + sync(config, INLINE_REF to workflowJson) + + val result = provider.getWorkflow("wf-1") + assertThat(result).isNotNull + assertThat(result!!.workflow.uiConfig).isEqualTo(UiConfig()) + } + + @Test + fun `a prefetch-marked workflow is downloaded during the sync, before any read`() = runTest(testDispatcher) { + val workflowJson = JsonTools.json.encodeToString(PublishedWorkflow.serializer(), minimalWorkflow("wf-1")) + val ref = refOf(workflowJson.toByteArray()) + downloads[ref] = workflowJson.toByteArray() + val config = """ + { + "domain": "app", + "manifest": "v1.workflows:etag1", + "active_topics": ["workflows"], + "topics": { + "workflows": { "wf-1": { "blob_ref": "$ref", "prefetch": true } } + } + } + """.trimIndent() + + sync(config) // no inline blob; the manager prefetches it during the sync + + assertThat(blobStore.contains(ref)).isTrue() + assertThat(downloadCount).isEqualTo(1) + // The read is then served from disk — the shared fetcher does not download again. + assertThat(provider.getWorkflow("wf-1")).isNotNull + assertThat(downloadCount).isEqualTo(1) + } + + @Test + fun `a non-prefetched workflow commits metadata and downloads its body on demand`() = runTest(testDispatcher) { + val workflowJson = JsonTools.json.encodeToString(PublishedWorkflow.serializer(), minimalWorkflow("wf-1")) + val ref = refOf(workflowJson.toByteArray()) + downloads[ref] = workflowJson.toByteArray() + val config = """ + { + "domain": "app", + "manifest": "v1.workflows:etag1", + "active_topics": ["workflows"], + "topics": { + "workflows": { "wf-1": { "blob_ref": "$ref", "offering_identifier": "premium_annual" } } + } + } + """.trimIndent() + + sync(config) // not inlined, not prefetched: only the metadata commits during the sync + assertThat(downloadCount).isZero() + assertThat(manager.topic(RemoteConfigTopic.Workflows)?.containsKey("wf-1")).isTrue() + + // First read misses the blob store and pulls the body on demand. + val result = provider.getWorkflow("wf-1") + assertThat(result).isNotNull + assertThat(result!!.workflow.id).isEqualTo("wf-1") + assertThat(downloadCount).isEqualTo(1) + + // A second read is served from the store — no further download. + assertThat(provider.getWorkflow("wf-1")).isNotNull + assertThat(downloadCount).isEqualTo(1) + } + + @Test + fun `WorkflowManager kept as the seam serves through the config path by offering id`() = runTest(testDispatcher) { + val workflowJson = JsonTools.json.encodeToString(PublishedWorkflow.serializer(), minimalWorkflow("wf-1")) + val ref = refOf(workflowJson.toByteArray()) + downloads[ref] = workflowJson.toByteArray() + val config = """ + { + "domain": "app", + "manifest": "v1.workflows:etag1", + "active_topics": ["workflows"], + "topics": { + "workflows": { "wf-1": { "blob_ref": "$ref", "offering_identifier": "premium_annual" } } + } + } + """.trimIndent() + sync(config) + + // WorkflowManager is kept as the consumer-facing seam; only its data source moved to the config layer. + val workflowManager = WorkflowManager(workflowsConfigProvider = provider, scope = testScope) + + var delivered: WorkflowDataResult? = null + var error: PurchasesError? = null + workflowManager.getWorkflow( + workflowOrOfferingId = "premium_annual", // by offering id; resolved via config metadata, not a backend call + onSuccess = { delivered = it }, + onError = { error = it }, + ) + + assertThat(error).isNull() + assertThat(delivered?.workflow?.id).isEqualTo("wf-1") + assertThat(downloadCount).isEqualTo(1) // body pulled on demand through the manager + } + + private fun sync(configJson: String, vararg blobs: Pair) { + manager.refreshRemoteConfig(appInBackground = false, appUserID = "user-1") + onSuccess.invoke(containerWith(configJson, *blobs), VerificationResult.VERIFIED) + } + + private fun minimalWorkflow(id: String) = PublishedWorkflow( + id = id, + displayName = "Workflow $id", + initialStepId = "step-1", + steps = emptyMap(), + screens = emptyMap(), + uiConfig = UiConfig(), + ) + + private fun containerWith(configJson: String, vararg blobs: Pair): RCContainer { + val configElement = mockk() + every { configElement.decode() } returns ByteBuffer.wrap(configJson.toByteArray()) + val elements = blobs.associate { (ref, json) -> + val element = mockk() + val bytes = ByteBuffer.wrap(json.toByteArray()) + every { element.decode() } returns bytes + every { element.matchesChecksum(any()) } returns true + ref to element + } + val container = mockk() + every { container.config } returns configElement + every { container.elements } returns elements + return container + } + + private fun fakeUrlConnectionFactory() = object : UrlConnectionFactory { + override fun createConnection(url: String, requestMethod: String): UrlConnection { + val ref = url.substringAfterLast("/") + val bytes = downloads[ref] + if (bytes != null) { + downloadCount++ + } + return object : UrlConnection { + override val responseCode: Int = if (bytes == null) { + HttpURLConnection.HTTP_NOT_FOUND + } else { + HttpURLConnection.HTTP_OK + } + override val inputStream = ByteArrayInputStream(bytes ?: byteArrayOf()) + override fun disconnect() = Unit + } + } + } + + private companion object { + private val FakeBlobSourceProvider = object : RemoteConfigSourceProvider { + override fun getCurrent(purpose: RemoteConfigSourceHandle.Purpose): RemoteConfigSourceHandle = + RemoteConfigSourceHandle( + purpose = purpose, + source = RemoteConfigSource(url = "https://blob.test/{blob_ref}", priority = 1, weight = 1), + token = 0, + ) + + override fun reportUnhealthy(handle: RemoteConfigSourceHandle) = Unit + + override fun restart(purpose: RemoteConfigSourceHandle.Purpose) = Unit + + override fun restartIfExhausted(purpose: RemoteConfigSourceHandle.Purpose): Boolean = false + } + + // A valid content-address ref for the inline path (the store checks shape, not hash, on write). + private const val INLINE_REF = "abcdefghijklmnopqrstuvwxyz012345" + + /** `base64url-nopad(sha256(bytes)[0 until 24])` — the ref the workflow body hashes to. */ + private fun refOf(bytes: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(bytes).copyOf(24) + return Base64.encodeToString(digest, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + } + } +} diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt index baab916072..d129cb09ef 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt @@ -65,5 +65,5 @@ internal class MockPurchasesType( throw NotImplementedError("Mock implementation for previews only") } - override fun workflowIdForOfferingId(offeringId: String): String? = null + override suspend fun workflowIdForOfferingId(offeringId: String): String? = null } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt index 8e8ca4f738..98d89892ac 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt @@ -69,7 +69,7 @@ internal interface PurchasesType { @Throws(PurchasesException::class) suspend fun awaitGetWorkflow(workflowId: String): WorkflowDataResult - fun workflowIdForOfferingId(offeringId: String): String? + suspend fun workflowIdForOfferingId(offeringId: String): String? val useWorkflows: Boolean } @@ -147,7 +147,7 @@ internal class PurchasesImpl(private val purchases: Purchases = Purchases.shared } @OptIn(InternalRevenueCatAPI::class) - override fun workflowIdForOfferingId(offeringId: String): String? = + override suspend fun workflowIdForOfferingId(offeringId: String): String? = purchases.workflowIdForOfferingId(offeringId) @OptIn(InternalRevenueCatAPI::class) diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt index 11a7713189..373c285e27 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt @@ -62,5 +62,5 @@ internal class MockPurchasesType( throw NotImplementedError("Mock implementation") } - override fun workflowIdForOfferingId(offeringId: String): String? = null + override suspend fun workflowIdForOfferingId(offeringId: String): String? = null } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt index e1acecebe1..de7ee03092 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt @@ -233,7 +233,7 @@ class PaywallViewModelTest { coEvery { purchases.awaitSyncPurchases() } returns customerInfo every { purchases.preferredUILocaleOverride } returns null every { purchases.useWorkflows } returns false - every { purchases.workflowIdForOfferingId(any()) } returns null + coEvery { purchases.workflowIdForOfferingId(any()) } returns null every { listener.onPurchaseStarted(any()) } just runs every { listener.onPurchaseCompleted(any(), any()) } just runs @@ -1426,7 +1426,7 @@ class PaywallViewModelTest { screens = mapOf("screen-1" to workflowScreen), uiConfig = UiConfig(), ) - every { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns "wfl-test" + coEvery { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns "wfl-test" coEvery { purchases.awaitGetWorkflow("wfl-test") } returns WorkflowDataResult(workflow, null) val model = PaywallViewModelImpl( @@ -1485,7 +1485,7 @@ class PaywallViewModelTest { screens = mapOf("screen-1" to workflowScreen), uiConfig = UiConfig(), ) - every { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns "wfl-test" + coEvery { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns "wfl-test" coEvery { purchases.awaitGetWorkflow("wfl-test") } returns WorkflowDataResult(workflow, null) val model = PaywallViewModelImpl( @@ -3133,7 +3133,7 @@ class PaywallViewModelTest { // offeringWithWPL has no legacy paywall (offering.paywall == null), so it is served through // the workflows endpoint. With a mapped workflow id, that id (not the offering id) is used. val workflowId = "wfl-real-id" - every { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns workflowId + coEvery { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns workflowId val workflowScreen = WorkflowScreen( templateName = "template", revision = 0, @@ -3183,7 +3183,7 @@ class PaywallViewModelTest { // offeringWithWPL has no legacy paywall and no mapped workflow (not yet converted). It must // still hit the workflows endpoint, passing the offering id so the backend lazily converts // it — rather than falling back to a legacy paywall that does not exist. - every { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns null + coEvery { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns null val workflowScreen = WorkflowScreen( templateName = "template", revision = 0, From 7ec56120935d00c62f6b4807473769afae9d9882 Mon Sep 17 00:00:00 2001 From: Cesar de la Vega <664544+vegaro@users.noreply.github.com> Date: Fri, 3 Jul 2026 08:15:19 +0200 Subject: [PATCH 2/4] feat(paywalls): fall back to the offerings paywall when the workflow fetch fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /v1/config endpoint can be disabled for the session after a 4xx (the kill switch), be unreachable, or simply have no workflow for the offering yet. In all of those cases the offering still carries the paywall that /offerings delivered, so render that through the regular components path instead of surfacing PaywallState.Error — apps keep showing a paywall when the config layer can't serve one. The error state remains for offerings with nothing to fall back to. Co-Authored-By: Claude Fable 5 --- .../ui/revenuecatui/data/PaywallViewModel.kt | 20 +++++-- .../revenuecatui/data/PaywallViewModelTest.kt | 58 +++++++++++++++++++ 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index 74615cffcd..e76daa7f3d 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -799,14 +799,26 @@ internal class PaywallViewModelImpl( val resolvedOfferingSelection = resolveOfferingSelection(offeringSelection) val selectedOffering = resolvedOfferingSelection.selectedOffering - // When workflows are enabled, every non-legacy paywall is served through the /workflows - // endpoint. `offering.paywall == null` is the durable marker of a non-legacy (workflow) + // When workflows are enabled, every non-legacy paywall is served through the workflows + // path. `offering.paywall == null` is the durable marker of a non-legacy (workflow) // paywall: a legacy v1 paywall always carries `offering.paywall`, and that field stays // even after `paywallComponents` is removed and all V2 paywalls move to workflows. We // deliberately do NOT gate on `paywallComponents`, which is going away. if (useWorkflowsEndpoint && selectedOffering != null && selectedOffering.paywall == null) { - presentWorkflow(selectedOffering, resolvedOfferingSelection.offeringsForExitOfferLookup) - return + try { + presentWorkflow(selectedOffering, resolvedOfferingSelection.offeringsForExitOfferLookup) + return + } catch (e: PurchasesException) { + // The workflow could not be served — the config endpoint is disabled for the session + // (4xx kill switch), unreachable, or the offering has no workflow yet. Degrade to the + // paywall /offerings already delivered so the app still shows something; rethrow + // (→ PaywallState.Error) only when there is nothing to fall back to. + if (selectedOffering.paywallComponents == null) throw e + Logger.w( + "Paywalls: Failed to fetch workflow for offering '${selectedOffering.identifier}' " + + "(${e.message}). Falling back to the offerings-provided paywall.", + ) + } } val exitOfferingId = selectedOffering?.paywallComponents?.data?.exitOffers?.dismiss?.offeringId diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt index de7ee03092..b90ff72290 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt @@ -3227,6 +3227,64 @@ class PaywallViewModelTest { coVerify(exactly = 1) { purchases.awaitGetWorkflow(offeringWithWPL.identifier) } } + @Test + fun `when useWorkflows is true and the workflow fetch fails, falls back to the offerings-provided paywall`() { + // The config endpoint may be disabled for the session (4xx kill switch) or unreachable, or the + // offering may have no workflow yet. The offering still carries the paywall /offerings delivered, + // so the paywall renders through the regular components path instead of erroring. + coEvery { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns null + coEvery { purchases.awaitGetWorkflow(offeringWithWPL.identifier) } throws PurchasesException( + PurchasesError(PurchasesErrorCode.UnknownError, "Workflow is unavailable from remote config."), + ) + + val model = PaywallViewModelImpl( + MockResourceProvider(), + purchases, + PaywallOptions.Builder(dismissRequest = { dismissInvoked = true }) + .setListener(listener) + .setOffering(offeringWithWPL) + .build(), + TestData.Constants.currentColorScheme, + isDarkMode = false, + shouldDisplayBlock = null, + useWorkflowsEndpoint = true, + ) + + assertThat(model.state.value).isInstanceOf(PaywallState.Loaded.Components::class.java) + coVerify(exactly = 1) { purchases.awaitGetWorkflow(offeringWithWPL.identifier) } + } + + @Test + fun `when useWorkflows is true and the workflow fetch fails with no fallback paywall, surfaces the error`() { + val offeringWithoutPaywallData = Offering( + identifier = "offering-no-paywall-data", + serverDescription = "description", + metadata = emptyMap(), + availablePackages = listOf(TestData.Packages.monthly), + paywallComponents = null, + webCheckoutURL = null, + ) + coEvery { purchases.workflowIdForOfferingId(offeringWithoutPaywallData.identifier) } returns null + coEvery { purchases.awaitGetWorkflow(offeringWithoutPaywallData.identifier) } throws PurchasesException( + PurchasesError(PurchasesErrorCode.UnknownError, "Workflow is unavailable from remote config."), + ) + + val model = PaywallViewModelImpl( + MockResourceProvider(), + purchases, + PaywallOptions.Builder(dismissRequest = { dismissInvoked = true }) + .setListener(listener) + .setOffering(offeringWithoutPaywallData) + .build(), + TestData.Constants.currentColorScheme, + isDarkMode = false, + shouldDisplayBlock = null, + useWorkflowsEndpoint = true, + ) + + assertThat(model.state.value).isInstanceOf(PaywallState.Error::class.java) + } + private fun create( offering: Offering? = null, customPurchaseLogic: PaywallPurchaseLogic? = null, From f667671ca5009f55403d9257f28429447bb15714 Mon Sep 17 00:00:00 2001 From: Cesar de la Vega <664544+vegaro@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:52:37 +0200 Subject: [PATCH 3/4] refactor(workflows): give ui_config its own independent read path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublishedWorkflow.uiConfig was parsed with a default and immediately overwritten from a separate ui_config topic read — UiConfig isn't workflow-scoped. Remove the field and add a symmetric read path: PurchasesOrchestrator.getUiConfig / Purchases.awaitGetUiConfig, mirroring getWorkflow/awaitGetWorkflow at every layer. WorkflowScreenMapper/PaywallViewModel wiring is left for a follow-up task; WorkflowAssetPreDownloader's font pre-download is temporarily a no-op since ui_config no longer lives on the workflow it receives. --- .../com/revenuecat/purchases/Purchases.kt | 10 +++ .../revenuecat/purchases/PurchasesFactory.kt | 3 + .../purchases/PurchasesOrchestrator.kt | 48 +++++++++++ .../common/workflows/WorkflowModels.kt | 4 - .../workflows/WorkflowsConfigProvider.kt | 5 -- .../utils/WorkflowAssetPreDownloader.kt | 5 +- .../revenuecat/purchases/BasePurchasesTest.kt | 3 + .../purchases/PurchasesCommonTest.kt | 80 +++++++++++++++++++ .../common/workflows/PublishedWorkflowTest.kt | 2 - .../common/workflows/WorkflowManagerTest.kt | 2 - .../WorkflowsConfigIntegrationTest.kt | 62 +++++++++----- .../utils/WorkflowAssetPreDownloaderTest.kt | 16 +--- .../ui/revenuecatui/data/MockPurchasesType.kt | 5 ++ .../ui/revenuecatui/data/PurchasesType.kt | 7 ++ .../ui/revenuecatui/data/MockPurchasesType.kt | 5 ++ .../revenuecatui/data/PaywallViewModelTest.kt | 5 -- .../data/PaywallViewModelWorkflowTest.kt | 9 --- .../workflow/WorkflowNavigatorTest.kt | 4 - 18 files changed, 206 insertions(+), 69 deletions(-) diff --git a/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt b/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt index 85e6359ad6..2a1237b3de 100644 --- a/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt +++ b/purchases/src/defaults/kotlin/com/revenuecat/purchases/Purchases.kt @@ -425,6 +425,16 @@ public class Purchases internal constructor( ) } + @InternalRevenueCatAPI + @JvmSynthetic + @Throws(PurchasesException::class) + public suspend fun awaitGetUiConfig(): UiConfig = suspendCoroutine { continuation -> + purchasesOrchestrator.getUiConfig( + onSuccess = continuation::resume, + onError = { continuation.resumeWithException(PurchasesException(it)) }, + ) + } + @InternalRevenueCatAPI @JvmSynthetic public suspend fun workflowIdForOfferingId(offeringId: String): String? = diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt index eddab6403d..f561ab63da 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt @@ -40,6 +40,7 @@ import com.revenuecat.purchases.common.offlineentitlements.PurchasedProductsFetc import com.revenuecat.purchases.common.remoteconfig.RemoteConfigBlobStore import com.revenuecat.purchases.common.remoteconfig.RemoteConfigDiskCache import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager +import com.revenuecat.purchases.common.remoteconfig.UiConfigProvider import com.revenuecat.purchases.common.verification.SignatureVerificationMode import com.revenuecat.purchases.common.verification.SigningManager import com.revenuecat.purchases.common.warnLog @@ -279,6 +280,7 @@ internal class PurchasesFactory( } else { null } + val uiConfigProvider = remoteConfigManager?.let { UiConfigProvider(it) } val identityManager = IdentityManager( cache, @@ -467,6 +469,7 @@ internal class PurchasesFactory( workflowManager = workflowManager, fileRepository = fileRepository, remoteConfigManager = remoteConfigManager, + uiConfigProvider = uiConfigProvider, ) return Purchases(purchasesOrchestrator) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt index 19e0e9ecef..b23d02b9fd 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt @@ -48,6 +48,7 @@ import com.revenuecat.purchases.common.log import com.revenuecat.purchases.common.offerings.OfferingsManager import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager +import com.revenuecat.purchases.common.remoteconfig.UiConfigProvider import com.revenuecat.purchases.common.sha1 import com.revenuecat.purchases.common.subscriberattributes.SubscriberAttributeKey import com.revenuecat.purchases.common.verboseLog @@ -106,6 +107,11 @@ import com.revenuecat.purchases.utils.Result import com.revenuecat.purchases.utils.isAndroidNOrNewer import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencies import com.revenuecat.purchases.virtualcurrencies.VirtualCurrencyManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import java.net.URL import java.util.Collections import java.util.Date @@ -162,10 +168,13 @@ internal class PurchasesOrchestrator( private val backupManager: BackupManager = BackupManager(application), val fileRepository: FileRepository = DefaultFileRepository(application), private val remoteConfigManager: RemoteConfigManager? = null, + private val uiConfigProvider: UiConfigProvider? = null, @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) val adTracker: AdTracker = AdTracker(adEventsManager), ) : LifecycleDelegate, CustomActivityLifecycleHandler { + private val uiConfigScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + internal var state: PurchasesState get() = purchasesStateCache.purchasesState set(value) { @@ -616,6 +625,44 @@ internal class PurchasesOrchestrator( suspend fun workflowIdForOfferingId(offeringId: String): String? = workflowManager?.workflowIdForOfferingId(offeringId) + fun getUiConfig( + onSuccess: (UiConfig) -> Unit, + onError: (PurchasesError) -> Unit, + ) { + if (appConfig.uiPreviewMode) { + dispatch { + onError( + PurchasesError( + PurchasesErrorCode.ConfigurationError, + "UI config cannot be fetched in UI preview mode.", + ), + ) + } + return + } + if (uiConfigProvider == null) { + dispatch { + onError( + PurchasesError( + PurchasesErrorCode.ConfigurationError, + "UI config is not enabled.", + ), + ) + } + return + } + uiConfigScope.launch { + try { + val uiConfig = uiConfigProvider.getUiConfig() + dispatch { onSuccess(uiConfig) } + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + dispatch { + onError(PurchasesError(PurchasesErrorCode.UnknownError, "Failed to fetch UI config: ${e.message}")) + } + } + } + } + fun getProducts( productIds: List, type: ProductType? = null, @@ -850,6 +897,7 @@ internal class PurchasesOrchestrator( this.backend.close() this.remoteConfigManager?.close() this.workflowManager?.close() + this.uiConfigScope.cancel() billing.close() updatedCustomerInfoListener = null // Do not call on state since the setter does more stuff diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt index ff14ac3523..a68d023c84 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt @@ -3,7 +3,6 @@ package com.revenuecat.purchases.common.workflows import com.revenuecat.purchases.InternalRevenueCatAPI -import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig import com.revenuecat.purchases.paywalls.components.common.ExitOffers import com.revenuecat.purchases.paywalls.components.common.LocaleId @@ -141,9 +140,6 @@ public data class PublishedWorkflow( @SerialName("initial_step_id") val initialStepId: String, val steps: Map, val screens: Map, - // Not sent by the config-topic path (ui_config is its own topic); defaults rather than failing the whole - // workflow, and the reader swaps the resolved UiConfig in. - @SerialName("ui_config") val uiConfig: UiConfig = UiConfig(), @Serializable(with = JsonObjectToMapSerializer::class) val metadata: Map = emptyMap(), val hash: String? = null, diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowsConfigProvider.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowsConfigProvider.kt index f6363009ac..e36c99880c 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowsConfigProvider.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowsConfigProvider.kt @@ -7,7 +7,6 @@ import com.revenuecat.purchases.common.debugLog import com.revenuecat.purchases.common.errorLog import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager import com.revenuecat.purchases.common.remoteconfig.RemoteConfigTopic -import com.revenuecat.purchases.common.remoteconfig.UiConfigProvider import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive @@ -20,7 +19,6 @@ import kotlinx.serialization.json.JsonPrimitive */ internal class WorkflowsConfigProvider( private val manager: RemoteConfigManager, - private val uiConfigProvider: UiConfigProvider = UiConfigProvider(manager), ) { suspend fun workflowIdForOfferingId(offeringId: String): String? { @@ -50,10 +48,7 @@ internal class WorkflowsConfigProvider( return null } return try { - // ui_config is its own topic (WFL-374), no longer embedded in the workflow body; the parsed - // uiConfig defaults to an empty UiConfig() until it's resolved and swapped in here. val workflow = WorkflowJsonParser.parsePublishedWorkflow(body.decodeToString()) - .copy(uiConfig = uiConfigProvider.getUiConfig()) debugLog { "Parsed workflow '$workflowId' (${workflow.steps.size} step(s))" } // enrolled_variants is out of scope for this spike; it does not fit the topic-dedup model and is // being designed separately. diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/WorkflowAssetPreDownloader.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/WorkflowAssetPreDownloader.kt index 10c6942d9e..77be886af6 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/WorkflowAssetPreDownloader.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/WorkflowAssetPreDownloader.kt @@ -24,6 +24,9 @@ internal class WorkflowAssetPreDownloader( workflow.screens.values.forEach { screen -> paywallComponentsImagePreDownloader.preDownloadImages(screen.componentsConfig.base) } - offeringFontPreDownloader.preDownloadFontsIfNeeded(workflow.uiConfig.app.fonts.values) + // ui_config no longer lives on PublishedWorkflow — it has its own independent read path + // (UiConfigProvider / PurchasesOrchestrator.getUiConfig). Font pre-download needs to be re-wired to + // pull fonts from that path; left as a no-op pending that wiring. + offeringFontPreDownloader.preDownloadFontsIfNeeded(emptyList()) } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt index 633d4a2fb2..c9fa930d7b 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt @@ -27,6 +27,7 @@ import com.revenuecat.purchases.common.diagnostics.DiagnosticsTracker import com.revenuecat.purchases.common.events.EventsManager import com.revenuecat.purchases.common.offerings.OfferingsManager import com.revenuecat.purchases.common.remoteconfig.RemoteConfigManager +import com.revenuecat.purchases.common.remoteconfig.UiConfigProvider import com.revenuecat.purchases.common.workflows.WorkflowManager import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager import com.revenuecat.purchases.deeplinks.WebPurchaseRedemptionHelper @@ -95,6 +96,7 @@ internal open class BasePurchasesTest { internal val mockPurchaseParamsValidator = mockk() internal val mockWorkflowManager = mockk(relaxed = true) internal val mockRemoteConfigManager = mockk(relaxed = true) + internal val mockUiConfigProvider = mockk(relaxed = true) private val mockBlockstoreHelper = mockk() private val purchasesStateProvider = PurchasesStateCache(PurchasesState()) @@ -513,6 +515,7 @@ internal open class BasePurchasesTest { purchaseParamsValidator = mockPurchaseParamsValidator, workflowManager = mockWorkflowManager, remoteConfigManager = mockRemoteConfigManager, + uiConfigProvider = mockUiConfigProvider, ) purchases = Purchases( diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt index 72d009d0cb..27e379039f 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt @@ -5,6 +5,7 @@ package com.revenuecat.purchases +import android.os.Looper import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingResult @@ -41,6 +42,8 @@ import com.revenuecat.purchases.utils.stubPricingPhase import com.revenuecat.purchases.utils.stubStoreProduct import com.revenuecat.purchases.utils.stubSubscriptionOption import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -54,9 +57,12 @@ import org.junit.After import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config import java.util.Collections.emptyList import java.util.Date +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.milliseconds @RunWith(AndroidJUnit4::class) @@ -69,6 +75,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { private val inAppPurchaseToken = "token_inapp" private val subProductId = "sub" private val subPurchaseToken = "token_sub" + private val defaultTimeout = 2000L private val initiationSource = PostReceiptInitiationSource.PURCHASE @@ -2907,6 +2914,79 @@ internal class PurchasesCommonTest: BasePurchasesTest() { // endregion + // region getUiConfig + + @Test + fun `getUiConfig delivers the provider's result to the caller`() { + val expected = UiConfig() + coEvery { mockUiConfigProvider.getUiConfig() } returns expected + + var received: UiConfig? = null + var receivedError: PurchasesError? = null + val latch = CountDownLatch(1) + purchases.purchasesOrchestrator.getUiConfig( + onSuccess = { received = it; latch.countDown() }, + onError = { receivedError = it; latch.countDown() }, + ) + + awaitMainLooperCallback(latch) + assertThat(received).isEqualTo(expected) + assertThat(receivedError).isNull() + } + + @Test + fun `getUiConfig delivers a failure as an error to the caller`() { + coEvery { mockUiConfigProvider.getUiConfig() } throws RuntimeException("boom") + + var received: UiConfig? = null + var receivedError: PurchasesError? = null + val latch = CountDownLatch(1) + purchases.purchasesOrchestrator.getUiConfig( + onSuccess = { received = it; latch.countDown() }, + onError = { receivedError = it; latch.countDown() }, + ) + + awaitMainLooperCallback(latch) + assertThat(receivedError).isNotNull + assertThat(received).isNull() + } + + /** + * `getUiConfig` delivers through `uiConfigScope` (a real `Dispatchers.IO` coroutine), then posts the + * callback to the main-thread handler. Robolectric's main looper is paused by default, so the posted + * callback sits queued until idled; poll-idle it while waiting on [latch] so we pick the callback up as + * soon as the background coroutine posts it, instead of guessing a fixed delay. + */ + private fun awaitMainLooperCallback(latch: CountDownLatch) { + val deadline = System.currentTimeMillis() + defaultTimeout + while (latch.count > 0 && System.currentTimeMillis() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + latch.await(10, TimeUnit.MILLISECONDS) + } + assertThat(latch.await(0, TimeUnit.MILLISECONDS)).isTrue() + } + + @Test + fun `getUiConfig returns ConfigurationError and never calls the provider when uiPreviewMode is true`() { + buildPurchases(anonymous = true, uiPreviewMode = true) + + var received: UiConfig? = null + var receivedError: PurchasesError? = null + purchases.purchasesOrchestrator.getUiConfig( + onSuccess = { received = it }, + onError = { receivedError = it }, + ) + + assertThat(receivedError).isNotNull + assertThat(receivedError!!.code).isEqualTo(PurchasesErrorCode.ConfigurationError) + assertThat(received).isNull() + coVerify(exactly = 0) { + mockUiConfigProvider.getUiConfig() + } + } + + // endregion + // region queryPurchases @Test diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/workflows/PublishedWorkflowTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/PublishedWorkflowTest.kt index 8710608043..b3756a3908 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/workflows/PublishedWorkflowTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/PublishedWorkflowTest.kt @@ -1,7 +1,6 @@ package com.revenuecat.purchases.common.workflows import com.revenuecat.purchases.InternalRevenueCatAPI -import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.paywalls.components.StackComponent import com.revenuecat.purchases.paywalls.components.common.Background import com.revenuecat.purchases.paywalls.components.common.ComponentsConfig @@ -82,7 +81,6 @@ class PublishedWorkflowTest { initialStepId = "step-1", steps = steps, screens = screens, - uiConfig = UiConfig(), singleStepFallbackId = singleStepFallbackId, ) diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowManagerTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowManagerTest.kt index edc4488866..b61c4d3284 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowManagerTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowManagerTest.kt @@ -2,7 +2,6 @@ package com.revenuecat.purchases.common.workflows import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.PurchasesError -import com.revenuecat.purchases.UiConfig import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.CoroutineScope @@ -71,7 +70,6 @@ class WorkflowManagerTest { initialStepId = "step-1", steps = emptyMap(), screens = emptyMap(), - uiConfig = UiConfig(), ), enrolledVariants = null, ) diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowsConfigIntegrationTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowsConfigIntegrationTest.kt index 28bd934808..1937175a3a 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowsConfigIntegrationTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowsConfigIntegrationTest.kt @@ -20,6 +20,7 @@ import com.revenuecat.purchases.common.remoteconfig.RemoteConfigSource import com.revenuecat.purchases.common.remoteconfig.RemoteConfigSourceHandle import com.revenuecat.purchases.common.remoteconfig.RemoteConfigSourceProvider import com.revenuecat.purchases.common.remoteconfig.RemoteConfigTopic +import com.revenuecat.purchases.common.remoteconfig.UiConfigProvider import com.revenuecat.purchases.paywalls.components.common.LocaleId import com.revenuecat.purchases.paywalls.components.common.VariableLocalizationKey import com.revenuecat.purchases.utils.UrlConnection @@ -30,6 +31,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test @@ -133,18 +135,13 @@ class WorkflowsConfigIntegrationTest { } @Test - fun `getWorkflow assembles ui_config from its own topic's three inline parts`() = runTest(testDispatcher) { - val workflowJson = JsonTools.json.encodeToString(PublishedWorkflow.serializer(), minimalWorkflow("wf-1")) + fun `UiConfigProvider assembles ui_config from its own topic's three inline parts`() = runTest(testDispatcher) { val config = """ { "domain": "app", "manifest": "v1.workflows:etag1", - "active_topics": ["workflows", "ui_config"], - "prefetch_blobs": ["$INLINE_REF"], + "active_topics": ["ui_config"], "topics": { - "workflows": { - "wf-1": { "blob_ref": "$INLINE_REF", "offering_identifier": "premium_annual", "prefetch": true } - }, "ui_config": { "app": { "colors": {}, "fonts": {} }, "localizations": { "en_US": { "day": "Day" } }, @@ -154,36 +151,58 @@ class WorkflowsConfigIntegrationTest { } """.trimIndent() - sync(config, INLINE_REF to workflowJson) + sync(config) - val result = provider.getWorkflow("wf-1") - assertThat(result).isNotNull - assertThat(result!!.workflow.uiConfig.localizations) + val uiConfig = UiConfigProvider(manager).getUiConfig() + assertThat(uiConfig.localizations) .isEqualTo(mapOf(LocaleId("en_US") to mapOf(VariableLocalizationKey.DAY to "Day"))) } @Test - fun `getWorkflow defaults ui_config when the topic is absent`() = runTest(testDispatcher) { - val workflowJson = JsonTools.json.encodeToString(PublishedWorkflow.serializer(), minimalWorkflow("wf-1")) + fun `UiConfigProvider defaults ui_config when the topic is absent`() = runTest(testDispatcher) { val config = """ { "domain": "app", "manifest": "v1.workflows:etag1", - "active_topics": ["workflows"], - "prefetch_blobs": ["$INLINE_REF"], + "active_topics": [], + "topics": {} + } + """.trimIndent() + + sync(config) + + val uiConfig = UiConfigProvider(manager).getUiConfig() + assertThat(uiConfig).isEqualTo(UiConfig()) + } + + @Test + fun `UiConfigProvider propagates a malformed part instead of defaulting`() = runTest(testDispatcher) { + // "colors" should be an object (alias -> ColorScheme); an array is a shape mismatch that should + // surface as a decode failure, not silently default like a missing part does. + val config = """ + { + "domain": "app", + "manifest": "v1.workflows:etag1", + "active_topics": ["ui_config"], "topics": { - "workflows": { - "wf-1": { "blob_ref": "$INLINE_REF", "offering_identifier": "premium_annual", "prefetch": true } + "ui_config": { + "app": { "colors": [], "fonts": {} }, + "localizations": { "en_US": { "day": "Day" } }, + "variable_config": { "variable_compatibility_map": {}, "function_compatibility_map": {} } } } } """.trimIndent() - sync(config, INLINE_REF to workflowJson) + sync(config) - val result = provider.getWorkflow("wf-1") - assertThat(result).isNotNull - assertThat(result!!.workflow.uiConfig).isEqualTo(UiConfig()) + var thrown: Throwable? = null + try { + UiConfigProvider(manager).getUiConfig() + } catch (e: SerializationException) { + thrown = e + } + assertThat(thrown).isNotNull } @Test @@ -286,7 +305,6 @@ class WorkflowsConfigIntegrationTest { initialStepId = "step-1", steps = emptyMap(), screens = emptyMap(), - uiConfig = UiConfig(), ) private fun containerWith(configJson: String, vararg blobs: Pair): RCContainer { diff --git a/purchases/src/test/java/com/revenuecat/purchases/utils/WorkflowAssetPreDownloaderTest.kt b/purchases/src/test/java/com/revenuecat/purchases/utils/WorkflowAssetPreDownloaderTest.kt index 67e1c84a62..180df35f7c 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/utils/WorkflowAssetPreDownloaderTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/utils/WorkflowAssetPreDownloaderTest.kt @@ -1,6 +1,5 @@ package com.revenuecat.purchases.utils -import com.revenuecat.purchases.FontAlias import com.revenuecat.purchases.LogHandler import com.revenuecat.purchases.NoOpLogHandler import com.revenuecat.purchases.UiConfig @@ -47,35 +46,24 @@ class WorkflowAssetPreDownloaderTest { } @Test - fun `preDownloadWorkflowAssets downloads screen images and workflow fonts`() { + fun `preDownloadWorkflowAssets downloads screen images`() { val screenConfig = mockk() val localizations = mapOf( LocaleId("en_US") to mapOf( LocalizationKey("title") to LocalizationData.Text("Title"), ), ) - val font = UiConfig.AppConfig.FontsConfig( - android = UiConfig.AppConfig.FontsConfig.FontInfo.GoogleFonts("Roboto"), - ) val workflow = createWorkflow( screens = mapOf( "screen_1" to createScreen(screenConfig, localizations), ), - uiConfig = UiConfig( - app = UiConfig.AppConfig( - fonts = mapOf(FontAlias("font_1") to font), - ), - ), ) preDownloader.preDownloadWorkflowAssets(workflow) - val fontsSlot = slot>() verify(exactly = 1) { imagePreDownloader.preDownloadImages(screenConfig) - fontPreDownloader.preDownloadFontsIfNeeded(capture(fontsSlot)) } - assertThat(fontsSlot.captured).containsExactly(font) } @Test @@ -98,7 +86,6 @@ class WorkflowAssetPreDownloaderTest { private fun createWorkflow( screens: Map, - uiConfig: UiConfig = UiConfig(), ): PublishedWorkflow { return PublishedWorkflow( id = "workflow_1", @@ -106,7 +93,6 @@ class WorkflowAssetPreDownloaderTest { initialStepId = "step_1", steps = emptyMap(), screens = screens, - uiConfig = uiConfig, ) } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt index d129cb09ef..a234317741 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt @@ -7,6 +7,7 @@ import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.PurchaseParams import com.revenuecat.purchases.PurchaseResult import com.revenuecat.purchases.PurchasesAreCompletedBy +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.common.events.FeatureEvent import com.revenuecat.purchases.common.workflows.WorkflowDataResult import com.revenuecat.purchases.customercenter.CustomerCenterConfigData @@ -65,5 +66,9 @@ internal class MockPurchasesType( throw NotImplementedError("Mock implementation for previews only") } + override suspend fun awaitGetUiConfig(): UiConfig { + throw NotImplementedError("Mock implementation for previews only") + } + override suspend fun workflowIdForOfferingId(offeringId: String): String? = null } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt index 98d89892ac..0e622bcfe1 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PurchasesType.kt @@ -10,6 +10,7 @@ import com.revenuecat.purchases.PurchaseResult import com.revenuecat.purchases.Purchases import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.awaitCreateSupportTicket import com.revenuecat.purchases.awaitCustomerCenterConfigData import com.revenuecat.purchases.awaitCustomerInfo @@ -69,6 +70,9 @@ internal interface PurchasesType { @Throws(PurchasesException::class) suspend fun awaitGetWorkflow(workflowId: String): WorkflowDataResult + @Throws(PurchasesException::class) + suspend fun awaitGetUiConfig(): UiConfig + suspend fun workflowIdForOfferingId(offeringId: String): String? val useWorkflows: Boolean @@ -146,6 +150,9 @@ internal class PurchasesImpl(private val purchases: Purchases = Purchases.shared return purchases.awaitGetWorkflow(workflowId) } + @Throws(PurchasesException::class) + override suspend fun awaitGetUiConfig(): UiConfig = purchases.awaitGetUiConfig() + @OptIn(InternalRevenueCatAPI::class) override suspend fun workflowIdForOfferingId(offeringId: String): String? = purchases.workflowIdForOfferingId(offeringId) diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt index 373c285e27..987f75f9be 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/MockPurchasesType.kt @@ -7,6 +7,7 @@ import com.revenuecat.purchases.Offerings import com.revenuecat.purchases.PurchaseParams import com.revenuecat.purchases.PurchaseResult import com.revenuecat.purchases.PurchasesAreCompletedBy +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.common.events.FeatureEvent import com.revenuecat.purchases.common.workflows.WorkflowDataResult import com.revenuecat.purchases.customercenter.CustomerCenterConfigData @@ -62,5 +63,9 @@ internal class MockPurchasesType( throw NotImplementedError("Mock implementation") } + override suspend fun awaitGetUiConfig(): UiConfig { + throw NotImplementedError("Mock implementation") + } + override suspend fun workflowIdForOfferingId(offeringId: String): String? = null } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt index b90ff72290..87cc70c6cf 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt @@ -1424,7 +1424,6 @@ class PaywallViewModelTest { initialStepId = "step-1", steps = mapOf("step-1" to stepOne, "step-2" to stepTwo), screens = mapOf("screen-1" to workflowScreen), - uiConfig = UiConfig(), ) coEvery { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns "wfl-test" coEvery { purchases.awaitGetWorkflow("wfl-test") } returns WorkflowDataResult(workflow, null) @@ -1483,7 +1482,6 @@ class PaywallViewModelTest { initialStepId = "step-1", steps = mapOf("step-1" to stepOne), screens = mapOf("screen-1" to workflowScreen), - uiConfig = UiConfig(), ) coEvery { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns "wfl-test" coEvery { purchases.awaitGetWorkflow("wfl-test") } returns WorkflowDataResult(workflow, null) @@ -3069,7 +3067,6 @@ class PaywallViewModelTest { initialStepId = "step-1", steps = mapOf("step-1" to stepOne), screens = mapOf("screen-1" to workflowScreen), - uiConfig = UiConfig(), ) val model = PaywallViewModelImpl( @@ -3156,7 +3153,6 @@ class PaywallViewModelTest { initialStepId = "step-1", steps = mapOf("step-1" to stepOne), screens = mapOf("screen-1" to workflowScreen), - uiConfig = UiConfig(), ) coEvery { purchases.awaitGetWorkflow(workflowId) } returns WorkflowDataResult(workflow, null) @@ -3206,7 +3202,6 @@ class PaywallViewModelTest { initialStepId = "step-1", steps = mapOf("step-1" to stepOne), screens = mapOf("screen-1" to workflowScreen), - uiConfig = UiConfig(), ) coEvery { purchases.awaitGetWorkflow(offeringWithWPL.identifier) } returns WorkflowDataResult(workflow, null) diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt index ae358eec7a..d3327fcbf2 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt @@ -53,7 +53,6 @@ import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallResult import com.revenuecat.purchases.ui.revenuecatui.utils.Resumable import com.revenuecat.purchases.ui.revenuecatui.data.testdata.MockResourceProvider import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData -import com.revenuecat.purchases.ui.revenuecatui.helpers.UiConfig import com.revenuecat.purchases.ui.revenuecatui.workflow.NavigationDirection import io.mockk.Runs import io.mockk.clearAllMocks @@ -253,7 +252,6 @@ class PaywallViewModelWorkflowTest { initialStepId = "step-1", steps = mapOf("step-1" to step1, "step-2" to step2), screens = mapOf(screenId1 to makeScreen(screenId1), screenId2 to makeScreen(screenId2)), - uiConfig = UiConfig(), metadata = emptyMap(), singleStepFallbackId = "step-1", ) @@ -304,7 +302,6 @@ class PaywallViewModelWorkflowTest { screenId1 to makeScreen(screenId1), screenId2 to makeScreenWithExitOffer(screenId2), ), - uiConfig = UiConfig(), metadata = emptyMap(), singleStepFallbackId = "step-2", ) @@ -327,7 +324,6 @@ class PaywallViewModelWorkflowTest { initialStepId = "step-only", steps = mapOf("step-only" to singleStep), screens = mapOf(screenId1 to makeScreenWithExitOffer(screenId1)), - uiConfig = UiConfig(), metadata = emptyMap(), singleStepFallbackId = "step-only", ) @@ -347,7 +343,6 @@ class PaywallViewModelWorkflowTest { screenId1 to makeScreenWithExitOffer(screenId1), screenId2 to makeScreen(screenId2), ), - uiConfig = UiConfig(), metadata = emptyMap(), singleStepFallbackId = "step-1", ) @@ -431,7 +426,6 @@ class PaywallViewModelWorkflowTest { initialStepId = "step-1", steps = mapOf("step-1" to step1WithEmptyOffering), screens = mapOf("screen-empty-initial" to screenWithEmptyOffering2), - uiConfig = UiConfig(), metadata = emptyMap(), ) private val fetchResultFailingInitial = WorkflowDataResult(workflow = workflowFailingInitial, enrolledVariants = null) @@ -442,7 +436,6 @@ class PaywallViewModelWorkflowTest { initialStepId = "step-1", steps = mapOf("step-1" to step1ToStep3, "step-3" to step3EmptyOffering), screens = mapOf(screenId1 to makeScreen(screenId1), screenEmptyId to screenWithEmptyOffering), - uiConfig = UiConfig(), metadata = emptyMap(), ) private val fetchResultToError = WorkflowDataResult(workflow = workflowToError, enrolledVariants = null) @@ -816,7 +809,6 @@ class PaywallViewModelWorkflowTest { initialStepId = "step-A", steps = mapOf("step-A" to stepA, "step-B" to stepB, "step-C" to stepC), screens = mapOf(screenAId to earlyScreen1, screenBId to earlyScreen2, screenCId to terminalScreen3), - uiConfig = UiConfig(), metadata = emptyMap(), singleStepFallbackId = "step-C", ) @@ -1142,7 +1134,6 @@ class PaywallViewModelWorkflowTest { "screen-empty-initial" to screenWithEmptyOffering2, screenId2 to makeScreen(screenId2), ), - uiConfig = UiConfig(), metadata = emptyMap(), singleStepFallbackId = "step-2", ) diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/workflow/WorkflowNavigatorTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/workflow/WorkflowNavigatorTest.kt index ec641966c8..b6228e5c81 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/workflow/WorkflowNavigatorTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/workflow/WorkflowNavigatorTest.kt @@ -9,7 +9,6 @@ import com.revenuecat.purchases.common.workflows.WorkflowStep import com.revenuecat.purchases.common.workflows.WorkflowTrigger import com.revenuecat.purchases.common.workflows.WorkflowTriggerAction import com.revenuecat.purchases.common.workflows.WorkflowTriggerType -import com.revenuecat.purchases.ui.revenuecatui.helpers.UiConfig import org.assertj.core.api.Assertions.assertThat import org.junit.Test @@ -89,7 +88,6 @@ class WorkflowNavigatorTest { "step-4" to step4, ), screens = emptyMap(), - uiConfig = UiConfig(), metadata = emptyMap(), ) @@ -99,7 +97,6 @@ class WorkflowNavigatorTest { initialStepId = "step-1", steps = mapOf("step-1" to step1, "step-2" to step2, "step-3" to step3), screens = emptyMap(), - uiConfig = UiConfig(), metadata = emptyMap(), ) @@ -434,7 +431,6 @@ class WorkflowNavigatorTest { initialStepId = "step-1", steps = mapOf("step-1" to step1Loop, "step-2" to step2Loop), screens = emptyMap(), - uiConfig = UiConfig(), metadata = emptyMap(), ) val navigator = WorkflowNavigator(loopWorkflow) From 5ad7327835dbca715612d280ccf1ba23ac789b71 Mon Sep 17 00:00:00 2001 From: Cesar de la Vega <664544+vegaro@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:21:56 +0200 Subject: [PATCH 4/4] refactor(workflows): wire the independent ui_config read path into workflow presentation Consumes the awaitGetUiConfig() read path added in the previous step from PaywallViewModel's workflow presentation, threading a UiConfig parameter through the presentation pipeline alongside the existing workflow/offerings chain now that PublishedWorkflow.uiConfig no longer exists. Also restores WorkflowAssetPreDownloader's font pre-download, wired to the same uiConfig, and lets injectedWorkflow (paywall-tester/previews) carry its own uiConfig via PaywallOptions. --- .../utils/WorkflowAssetPreDownloader.kt | 8 +- .../utils/WorkflowAssetPreDownloaderTest.kt | 20 ++- .../ui/revenuecatui/PaywallOptions.kt | 18 +- .../ui/revenuecatui/data/PaywallViewModel.kt | 35 +++- .../revenuecatui/data/PaywallViewModelTest.kt | 54 +++++- .../data/PaywallViewModelWorkflowTest.kt | 159 +++++++++--------- 6 files changed, 200 insertions(+), 94 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/WorkflowAssetPreDownloader.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/WorkflowAssetPreDownloader.kt index 77be886af6..b567427af8 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/utils/WorkflowAssetPreDownloader.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/utils/WorkflowAssetPreDownloader.kt @@ -3,6 +3,7 @@ package com.revenuecat.purchases.utils import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.common.debugLog import com.revenuecat.purchases.common.workflows.PublishedWorkflow import com.revenuecat.purchases.paywalls.OfferingFontPreDownloader @@ -14,7 +15,7 @@ internal class WorkflowAssetPreDownloader( private val preDownloadedWorkflowIds = mutableSetOf() - fun preDownloadWorkflowAssets(workflow: PublishedWorkflow) { + fun preDownloadWorkflowAssets(workflow: PublishedWorkflow, uiConfig: UiConfig) { synchronized(preDownloadedWorkflowIds) { if (!preDownloadedWorkflowIds.add(workflow.id)) return } @@ -24,9 +25,6 @@ internal class WorkflowAssetPreDownloader( workflow.screens.values.forEach { screen -> paywallComponentsImagePreDownloader.preDownloadImages(screen.componentsConfig.base) } - // ui_config no longer lives on PublishedWorkflow — it has its own independent read path - // (UiConfigProvider / PurchasesOrchestrator.getUiConfig). Font pre-download needs to be re-wired to - // pull fonts from that path; left as a no-op pending that wiring. - offeringFontPreDownloader.preDownloadFontsIfNeeded(emptyList()) + offeringFontPreDownloader.preDownloadFontsIfNeeded(uiConfig.app.fonts.values) } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/utils/WorkflowAssetPreDownloaderTest.kt b/purchases/src/test/java/com/revenuecat/purchases/utils/WorkflowAssetPreDownloaderTest.kt index 180df35f7c..7812e1d88e 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/utils/WorkflowAssetPreDownloaderTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/utils/WorkflowAssetPreDownloaderTest.kt @@ -1,5 +1,6 @@ package com.revenuecat.purchases.utils +import com.revenuecat.purchases.FontAlias import com.revenuecat.purchases.LogHandler import com.revenuecat.purchases.NoOpLogHandler import com.revenuecat.purchases.UiConfig @@ -46,24 +47,35 @@ class WorkflowAssetPreDownloaderTest { } @Test - fun `preDownloadWorkflowAssets downloads screen images`() { + fun `preDownloadWorkflowAssets downloads screen images and workflow fonts`() { val screenConfig = mockk() val localizations = mapOf( LocaleId("en_US") to mapOf( LocalizationKey("title") to LocalizationData.Text("Title"), ), ) + val font = UiConfig.AppConfig.FontsConfig( + android = UiConfig.AppConfig.FontsConfig.FontInfo.GoogleFonts("Roboto"), + ) val workflow = createWorkflow( screens = mapOf( "screen_1" to createScreen(screenConfig, localizations), ), ) + val uiConfig = UiConfig( + app = UiConfig.AppConfig( + fonts = mapOf(FontAlias("font_1") to font), + ), + ) - preDownloader.preDownloadWorkflowAssets(workflow) + preDownloader.preDownloadWorkflowAssets(workflow, uiConfig) + val fontsSlot = slot>() verify(exactly = 1) { imagePreDownloader.preDownloadImages(screenConfig) + fontPreDownloader.preDownloadFontsIfNeeded(capture(fontsSlot)) } + assertThat(fontsSlot.captured).containsExactly(font) } @Test @@ -73,8 +85,8 @@ class WorkflowAssetPreDownloaderTest { screens = mapOf("screen_1" to createScreen(screenConfig)), ) - preDownloader.preDownloadWorkflowAssets(workflow) - preDownloader.preDownloadWorkflowAssets(workflow) + preDownloader.preDownloadWorkflowAssets(workflow, UiConfig()) + preDownloader.preDownloadWorkflowAssets(workflow, UiConfig()) val fontsSlot = slot>() verify(exactly = 1) { diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt index 977b3ac158..84d05cee2e 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PaywallOptions.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Stable import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.Offering import com.revenuecat.purchases.PresentedOfferingContext +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.common.workflows.WorkflowDataResult import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallResult import com.revenuecat.purchases.ui.revenuecatui.fonts.FontProvider @@ -60,6 +61,7 @@ public class PaywallOptions internal constructor( */ public val customVariables: Map = emptyMap(), internal val injectedWorkflow: WorkflowDataResult? = null, + internal val injectedWorkflowUiConfig: UiConfig = UiConfig(), ) { public companion object { private const val hashMultiplier = 31 @@ -76,6 +78,7 @@ public class PaywallOptions internal constructor( dismissRequestWithExitOffering = builder.dismissRequestWithExitOffering, customVariables = builder.customVariables, injectedWorkflow = builder.injectedWorkflow, + injectedWorkflowUiConfig = builder.injectedWorkflowUiConfig, ) // Only key fields that affect the paywall's identity and rendering logic are used in hashCode. @@ -87,6 +90,7 @@ public class PaywallOptions internal constructor( result = hashMultiplier * result + mode.hashCode() result = hashMultiplier * result + customVariables.hashCode() result = hashMultiplier * result + injectedWorkflow.hashCode() + result = hashMultiplier * result + injectedWorkflowUiConfig.hashCode() return result } @@ -103,6 +107,7 @@ public class PaywallOptions internal constructor( this.mode != other.mode -> false this.customVariables != other.customVariables -> false this.injectedWorkflow != other.injectedWorkflow -> false + this.injectedWorkflowUiConfig != other.injectedWorkflowUiConfig -> false else -> this.dismissRequest == other.dismissRequest } } @@ -118,6 +123,7 @@ public class PaywallOptions internal constructor( dismissRequestWithExitOffering: ((Offering?, PaywallResult?) -> Unit)? = this.dismissRequestWithExitOffering, customVariables: Map = this.customVariables, injectedWorkflow: WorkflowDataResult? = this.injectedWorkflow, + injectedWorkflowUiConfig: UiConfig = this.injectedWorkflowUiConfig, ): PaywallOptions = PaywallOptions( offeringSelection = offeringSelection, shouldDisplayDismissButton = shouldDisplayDismissButton, @@ -129,6 +135,7 @@ public class PaywallOptions internal constructor( dismissRequestWithExitOffering = dismissRequestWithExitOffering, customVariables = customVariables, injectedWorkflow = injectedWorkflow, + injectedWorkflowUiConfig = injectedWorkflowUiConfig, ) @Suppress("TooManyFunctions") @@ -144,6 +151,7 @@ public class PaywallOptions internal constructor( internal var dismissRequestWithExitOffering: ((Offering?, PaywallResult?) -> Unit)? = null internal var customVariables: Map = emptyMap() internal var injectedWorkflow: WorkflowDataResult? = null + internal var injectedWorkflowUiConfig: UiConfig = UiConfig() public fun setOffering(offering: Offering?): Builder = apply { this.offeringSelection = offering?.let { OfferingSelection.OfferingType(it) } @@ -213,11 +221,17 @@ public class PaywallOptions internal constructor( * The workflow's screens resolve their packages from [offering]; pass the single * offering the workflow references (prefer single-offering workflows in preview), or * null for workflows without an associated offering. This sets the offering for you, - * so there's no need to also call [setOffering]. + * so there's no need to also call [setOffering]. Optionally pass [uiConfig] to style the + * injected workflow, since it no longer ships with its own `ui_config`. */ @InternalRevenueCatAPI - public fun injectedWorkflow(workflow: WorkflowDataResult, offering: Offering?): Builder = apply { + public fun injectedWorkflow( + workflow: WorkflowDataResult, + offering: Offering?, + uiConfig: UiConfig = UiConfig(), + ): Builder = apply { this.injectedWorkflow = workflow + this.injectedWorkflowUiConfig = uiConfig this.offeringSelection = offering?.let { OfferingSelection.OfferingType(it) } ?: OfferingSelection.None } diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index e76daa7f3d..2e6430dc45 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -22,6 +22,7 @@ import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.UiConfig import com.revenuecat.purchases.common.workflows.PublishedWorkflow import com.revenuecat.purchases.common.workflows.WorkflowDataResult import com.revenuecat.purchases.common.workflows.WorkflowScreenType @@ -204,6 +205,7 @@ internal class PaywallViewModelImpl( private var workflowNavigator: WorkflowNavigator? = null private var currentWorkflowResult: WorkflowDataResult? = null + private var currentWorkflowUiConfig: UiConfig = UiConfig() private var currentWorkflowOfferings: Offerings? = null private var currentWorkflowPresentedOfferingContext: PresentedOfferingContext? = null private var currentWorkflowStepTracksPaywallEvents = true @@ -850,7 +852,12 @@ internal class PaywallViewModelImpl( current = offering, all = offering?.let { mapOf(it.identifier to it) } ?: emptyMap(), ) - startWorkflowPresentation(injectedWorkflow, offerings, offering?.presentedOfferingContext) + startWorkflowPresentation( + injectedWorkflow, + options.injectedWorkflowUiConfig, + offerings, + offering?.presentedOfferingContext, + ) return true } @@ -861,9 +868,11 @@ internal class PaywallViewModelImpl( val workflowIdentifier = purchases.workflowIdForOfferingId(offering.identifier) ?: offering.identifier coroutineScope { val fetchResultDeferred = async { purchases.awaitGetWorkflow(workflowIdentifier) } + val uiConfigDeferred = async { purchases.awaitGetUiConfig() } val offeringsDeferred = async { preloadedOfferings ?: purchases.awaitOfferings() } startWorkflowPresentation( fetchResultDeferred.await(), + uiConfigDeferred.await(), offeringsDeferred.await(), offering.presentedOfferingContext, ) @@ -931,14 +940,16 @@ internal class PaywallViewModelImpl( fetchResult: WorkflowDataResult, offerings: Offerings, presentedOfferingContext: PresentedOfferingContext?, + uiConfig: UiConfig = UiConfig(), ) { cancelStateUpdate() - startWorkflowPresentation(fetchResult, offerings, presentedOfferingContext) + startWorkflowPresentation(fetchResult, uiConfig, offerings, presentedOfferingContext) } @Suppress("ReturnCount") private fun startWorkflowPresentation( fetchResult: WorkflowDataResult, + uiConfig: UiConfig, offerings: Offerings, presentedOfferingContext: PresentedOfferingContext?, ) { @@ -957,6 +968,7 @@ internal class PaywallViewModelImpl( trackCurrentWorkflowStepCompleted() currentWorkflowResult = fetchResult + currentWorkflowUiConfig = uiConfig currentWorkflowOfferings = offerings currentWorkflowPresentedOfferingContext = presentedOfferingContext workflowNavigator = WorkflowNavigator(workflow) @@ -977,6 +989,7 @@ internal class PaywallViewModelImpl( } buildWorkflowStates( workflow = workflow, + uiConfig = uiConfig, offerings = offerings, presentedOfferingContext = presentedOfferingContext, currentStep = initialStep, @@ -996,6 +1009,7 @@ internal class PaywallViewModelImpl( val currentStep = workflowNavigator?.currentStep ?: return buildWorkflowStates( workflow = result.workflow, + uiConfig = currentWorkflowUiConfig, offerings = offerings, presentedOfferingContext = currentWorkflowPresentedOfferingContext, currentStep = currentStep, @@ -1010,6 +1024,7 @@ internal class PaywallViewModelImpl( */ private fun buildWorkflowStates( workflow: PublishedWorkflow, + uiConfig: UiConfig, offerings: Offerings, presentedOfferingContext: PresentedOfferingContext?, currentStep: WorkflowStep, @@ -1029,13 +1044,14 @@ internal class PaywallViewModelImpl( buildStateFromStep( stepWithPackages, workflow, + uiConfig, offerings, presentedOfferingContext, shouldApplyState = false, ) } - buildStateFromStep(currentStep, workflow, offerings, presentedOfferingContext) + buildStateFromStep(currentStep, workflow, uiConfig, offerings, presentedOfferingContext) if (isNewWorkflowImpression && _workflowState.value != null) { trackWorkflowStepStarted( step = currentStep, @@ -1043,12 +1059,13 @@ internal class PaywallViewModelImpl( entryReason = WorkflowStepEntryReason.START, ) } - preWarmWorkflowStepCache(workflow, offerings, presentedOfferingContext) + preWarmWorkflowStepCache(workflow, uiConfig, offerings, presentedOfferingContext) } private fun buildStateFromStep( step: WorkflowStep, workflow: PublishedWorkflow, + uiConfig: UiConfig, offerings: Offerings, presentedOfferingContext: PresentedOfferingContext?, fromStepId: String? = null, @@ -1056,7 +1073,7 @@ internal class PaywallViewModelImpl( shouldApplyState: Boolean = true, ) { val cached = workflowStepStateCache[step.id] - val newState = cached ?: computeStateForStep(step, workflow, offerings, presentedOfferingContext) + val newState = cached ?: computeStateForStep(step, workflow, uiConfig, offerings, presentedOfferingContext) if (cached == null && newState is PaywallState.Loaded.Components) { workflowStepStateCache[step.id] = newState } @@ -1114,6 +1131,7 @@ internal class PaywallViewModelImpl( private fun computeStateForStep( step: WorkflowStep, workflow: PublishedWorkflow, + uiConfig: UiConfig, offerings: Offerings, presentedOfferingContext: PresentedOfferingContext?, ): PaywallState { @@ -1126,7 +1144,7 @@ internal class PaywallViewModelImpl( val baseOffering = offerings[offeringId] ?: return PaywallState.Error("Offering '$offeringId' not found for screen '$screenId'") - val paywallComponents = WorkflowScreenMapper.toPaywallComponents(screen, screenId, workflow.uiConfig) + val paywallComponents = WorkflowScreenMapper.toPaywallComponents(screen, screenId, uiConfig) val offering = Offering( identifier = baseOffering.identifier, serverDescription = baseOffering.serverDescription, @@ -1151,6 +1169,7 @@ internal class PaywallViewModelImpl( */ private fun preWarmWorkflowStepCache( workflow: PublishedWorkflow, + uiConfig: UiConfig, offerings: Offerings, presentedOfferingContext: PresentedOfferingContext?, ) { @@ -1158,7 +1177,7 @@ internal class PaywallViewModelImpl( for ((stepId, step) in workflow.steps) { if (stepId in workflowStepStateCache) continue val computed = withContext(backgroundDispatcher) { - computeStateForStep(step, workflow, offerings, presentedOfferingContext) + computeStateForStep(step, workflow, uiConfig, offerings, presentedOfferingContext) } if (computed is PaywallState.Loaded.Components && stepId !in workflowStepStateCache) { workflowStepStateCache[stepId] = computed @@ -1193,6 +1212,7 @@ internal class PaywallViewModelImpl( buildStateFromStep( newStep, result.workflow, + currentWorkflowUiConfig, offerings, currentWorkflowPresentedOfferingContext, fromStepId = fromStepId, @@ -1226,6 +1246,7 @@ internal class PaywallViewModelImpl( buildStateFromStep( newStep, result.workflow, + currentWorkflowUiConfig, offerings, currentWorkflowPresentedOfferingContext, fromStepId = fromStepId, diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt index 87cc70c6cf..2516afe0af 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt @@ -234,6 +234,7 @@ class PaywallViewModelTest { every { purchases.preferredUILocaleOverride } returns null every { purchases.useWorkflows } returns false coEvery { purchases.workflowIdForOfferingId(any()) } returns null + coEvery { purchases.awaitGetUiConfig() } returns UiConfig() every { listener.onPurchaseStarted(any()) } just runs every { listener.onPurchaseCompleted(any(), any()) } just runs @@ -3074,7 +3075,7 @@ class PaywallViewModelTest { purchases, PaywallOptions.Builder(dismissRequest = { dismissInvoked = true }) .setListener(listener) - .injectedWorkflow(WorkflowDataResult(workflow, null), defaultOffering) + .injectedWorkflow(WorkflowDataResult(workflow, null), defaultOffering, UiConfig()) .build(), TestData.Constants.currentColorScheme, isDarkMode = false, @@ -3249,6 +3250,57 @@ class PaywallViewModelTest { coVerify(exactly = 1) { purchases.awaitGetWorkflow(offeringWithWPL.identifier) } } + @Test + fun `when useWorkflows is true and the ui config fetch fails, falls back to the offerings-provided paywall`() { + // The ui_config leg of the parallel fetch can fail independently of the workflow leg. With the + // offering carrying the paywall /offerings delivered, the paywall renders through the regular + // components path instead of erroring. + coEvery { purchases.workflowIdForOfferingId(offeringWithWPL.identifier) } returns null + val workflowScreen = WorkflowScreen( + templateName = "template", + revision = 0, + assetBaseURL = URL("https://assets.pawwalls.com"), + componentsConfig = ComponentsConfig( + base = PaywallComponentsConfig( + stack = StackComponent(components = listOf(TestData.Components.monthlyPackageComponent)), + background = Background.Color(ColorScheme(light = ColorInfo.Hex(Color.White.toArgb()))), + stickyFooter = null, + ), + ), + componentsLocalizations = localizations, + defaultLocaleIdentifier = defaultLocaleIdentifier, + offeringIdentifier = defaultOffering.identifier, + ) + val stepOne = WorkflowStep(id = "step-1", type = "screen", screenId = "screen-1") + val workflow = PublishedWorkflow( + id = "wfl-ui-config-fails", + displayName = "Workflow", + initialStepId = "step-1", + steps = mapOf("step-1" to stepOne), + screens = mapOf("screen-1" to workflowScreen), + ) + coEvery { purchases.awaitGetWorkflow(offeringWithWPL.identifier) } returns WorkflowDataResult(workflow, null) + coEvery { purchases.awaitGetUiConfig() } throws PurchasesException( + PurchasesError(PurchasesErrorCode.UnknownError, "UI config is unavailable from remote config."), + ) + + val model = PaywallViewModelImpl( + MockResourceProvider(), + purchases, + PaywallOptions.Builder(dismissRequest = { dismissInvoked = true }) + .setListener(listener) + .setOffering(offeringWithWPL) + .build(), + TestData.Constants.currentColorScheme, + isDarkMode = false, + shouldDisplayBlock = null, + useWorkflowsEndpoint = true, + ) + + assertThat(model.state.value).isInstanceOf(PaywallState.Loaded.Components::class.java) + coVerify(exactly = 1) { purchases.awaitGetUiConfig() } + } + @Test fun `when useWorkflows is true and the workflow fetch fails with no fallback paywall, surfaces the error`() { val offeringWithoutPaywallData = Offering( diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt index d3327fcbf2..c949544a75 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelWorkflowTest.kt @@ -53,6 +53,7 @@ import com.revenuecat.purchases.ui.revenuecatui.activity.PaywallResult import com.revenuecat.purchases.ui.revenuecatui.utils.Resumable import com.revenuecat.purchases.ui.revenuecatui.data.testdata.MockResourceProvider import com.revenuecat.purchases.ui.revenuecatui.data.testdata.TestData +import com.revenuecat.purchases.ui.revenuecatui.helpers.UiConfig import com.revenuecat.purchases.ui.revenuecatui.workflow.NavigationDirection import io.mockk.Runs import io.mockk.clearAllMocks @@ -113,6 +114,10 @@ class PaywallViewModelWorkflowTest { ), ) + // Includes the standard variable localizations (e.g. period names) needed for calculateState to + // produce PaywallState.Loaded.Components instead of falling back on a MissingLocalization warning. + private val testUiConfig = UiConfig() + private fun makeScreen(screenId: String) = WorkflowScreen( name = screenId, templateName = "template_v2", @@ -507,7 +512,7 @@ class PaywallViewModelWorkflowTest { @Test fun `forward navigation sets FORWARD on pendingTransition`() { val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) @@ -518,7 +523,7 @@ class PaywallViewModelWorkflowTest { @Test fun `forward navigation sets fromStepId to the step navigated away from`() { val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) @@ -532,7 +537,7 @@ class PaywallViewModelWorkflowTest { @Test fun `back navigation sets BACKWARD on pendingTransition`() { val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) vm.onTransitionComplete(vm.workflowState.value!!.pendingTransition!!.id) @@ -545,7 +550,7 @@ class PaywallViewModelWorkflowTest { @Test fun `back navigation sets fromStepId to the step navigated away from`() { val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) vm.onTransitionComplete(vm.workflowState.value!!.pendingTransition!!.id) @@ -561,7 +566,7 @@ class PaywallViewModelWorkflowTest { @Test fun `second visit to a step returns the cached state instance`() { val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) // First visit to step-2: state is computed and cached. vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) @@ -581,7 +586,7 @@ class PaywallViewModelWorkflowTest { fun `back navigation returns step state with the selection the user left on it`() { val (fetchResult2, offerings2) = makeTwoPackageWorkflow() val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult2, offerings2, null) + vm.startWorkflowPresentationFromResult(fetchResult2, offerings2, null, testUiConfig) // Switch step-1 to annual before navigating forward. val step1State = vm.workflowState.value?.stepStates?.get("step-1")!! @@ -604,7 +609,7 @@ class PaywallViewModelWorkflowTest { @Test fun `two rapid forward navigations from same step do not corrupt state`() { val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) // Two calls before any transition completes. vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) @@ -633,7 +638,7 @@ class PaywallViewModelWorkflowTest { // instead of it escaping to Dispatchers.Default and resuming after resetMain(). backgroundDispatcher = testDispatcher, ) - vmWithVars.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vmWithVars.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vmWithVars.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) @@ -651,7 +656,7 @@ class PaywallViewModelWorkflowTest { @Test fun `onTransitionComplete clears pendingTransition for the matching id`() { val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) val id = vm.workflowState.value!!.pendingTransition!!.id @@ -663,7 +668,7 @@ class PaywallViewModelWorkflowTest { @Test fun `onTransitionComplete with stale id does not clear a newer transition`() { val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) val staleId = vm.workflowState.value!!.pendingTransition!!.id vm.onTransitionComplete(staleId) @@ -687,7 +692,7 @@ class PaywallViewModelWorkflowTest { val (result, offerings) = makeContextPackageWorkflow() val vm = createVm() - vm.startWorkflowPresentationFromResult(result, offerings, null) + vm.startWorkflowPresentationFromResult(result, offerings, null, testUiConfig) val step1State = vm.workflowState.value?.stepStates?.get("step-1") assertThat(step1State).isNotNull() @@ -701,7 +706,7 @@ class PaywallViewModelWorkflowTest { fun `back navigation does not overwrite early step context on return`() { val (result, offerings) = makeContextPackageWorkflow() val vm = createVm() - vm.startWorkflowPresentationFromResult(result, offerings, null) + vm.startWorkflowPresentationFromResult(result, offerings, null, testUiConfig) // Step-1 initial context is MONTHLY (from singleStepFallbackId → step-2 default). val step1StateBefore = vm.workflowState.value?.stepStates?.get("step-1") @@ -824,7 +829,7 @@ class PaywallViewModelWorkflowTest { val wflResult = WorkflowDataResult(workflow = threeStepWorkflow, enrolledVariants = null) val vm = createVm() - vm.startWorkflowPresentationFromResult(wflResult, threeStepOfferings, null) + vm.startWorkflowPresentationFromResult(wflResult, threeStepOfferings, null, testUiConfig) // Navigate step-A → step-B → step-C. vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) @@ -852,7 +857,7 @@ class PaywallViewModelWorkflowTest { fun `own package selection on packaged step is preserved through back-and-forward navigation`() { val (result, offerings) = makeContextPackageWorkflow() val vm = createVm() - vm.startWorkflowPresentationFromResult(result, offerings, null) + vm.startWorkflowPresentationFromResult(result, offerings, null, testUiConfig) // Navigate to terminal step-2 (has packages, default is MONTHLY). vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) @@ -884,7 +889,7 @@ class PaywallViewModelWorkflowTest { ) val vm = createVm() - vm.startWorkflowPresentationFromResult(brokenResult, offerings, null) + vm.startWorkflowPresentationFromResult(brokenResult, offerings, null, testUiConfig) val step1State = vm.workflowState.value?.stepStates?.get("step-1") assertThat(step1State).isNotNull() @@ -902,7 +907,7 @@ class PaywallViewModelWorkflowTest { ) val vm = createVm() - vm.startWorkflowPresentationFromResult(singleFallbackResult, twoPackageOfferings, null) + vm.startWorkflowPresentationFromResult(singleFallbackResult, twoPackageOfferings, null, testUiConfig) val step1State = vm.workflowState.value?.stepStates?.get("step-1") assertThat(step1State).isNotNull() @@ -920,7 +925,7 @@ class PaywallViewModelWorkflowTest { coEvery { purchases.awaitOfferings() } returns testOfferingsWithExitOffer val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null) + vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null, testUiConfig) vm.preloadExitOffering() advanceUntilIdle() @@ -930,7 +935,7 @@ class PaywallViewModelWorkflowTest { @Test fun `preloadExitOffering without singleStepFallbackId does not set preloaded offering`() = runTest { val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.preloadExitOffering() advanceUntilIdle() @@ -943,7 +948,7 @@ class PaywallViewModelWorkflowTest { coEvery { purchases.awaitOfferings() } returns testOfferings val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferings, null, testUiConfig) vm.preloadExitOffering() advanceUntilIdle() @@ -953,12 +958,12 @@ class PaywallViewModelWorkflowTest { @Test fun `preloadExitOffering not-found result is not re-attempted when same offerings are re-set`() = runTest { val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferings, null, testUiConfig) vm.preloadExitOffering() assertThat(vm.preloadedExitOffering).isNull() // Simulate locale/colour/options refresh pushing the same workflow data again. - vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferings, null, testUiConfig) // Still null — carry-forward prevented a redundant lookup and a duplicate error log. assertThat(vm.preloadedExitOffering).isNull() @@ -968,12 +973,12 @@ class PaywallViewModelWorkflowTest { fun `preloadExitOffering re-resolves when offerings are refreshed and now contain the exit offering`() = runTest { val vm = createVm() // First update: exit offering absent. - vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferings, null, testUiConfig) vm.preloadExitOffering() assertThat(vm.preloadedExitOffering).isNull() // Second update: fresh offerings that now include the exit offering. - vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null) + vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null, testUiConfig) assertThat(vm.preloadedExitOffering?.identifier).isEqualTo(exitOfferingId) } @@ -988,7 +993,7 @@ class PaywallViewModelWorkflowTest { receivedExitOffering = offering }, ) - vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null) + vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null, testUiConfig) vm.preloadExitOffering() advanceUntilIdle() vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) @@ -1011,7 +1016,7 @@ class PaywallViewModelWorkflowTest { receivedExitOffering = offering }, ) - vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null) + vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null, testUiConfig) vm.preloadExitOffering() advanceUntilIdle() @@ -1031,7 +1036,7 @@ class PaywallViewModelWorkflowTest { val vm = createVm() vm.preloadExitOffering() - vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null) + vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null, testUiConfig) advanceUntilIdle() assertThat(vm.preloadedExitOffering?.identifier).isEqualTo(exitOfferingId) @@ -1041,10 +1046,10 @@ class PaywallViewModelWorkflowTest { fun `startWorkflowPresentationFromResult with same workflow data reloads preloaded exit offering`() = runTest { val vm = createVm() vm.preloadExitOffering() - vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null) + vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null, testUiConfig) assertThat(vm.preloadedExitOffering?.identifier).isEqualTo(exitOfferingId) - vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null) + vm.startWorkflowPresentationFromResult(fetchResultWithExitOffer, testOfferingsWithExitOffer, null, testUiConfig) assertThat(vm.preloadedExitOffering?.identifier).isEqualTo(exitOfferingId) } @@ -1053,7 +1058,7 @@ class PaywallViewModelWorkflowTest { coEvery { purchases.awaitOfferings() } returns testOfferingsWithExitOffer val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResultWithFallback, testOfferingsWithExitOffer, null) + vm.startWorkflowPresentationFromResult(fetchResultWithFallback, testOfferingsWithExitOffer, null, testUiConfig) vm.preloadExitOffering() advanceUntilIdle() @@ -1065,7 +1070,7 @@ class PaywallViewModelWorkflowTest { coEvery { purchases.awaitOfferings() } returns testOfferingsWithExitOffer val vm = createVm() - vm.startWorkflowPresentationFromResult(singleStepFetchResultWithExitOffer, testOfferingsWithExitOffer, null) + vm.startWorkflowPresentationFromResult(singleStepFetchResultWithExitOffer, testOfferingsWithExitOffer, null, testUiConfig) vm.preloadExitOffering() advanceUntilIdle() @@ -1084,7 +1089,7 @@ class PaywallViewModelWorkflowTest { } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) val started = captured.filterIsInstance() assertThat(started).hasSize(1) @@ -1103,7 +1108,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) val initialTraceId = captured.filterIsInstance().single().traceId captured.clear() @@ -1143,6 +1148,7 @@ class PaywallViewModelWorkflowTest { WorkflowDataResult(workflowWithFailingInitialAndSuccessfulFallback, enrolledVariants = null), testOfferingsWithEmpty, null, + testUiConfig, ) assertThat(captured.filterIsInstance()).isEmpty() @@ -1156,7 +1162,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) captured.clear() vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) @@ -1184,7 +1190,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) vm.onTransitionComplete(vm.workflowState.value!!.pendingTransition!!.id) captured.clear() // clear load + forward nav events @@ -1212,7 +1218,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) captured.clear() // clear load event vm.closePaywall(result = null) @@ -1228,7 +1234,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) captured.clear() // clear load event vm.closePaywall(result = null) @@ -1247,7 +1253,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) // Navigate step-1 → step-2 (the terminal step) and settle the transition. vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) vm.onTransitionComplete(vm.workflowState.value!!.pendingTransition!!.id) @@ -1272,7 +1278,7 @@ class PaywallViewModelWorkflowTest { val (result, offerings) = makeContextPackageWorkflow() val vm = createVm() - vm.startWorkflowPresentationFromResult(result, offerings, null) + vm.startWorkflowPresentationFromResult(result, offerings, null, testUiConfig) captured.clear() vm.closePaywall(result = null) @@ -1286,7 +1292,7 @@ class PaywallViewModelWorkflowTest { @Test fun `closePaywall clears workflowState`() { val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) assertThat(vm.workflowState.value).isNotNull() vm.closePaywall(result = null) @@ -1300,7 +1306,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.trackPaywallImpressionIfNeeded() vm.closePaywall(result = null) captured.clear() @@ -1320,7 +1326,7 @@ class PaywallViewModelWorkflowTest { val (result, offerings) = makeContextPackageWorkflow() val vm = createVm() - vm.startWorkflowPresentationFromResult(result, offerings, null) + vm.startWorkflowPresentationFromResult(result, offerings, null, testUiConfig) // step-1 is a context (non-paywall) step, so currentWorkflowStepTracksPaywallEvents = false vm.trackPaywallImpressionIfNeeded() assertThat(captured.filterIsInstance()).isEmpty() @@ -1330,7 +1336,7 @@ class PaywallViewModelWorkflowTest { // After close, simulate the VM being reused: navigate to step-2 (paywall step) via a new // workflow presentation and verify impression is tracked normally (not suppressed by stale state). - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.trackPaywallImpressionIfNeeded() val impressions = captured.filterIsInstance() @@ -1348,7 +1354,7 @@ class PaywallViewModelWorkflowTest { ) val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) advanceUntilIdle() captured.clear() @@ -1371,7 +1377,7 @@ class PaywallViewModelWorkflowTest { ) val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) advanceUntilIdle() vm.handlePackagePurchase(activity = mockk(), pkg = TestData.Packages.monthly) captured.clear() @@ -1391,7 +1397,7 @@ class PaywallViewModelWorkflowTest { // shouldDisplayBlock is null in createVm, so a successful REVENUECAT restore does NOT auto-dismiss // and does NOT set purchaseCompleted: the paywall stays up and the user dismisses manually. val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) advanceUntilIdle() vm.handleRestorePurchases() advanceUntilIdle() @@ -1416,7 +1422,7 @@ class PaywallViewModelWorkflowTest { coEvery { purchases.awaitRestore() } returns mockk() val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) advanceUntilIdle() // Successful restore with shouldDisplayBlock null: the paywall stays open, completion recorded. vm.handleRestorePurchases() @@ -1425,7 +1431,7 @@ class PaywallViewModelWorkflowTest { // A refresh re-presents the SAME open session (e.g. updateOptions -> presentWorkflow -> // startWorkflowPresentation). The user has NOT dismissed, so the restore completion must survive // and still suppress workflows_close on the eventual manual dismiss. - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) advanceUntilIdle() captured.clear() @@ -1458,7 +1464,7 @@ class PaywallViewModelWorkflowTest { shouldDisplayBlock = null, backgroundDispatcher = testDispatcher, ) - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) advanceUntilIdle() // MY_APP purchase completes and auto-dismisses the paywall (closePaywall -> clearWorkflowState), // which ends the session. @@ -1467,7 +1473,7 @@ class PaywallViewModelWorkflowTest { // The same ViewModel later presents a NEW workflow session. _purchaseCompleted stays true (sticky), // but this session had no purchase, so abandoning it must still emit workflows_close. - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) advanceUntilIdle() captured.clear() @@ -1487,7 +1493,7 @@ class PaywallViewModelWorkflowTest { ) val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) advanceUntilIdle() // REVENUECAT purchase completes and dismisses via options.dismissRequest() (not closePaywall), // which ends the session on an embedded ViewModel that is not destroyed. @@ -1496,7 +1502,7 @@ class PaywallViewModelWorkflowTest { // The same ViewModel later presents a NEW workflow session with no purchase, so abandoning it // must still emit workflows_close (the completion must not stick past the dismiss). - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) advanceUntilIdle() captured.clear() @@ -1537,7 +1543,7 @@ class PaywallViewModelWorkflowTest { shouldDisplayBlock = null, backgroundDispatcher = testDispatcher, ) - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) advanceUntilIdle() captured.clear() @@ -1565,7 +1571,7 @@ class PaywallViewModelWorkflowTest { shouldDisplayBlock = { false }, backgroundDispatcher = testDispatcher, ) - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) advanceUntilIdle() captured.clear() @@ -1584,7 +1590,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResultFailingInitial, testOfferingsWithEmpty, null) + vm.startWorkflowPresentationFromResult(fetchResultFailingInitial, testOfferingsWithEmpty, null, testUiConfig) val started = captured.filterIsInstance() assertThat(started).isEmpty() @@ -1596,7 +1602,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResultToError, testOfferingsWithEmpty, null) + vm.startWorkflowPresentationFromResult(fetchResultToError, testOfferingsWithEmpty, null, testUiConfig) captured.clear() // clear load event // Navigate to step-3 — computeStateForStep returns Error since its offering has no packages @@ -1616,13 +1622,13 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) val firstImpressionTraceId = captured.filterIsInstance().map { it.traceId }.distinct().single() captured.clear() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) // The abandoned step's StepCompleted carries the old trace ID; the new StepStarted gets a fresh one. val secondImpressionEvents = captured.filterIsInstance() @@ -1638,7 +1644,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) val startedTraceId = captured.filterIsInstance().single().traceId vm.closePaywall(result = null) @@ -1657,11 +1663,11 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleWorkflowAction("btn-next", WorkflowTriggerType.ON_PRESS) captured.clear() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) val workflowEvents = captured.filterIsInstance() val completed = workflowEvents.filterIsInstance().single() @@ -1677,7 +1683,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) assertThat(captured.filterIsInstance()).isEmpty() } @@ -1688,7 +1694,7 @@ class PaywallViewModelWorkflowTest { every { purchases.track(any()) } answers { captured.add(firstArg()) } val vm = createVm() - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.trackPaywallImpressionIfNeeded() val impressions = captured.filterIsInstance() @@ -1732,11 +1738,11 @@ class PaywallViewModelWorkflowTest { enrolledVariants = null, ) - vm.startWorkflowPresentationFromResult(step1Result, testOfferings, null) + vm.startWorkflowPresentationFromResult(step1Result, testOfferings, null, testUiConfig) vm.trackPaywallImpressionIfNeeded() captured.clear() - vm.startWorkflowPresentationFromResult(step2Result, testOfferings, null) + vm.startWorkflowPresentationFromResult(step2Result, testOfferings, null, testUiConfig) vm.trackPaywallImpressionIfNeeded() // Same visual fingerprint, so no new impression is emitted: this proves the de-dup branch // (the one withCurrentWorkflowMetadata lives in) was taken, not a fresh re-creation. @@ -1783,11 +1789,11 @@ class PaywallViewModelWorkflowTest { enrolledVariants = null, ) - vm.startWorkflowPresentationFromResult(step1Result, testOfferings, null) + vm.startWorkflowPresentationFromResult(step1Result, testOfferings, null, testUiConfig) vm.trackPaywallImpressionIfNeeded() captured.clear() - vm.startWorkflowPresentationFromResult(step2Result, testOfferings, null) + vm.startWorkflowPresentationFromResult(step2Result, testOfferings, null, testUiConfig) vm.trackPaywallImpressionIfNeeded() // Same visual fingerprint, so no new impression is emitted: this proves the de-dup branch // (the one withCurrentWorkflowMetadata lives in) was taken, not a fresh re-creation. @@ -1810,7 +1816,7 @@ class PaywallViewModelWorkflowTest { val (result, offerings) = makeContextPackageWorkflow() val vm = createVm() - vm.startWorkflowPresentationFromResult(result, offerings, null) + vm.startWorkflowPresentationFromResult(result, offerings, null, testUiConfig) vm.trackPaywallImpressionIfNeeded() assertThat(captured.filterIsInstance()).isEmpty() @@ -1852,7 +1858,7 @@ class PaywallViewModelWorkflowTest { coEvery { purchases.awaitPurchase(any()) } returns PurchaseResult(transaction, mockk(relaxed = true)) val activity = mockk() val vm = createVm(listener = listener) - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handlePackagePurchase(activity, TestData.Packages.monthly) advanceUntilIdle() @@ -1868,7 +1874,7 @@ class PaywallViewModelWorkflowTest { coEvery { purchases.awaitPurchase(any()) } returns PurchaseResult(transaction, customerInfo) val activity = mockk() val vm = createVm(listener = listener) - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handlePackagePurchase(activity, TestData.Packages.monthly) advanceUntilIdle() @@ -1886,7 +1892,7 @@ class PaywallViewModelWorkflowTest { coEvery { purchases.awaitPurchase(any()) } throws PurchasesException(cancelError) val activity = mockk() val vm = createVm(listener = listener) - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handlePackagePurchase(activity, TestData.Packages.monthly) advanceUntilIdle() @@ -1902,7 +1908,7 @@ class PaywallViewModelWorkflowTest { coEvery { purchases.awaitPurchase(any()) } throws PurchasesException(purchaseError) val activity = mockk() val vm = createVm(listener = listener) - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handlePackagePurchase(activity, TestData.Packages.monthly) advanceUntilIdle() @@ -1916,7 +1922,7 @@ class PaywallViewModelWorkflowTest { val listener = makeListener() coEvery { purchases.awaitRestore() } returns mockk(relaxed = true) val vm = createVm(listener = listener) - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleRestorePurchases() advanceUntilIdle() @@ -1930,7 +1936,7 @@ class PaywallViewModelWorkflowTest { val customerInfo = mockk(relaxed = true) coEvery { purchases.awaitRestore() } returns customerInfo val vm = createVm(listener = listener) - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleRestorePurchases() advanceUntilIdle() @@ -1947,7 +1953,7 @@ class PaywallViewModelWorkflowTest { val restoreError = PurchasesError(PurchasesErrorCode.NetworkError) coEvery { purchases.awaitRestore() } throws PurchasesException(restoreError) val vm = createVm(listener = listener) - vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null) + vm.startWorkflowPresentationFromResult(fetchResult, testOfferings, null, testUiConfig) vm.handleRestorePurchases() advanceUntilIdle() @@ -1993,6 +1999,7 @@ class PaywallViewModelWorkflowTest { singleStepScreenTypeWorkflow(screenTypeMetadata(WorkflowScreenType.PAYWALL)), testOfferings, null, + testUiConfig, ) vm.trackPaywallImpressionIfNeeded() assertThat(captured.paywallEventsOfType(PaywallEventType.IMPRESSION)).hasSize(1) @@ -2012,6 +2019,7 @@ class PaywallViewModelWorkflowTest { singleStepScreenTypeWorkflow(screenTypeMetadata()), testOfferings, null, + testUiConfig, ) vm.trackPaywallImpressionIfNeeded() vm.closePaywall(result = null) @@ -2031,6 +2039,7 @@ class PaywallViewModelWorkflowTest { singleStepScreenTypeWorkflow(metadata = null), testOfferings, null, + testUiConfig, ) vm.trackPaywallImpressionIfNeeded() @@ -2053,7 +2062,7 @@ class PaywallViewModelWorkflowTest { workflow = workflow.copy(singleStepFallbackId = "step-2"), enrolledVariants = null, ) - vm.startWorkflowPresentationFromResult(untaggedMultiStep, testOfferings, null) + vm.startWorkflowPresentationFromResult(untaggedMultiStep, testOfferings, null, testUiConfig) vm.trackPaywallImpressionIfNeeded() assertThat(captured.paywallEventsOfType(PaywallEventType.IMPRESSION)).isEmpty()