diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt index 50785dd01e..bdb45b7358 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesFactory.kt @@ -39,11 +39,13 @@ import com.revenuecat.purchases.common.offlineentitlements.PurchasedProductsFetc 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.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.DefaultFileRepository import com.revenuecat.purchases.strings.ConfigureStrings import com.revenuecat.purchases.strings.Emojis import com.revenuecat.purchases.subscriberattributes.SubscriberAttributesManager @@ -196,6 +198,9 @@ internal class PurchasesFactory( eventsDispatcher, httpClient, backendHelper, + workflowCdnFetcher = FileCachedWorkflowCdnFetcher( + fileRepository = DefaultFileRepository(contextForStorage), + ), ) val purchasesStateProvider = PurchasesStateCache(PurchasesState()) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt index b406df69b2..7aa0789612 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt @@ -22,6 +22,13 @@ import com.revenuecat.purchases.common.networking.WebBillingProductsResponse import com.revenuecat.purchases.common.networking.buildPostReceiptResponse import com.revenuecat.purchases.common.offlineentitlements.ProductEntitlementMapping import com.revenuecat.purchases.common.verification.SignatureVerificationMode +import com.revenuecat.purchases.common.workflows.FileCachedWorkflowCdnFetcher +import com.revenuecat.purchases.common.workflows.WorkflowCdnFetcher +import com.revenuecat.purchases.common.workflows.WorkflowDetailHttpProcessingResult +import com.revenuecat.purchases.common.workflows.WorkflowDetailHttpProcessor +import com.revenuecat.purchases.common.workflows.WorkflowFetchResult +import com.revenuecat.purchases.common.workflows.WorkflowJsonParser +import com.revenuecat.purchases.common.workflows.WorkflowsListResponse import com.revenuecat.purchases.customercenter.CustomerCenterConfigData import com.revenuecat.purchases.customercenter.CustomerCenterRoot import com.revenuecat.purchases.interfaces.RedeemWebPurchaseListener @@ -96,6 +103,12 @@ internal typealias VirtualCurrenciesCallback = Pair<(VirtualCurrencies) -> Unit, internal typealias WebBillingProductsCallback = Pair<(WebBillingProductsResponse) -> Unit, (PurchasesError) -> Unit> +@OptIn(InternalRevenueCatAPI::class) +internal typealias WorkflowsListCallback = Pair<(WorkflowsListResponse) -> Unit, (PurchasesError) -> Unit> + +@OptIn(InternalRevenueCatAPI::class) +internal typealias WorkflowDetailCallback = Pair<(WorkflowFetchResult) -> Unit, (PurchasesError) -> Unit> + internal enum class PostReceiptErrorHandlingBehavior { SHOULD_BE_MARKED_SYNCED, SHOULD_USE_OFFLINE_ENTITLEMENTS_AND_NOT_CONSUME, @@ -115,7 +128,9 @@ internal class Backend( private val eventsDispatcher: Dispatcher, private val httpClient: HTTPClient, private val backendHelper: BackendHelper, + workflowCdnFetcher: WorkflowCdnFetcher = FileCachedWorkflowCdnFetcher(fileRepository = null), ) { + private val workflowDetailHttpProcessor = WorkflowDetailHttpProcessor(workflowCdnFetcher) companion object { private const val APP_USER_ID = "app_user_id" private const val FETCH_TOKEN = "fetch_token" @@ -177,6 +192,14 @@ internal class Backend( @get:Synchronized @set:Synchronized @Volatile var webBillingProductsCallbacks = mutableMapOf>() + @get:Synchronized @set:Synchronized + @Volatile var workflowsListCallbacks = + mutableMapOf>() + + @get:Synchronized @set:Synchronized + @Volatile var workflowDetailCallbacks = + mutableMapOf>() + fun close() { this.dispatcher.close() } @@ -975,6 +998,136 @@ internal class Backend( } } + fun getWorkflows( + appUserID: String, + appInBackground: Boolean, + onSuccess: (WorkflowsListResponse) -> Unit, + onError: (PurchasesError) -> Unit, + ) { + val endpoint = Endpoint.GetWorkflows(appUserID) + val path = endpoint.getPath() + val cacheKey = BackgroundAwareCallbackCacheKey(listOf(path), appInBackground) + val call = object : Dispatcher.AsyncCall() { + override fun call(): HTTPResult { + return httpClient.performRequest( + appConfig.baseURL, + endpoint, + body = null, + postFieldsToSign = null, + backendHelper.authenticationHeaders, + fallbackBaseURLs = appConfig.fallbackBaseURLs, + ) + } + + override fun onError(error: PurchasesError) { + synchronized(this@Backend) { + workflowsListCallbacks.remove(cacheKey) + }?.forEach { (_, onErrorHandler) -> + onErrorHandler(error) + } + } + + override fun onCompletion(result: HTTPResult) { + synchronized(this@Backend) { + workflowsListCallbacks.remove(cacheKey) + }?.forEach { (onSuccessHandler, onErrorHandler) -> + if (result.isSuccessful()) { + try { + onSuccessHandler(WorkflowJsonParser.parseWorkflowsListResponse(result.payload)) + } catch (e: SerializationException) { + onErrorHandler(e.toPurchasesError().also { errorLog(it) }) + } catch (e: IllegalArgumentException) { + onErrorHandler(e.toPurchasesError().also { errorLog(it) }) + } + } else { + onErrorHandler(result.toPurchasesError().also { errorLog(it) }) + } + } + } + } + synchronized(this@Backend) { + val delay = if (appInBackground) Delay.DEFAULT else Delay.NONE + workflowsListCallbacks.addBackgroundAwareCallback( + call, + dispatcher, + cacheKey, + onSuccess to onError, + delay, + ) + } + } + + fun getWorkflow( + appUserID: String, + workflowId: String, + appInBackground: Boolean, + onSuccess: (WorkflowFetchResult) -> Unit, + onError: (PurchasesError) -> Unit, + ) { + val endpoint = Endpoint.GetWorkflow(appUserID, workflowId) + val path = endpoint.getPath() + val cacheKey = BackgroundAwareCallbackCacheKey(listOf(path), appInBackground) + val call = object : Dispatcher.AsyncCall() { + private var workflowDetailProcessing: WorkflowDetailHttpProcessingResult? = null + + override fun call(): HTTPResult { + val raw = httpClient.performRequest( + appConfig.baseURL, + endpoint, + body = null, + postFieldsToSign = null, + backendHelper.authenticationHeaders, + fallbackBaseURLs = appConfig.fallbackBaseURLs, + ) + val processed = workflowDetailHttpProcessor.process(raw) + workflowDetailProcessing = processed + return processed.httpResult + } + + override fun onError(error: PurchasesError) { + synchronized(this@Backend) { + workflowDetailCallbacks.remove(cacheKey) + }?.forEach { (_, onErrorHandler) -> + onErrorHandler(error) + } + } + + override fun onCompletion(result: HTTPResult) { + synchronized(this@Backend) { + workflowDetailCallbacks.remove(cacheKey) + }?.forEach { (onSuccessHandler, onErrorHandler) -> + if (result.isSuccessful()) { + try { + val workflow = WorkflowJsonParser.parsePublishedWorkflow(result.payload) + onSuccessHandler( + WorkflowFetchResult( + workflow = workflow, + enrolledVariants = workflowDetailProcessing?.enrolledVariants, + ), + ) + } catch (e: SerializationException) { + onErrorHandler(e.toPurchasesError().also { errorLog(it) }) + } catch (e: IllegalArgumentException) { + onErrorHandler(e.toPurchasesError().also { errorLog(it) }) + } + } else { + onErrorHandler(result.toPurchasesError().also { errorLog(it) }) + } + } + } + } + synchronized(this@Backend) { + val delay = if (appInBackground) Delay.DEFAULT else Delay.NONE + workflowDetailCallbacks.addBackgroundAwareCallback( + call, + dispatcher, + cacheKey, + onSuccess to onError, + delay, + ) + } + } + fun getWebBillingProducts( appUserID: String, productIds: Set, diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt index b716f11be4..d4460ebb07 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/networking/Endpoint.kt @@ -27,6 +27,21 @@ internal sealed class Endpoint( } } } + + data class GetWorkflows(val userId: String) : Endpoint( + "/v1/subscribers/%s/workflows", + "get_workflows", + ) { + override fun getPath(useFallback: Boolean) = pathTemplate.format(Uri.encode(userId)) + } + + data class GetWorkflow(val userId: String, val workflowId: String) : Endpoint( + "/v1/subscribers/%s/workflows/%s", + "get_workflow", + ) { + override fun getPath(useFallback: Boolean) = + pathTemplate.format(Uri.encode(userId), Uri.encode(workflowId)) + } object LogIn : Endpoint("/v1/subscribers/identify", "log_in") { override fun getPath(useFallback: Boolean) = pathTemplate } @@ -102,6 +117,7 @@ internal sealed class Endpoint( LogIn, PostReceipt, is GetOfferings, + is GetWorkflows, GetProductEntitlementMapping, PostRedeemWebPurchase, is GetVirtualCurrencies, @@ -115,6 +131,7 @@ internal sealed class Endpoint( PostCreateSupportTicket, is WebBillingGetProducts, is AliasUsers, + is GetWorkflow, -> false } @@ -130,6 +147,8 @@ internal sealed class Endpoint( true is GetAmazonReceipt, is GetOfferings, + is GetWorkflows, + is GetWorkflow, is PostAttributes, PostDiagnostics, PostEvents, diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowCdnFetcher.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowCdnFetcher.kt new file mode 100644 index 0000000000..0b738b65ca --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowCdnFetcher.kt @@ -0,0 +1,41 @@ +@file:OptIn(InternalRevenueCatAPI::class) + +package com.revenuecat.purchases.common.workflows + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.storage.FileRepository +import kotlinx.coroutines.runBlocking +import java.io.File +import java.io.IOException +import java.net.URL + +/** + * Fetches compiled workflow JSON from a CDN URL (inject for tests; use [FileCachedWorkflowCdnFetcher] in production). + */ +internal fun interface WorkflowCdnFetcher { + @Throws(IOException::class) + fun fetchCompiledWorkflowJson(cdnUrl: String): String +} + +/** + * Uses [FileRepository] for disk caching when non-null; otherwise reads the URL directly. + */ +internal class FileCachedWorkflowCdnFetcher( + private val fileRepository: FileRepository?, +) : WorkflowCdnFetcher { + + @Throws(IOException::class) + override fun fetchCompiledWorkflowJson(cdnUrl: String): String { + val url = URL(cdnUrl) + return if (fileRepository != null) { + runBlocking { + val uri = fileRepository.generateOrGetCachedFileURL(url) + File(uri).readText() + } + } else { + url.openConnection().getInputStream().use { stream -> + stream.readBytes().decodeToString() + } + } + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowDetailHttpProcessor.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowDetailHttpProcessor.kt new file mode 100644 index 0000000000..3331b86e8a --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowDetailHttpProcessor.kt @@ -0,0 +1,54 @@ +@file:OptIn(InternalRevenueCatAPI::class) + +package com.revenuecat.purchases.common.workflows + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.common.isSuccessful +import com.revenuecat.purchases.common.networking.HTTPResult +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException + +/** + * Normalizes a successful workflow-detail HTTP payload: `inline` (unwrap `data`) or `use_cdn` (fetch JSON). + * Non-success [HTTPResult] is returned unchanged with null [WorkflowDetailHttpProcessingResult.enrolledVariants]. + */ +internal class WorkflowDetailHttpProcessor( + private val workflowCdnFetcher: WorkflowCdnFetcher, +) { + + @Throws(JSONException::class, IOException::class) + fun process(result: HTTPResult): WorkflowDetailHttpProcessingResult { + if (!result.isSuccessful()) { + return WorkflowDetailHttpProcessingResult(httpResult = result, enrolledVariants = null) + } + val root = JSONObject(result.payload) + val enrolledVariants = root.optJSONObject("enrolled_variants")?.toEnrolledVariantsMap() + val rawAction = root.getString("action") + val action = WorkflowResponseAction.fromValue(rawAction) + ?: throw JSONException("Unknown workflow response action: $rawAction") + val normalizedPayload = when (action) { + WorkflowResponseAction.INLINE -> root.getJSONObject("data").toString() + WorkflowResponseAction.USE_CDN -> workflowCdnFetcher.fetchCompiledWorkflowJson(root.getString("url")) + } + return WorkflowDetailHttpProcessingResult( + httpResult = result.copy(payload = normalizedPayload), + enrolledVariants = enrolledVariants, + ) + } + + private fun JSONObject.toEnrolledVariantsMap(): Map? { + val map = mutableMapOf() + val keyIterator = keys() + while (keyIterator.hasNext()) { + val key = keyIterator.next() + map[key] = optString(key) + } + return map.takeUnless { it.isEmpty() } + } +} + +internal data class WorkflowDetailHttpProcessingResult( + val httpResult: HTTPResult, + val enrolledVariants: Map?, +) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowJsonParser.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowJsonParser.kt new file mode 100644 index 0000000000..130d57e70b --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowJsonParser.kt @@ -0,0 +1,21 @@ +@file:OptIn(InternalRevenueCatAPI::class) + +package com.revenuecat.purchases.common.workflows + +import com.revenuecat.purchases.InternalRevenueCatAPI +import kotlinx.serialization.json.Json + +internal object WorkflowJsonParser { + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun parseWorkflowsListResponse(payload: String): WorkflowsListResponse { + return json.decodeFromString(payload) + } + + fun parsePublishedWorkflow(payload: String): PublishedWorkflow { + return json.decodeFromString(payload) + } +} 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 new file mode 100644 index 0000000000..08e8506274 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/workflows/WorkflowModels.kt @@ -0,0 +1,104 @@ +@file:OptIn(InternalRevenueCatAPI::class) + +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.LocaleId +import com.revenuecat.purchases.paywalls.components.common.LocalizationData +import com.revenuecat.purchases.paywalls.components.common.LocalizationKey +import com.revenuecat.purchases.utils.serializers.URLSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import java.net.URL + +@Serializable +internal data class WorkflowSummary( + val id: String, + @SerialName("display_name") val displayName: String, +) + +@Serializable +internal data class WorkflowsListResponse( + val workflows: List = emptyList(), + @SerialName("ui_config") val uiConfig: UiConfig, +) + +@Serializable +internal data class WorkflowTriggerAction( + val type: String, + val value: String? = null, + @SerialName("step_id") val stepId: String? = null, +) { + val resolvedTargetStepId: String? + get() = value ?: stepId +} + +@Serializable +internal data class WorkflowTrigger( + val name: String, + val type: String, + @SerialName("action_id") val actionId: String, + @SerialName("component_id") val componentId: String, +) + +@Serializable +internal data class WorkflowStep( + val id: String, + val type: String, + @SerialName("screen_id") val screenId: String? = null, + @SerialName("param_values") val paramValues: Map = emptyMap(), + val triggers: List = emptyList(), + val outputs: Map = emptyMap(), + @SerialName("trigger_actions") val triggerActions: Map = emptyMap(), + val metadata: JsonElement? = null, +) + +@Serializable +internal data class WorkflowScreen( + val name: String? = null, + @SerialName("template_name") val templateName: String, + val revision: Int = 0, + @Serializable(with = URLSerializer::class) + @SerialName("asset_base_url") val assetBaseURL: URL, + @SerialName("components_config") val componentsConfig: ComponentsConfig, + @SerialName("components_localizations") + val componentsLocalizations: Map>, + @SerialName("default_locale") val defaultLocaleIdentifier: LocaleId, + @SerialName("config") val config: JsonObject = JsonObject(emptyMap()), + @SerialName("offering_id") val offeringId: String? = null, +) + +/** + * Published workflow document returned inline or via CDN (same JSON shape). + */ +@Serializable +internal data class PublishedWorkflow( + val id: String, + @SerialName("display_name") val displayName: String, + @SerialName("initial_step_id") val initialStepId: String, + val steps: Map, + val screens: Map, + @SerialName("ui_config") val uiConfig: UiConfig, + @SerialName("content_max_width") val contentMaxWidth: Int? = null, + val metadata: JsonObject? = null, +) + +internal data class WorkflowFetchResult( + val workflow: PublishedWorkflow, + val enrolledVariants: Map?, +) + +internal enum class WorkflowResponseAction(val value: String) { + INLINE("inline"), + USE_CDN("use_cdn"), + ; + + internal companion object { + fun fromValue(value: String): WorkflowResponseAction? = + values().firstOrNull { it.value == value } + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendWorkflowsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendWorkflowsTest.kt new file mode 100644 index 0000000000..f6cb249e09 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendWorkflowsTest.kt @@ -0,0 +1,258 @@ +package com.revenuecat.purchases.common.backend + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.VerificationResult +import com.revenuecat.purchases.common.AppConfig +import com.revenuecat.purchases.common.Backend +import com.revenuecat.purchases.common.BackendHelper +import com.revenuecat.purchases.common.HTTPClient +import com.revenuecat.purchases.common.SyncDispatcher +import com.revenuecat.purchases.common.networking.Endpoint +import com.revenuecat.purchases.common.networking.HTTPResult +import com.revenuecat.purchases.common.networking.RCHTTPStatusCodes +import com.revenuecat.purchases.common.workflows.FileCachedWorkflowCdnFetcher +import com.revenuecat.purchases.common.workflows.WorkflowCdnFetcher +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Fail.fail +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.net.URL + +@OptIn(InternalRevenueCatAPI::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +class BackendWorkflowsTest { + + private val mockClient: HTTPClient = mockk(relaxed = true) + private val mockBaseURL = URL("http://mock-api-test.revenuecat.com/") + private val apiKey = "TEST_API_KEY" + private val defaultAuthHeaders = mapOf("Authorization" to "Bearer $apiKey") + private val mockAppConfig: AppConfig = mockk().apply { + every { baseURL } returns mockBaseURL + every { customEntitlementComputation } returns false + every { fallbackBaseURLs } returns emptyList() + } + private val dispatcher = SyncDispatcher() + private val backendHelper = BackendHelper(apiKey, dispatcher, mockAppConfig, mockClient) + private val backend = Backend( + mockAppConfig, + dispatcher, + dispatcher, + mockClient, + backendHelper, + workflowCdnFetcher = FileCachedWorkflowCdnFetcher(fileRepository = null), + ) + private val appUserId = "user_1" + + private val minimalUiConfigJson = """ + "ui_config": { + "app": { "colors": {}, "fonts": {} }, + "localizations": {}, + "variable_config": {} + } + """.trimIndent() + + @Test + fun `getWorkflows calls performRequest with GetWorkflows endpoint`() { + val listJson = """ + { + "workflows": [], + $minimalUiConfigJson + } + """.trimIndent() + every { + mockClient.performRequest( + baseURL = mockBaseURL, + endpoint = Endpoint.GetWorkflows(appUserId), + body = null, + postFieldsToSign = null, + requestHeaders = defaultAuthHeaders, + fallbackBaseURLs = emptyList(), + ) + } returns httpResult(RCHTTPStatusCodes.SUCCESS, listJson) + + var success = false + backend.getWorkflows( + appUserID = appUserId, + appInBackground = false, + onSuccess = { + assertThat(it.workflows).isEmpty() + success = true + }, + onError = { fail("unexpected error $it") }, + ) + assertThat(success).isTrue() + verify(exactly = 1) { + mockClient.performRequest( + baseURL = mockBaseURL, + endpoint = Endpoint.GetWorkflows(appUserId), + body = null, + postFieldsToSign = null, + requestHeaders = defaultAuthHeaders, + fallbackBaseURLs = emptyList(), + ) + } + } + + @Test + fun `getWorkflow inline unwraps data and returns WorkflowFetchResult`() { + val workflowJson = """ + { + "id": "wf_1", + "display_name": "W", + "initial_step_id": "step_1", + "steps": { + "step_1": { + "id": "step_1", + "type": "screen", + "param_values": {}, + "triggers": [], + "outputs": {}, + "trigger_actions": {}, + "metadata": null + } + }, + "screens": {}, + $minimalUiConfigJson, + "content_max_width": null, + "metadata": null + } + """.trimIndent() + val envelope = """ + { + "action": "inline", + "data": $workflowJson + } + """.trimIndent() + every { + mockClient.performRequest( + baseURL = mockBaseURL, + endpoint = Endpoint.GetWorkflow(appUserId, "wf_1"), + body = null, + postFieldsToSign = null, + requestHeaders = defaultAuthHeaders, + fallbackBaseURLs = emptyList(), + ) + } returns httpResult(RCHTTPStatusCodes.SUCCESS, envelope) + + var success = false + backend.getWorkflow( + appUserID = appUserId, + workflowId = "wf_1", + appInBackground = false, + onSuccess = { result -> + assertThat(result.workflow.id).isEqualTo("wf_1") + assertThat(result.workflow.initialStepId).isEqualTo("step_1") + assertThat(result.enrolledVariants).isNull() + success = true + }, + onError = { fail("unexpected error $it") }, + ) + assertThat(success).isTrue() + } + + @Test + fun `getWorkflow use_cdn uses injected WorkflowCdnFetcher`() { + val cdnWorkflowJson = """ + { + "id": "wf_cdn", + "display_name": "From CDN", + "initial_step_id": "step_1", + "steps": { + "step_1": { + "id": "step_1", + "type": "screen", + "param_values": {}, + "triggers": [], + "outputs": {}, + "trigger_actions": {}, + "metadata": null + } + }, + "screens": {}, + $minimalUiConfigJson, + "content_max_width": null, + "metadata": null + } + """.trimIndent() + var fetchedUrl: String? = null + val testFetcher = WorkflowCdnFetcher { url -> + fetchedUrl = url + cdnWorkflowJson + } + val backendWithFetcher = Backend( + mockAppConfig, + dispatcher, + dispatcher, + mockClient, + backendHelper, + workflowCdnFetcher = testFetcher, + ) + val envelope = """{"action":"use_cdn","url":"https://cdn.example.com/wf.json"}""" + every { + mockClient.performRequest( + baseURL = mockBaseURL, + endpoint = Endpoint.GetWorkflow(appUserId, "wf_cdn"), + body = null, + postFieldsToSign = null, + requestHeaders = defaultAuthHeaders, + fallbackBaseURLs = emptyList(), + ) + } returns httpResult(RCHTTPStatusCodes.SUCCESS, envelope) + + var success = false + backendWithFetcher.getWorkflow( + appUserID = appUserId, + workflowId = "wf_cdn", + appInBackground = false, + onSuccess = { result -> + assertThat(fetchedUrl).isEqualTo("https://cdn.example.com/wf.json") + assertThat(result.workflow.id).isEqualTo("wf_cdn") + assertThat(result.workflow.displayName).isEqualTo("From CDN") + success = true + }, + onError = { fail("unexpected error $it") }, + ) + assertThat(success).isTrue() + } + + @Test + fun `getWorkflow propagates HTTP errors`() { + every { + mockClient.performRequest( + baseURL = mockBaseURL, + endpoint = Endpoint.GetWorkflow(appUserId, "wf_missing"), + body = null, + postFieldsToSign = null, + requestHeaders = defaultAuthHeaders, + fallbackBaseURLs = emptyList(), + ) + } returns httpResult(RCHTTPStatusCodes.NOT_FOUND, """{"error":"not found"}""") + + var errorCode: PurchasesErrorCode? = null + backend.getWorkflow( + appUserID = appUserId, + workflowId = "wf_missing", + appInBackground = false, + onSuccess = { fail("expected error") }, + onError = { errorCode = it.code }, + ) + assertThat(errorCode).isNotNull + } + + private fun httpResult(responseCode: Int, payload: String) = HTTPResult( + responseCode = responseCode, + payload = payload, + origin = HTTPResult.Origin.BACKEND, + requestDate = null, + verificationResult = VerificationResult.NOT_REQUESTED, + isLoadShedderResponse = false, + isFallbackURL = false, + ) +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt index e70cf5d143..e9e1c77b1d 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/networking/EndpointTest.kt @@ -22,6 +22,8 @@ class EndpointTest { Endpoint.PostEvents, Endpoint.PostRedeemWebPurchase, Endpoint.GetVirtualCurrencies("test-user-id"), + Endpoint.GetWorkflows("test-user-id"), + Endpoint.GetWorkflow("test-user-id", "wf_test"), Endpoint.AliasUsers("test-user-id") ) @@ -46,6 +48,20 @@ class EndpointTest { assertThat(endpoint.getPath()).isEqualTo(expectedPath) } + @Test + fun `GetWorkflows has correct path`() { + val endpoint = Endpoint.GetWorkflows("test user-id") + val expectedPath = "/v1/subscribers/test%20user-id/workflows" + assertThat(endpoint.getPath()).isEqualTo(expectedPath) + } + + @Test + fun `GetWorkflow has correct path`() { + val endpoint = Endpoint.GetWorkflow("test user-id", "wf abc") + val expectedPath = "/v1/subscribers/test%20user-id/workflows/wf%20abc" + assertThat(endpoint.getPath()).isEqualTo(expectedPath) + } + @Test fun `LogIn has correct path`() { val endpoint = Endpoint.LogIn @@ -136,6 +152,7 @@ class EndpointTest { Endpoint.LogIn, Endpoint.PostReceipt, Endpoint.GetOfferings("test-user-id"), + Endpoint.GetWorkflows("test-user-id"), Endpoint.GetProductEntitlementMapping, Endpoint.PostRedeemWebPurchase, Endpoint.GetVirtualCurrencies(userId = "test-user-id"), @@ -155,6 +172,7 @@ class EndpointTest { Endpoint.PostDiagnostics, Endpoint.PostEvents, Endpoint.WebBillingGetProducts("test-user-id", setOf("product1", "product2")), + Endpoint.GetWorkflow("test-user-id", "wf_1"), Endpoint.AliasUsers("test-user-id"), ) for (endpoint in expectedNotSupportsValidationEndpoints) { @@ -195,6 +213,8 @@ class EndpointTest { fun `needsNonceToPerformSigning is false for expected values`() { val expectedEndpoints = listOf( Endpoint.GetOfferings("test-user-id"), + Endpoint.GetWorkflows("test-user-id"), + Endpoint.GetWorkflow("test-user-id", "wf_1"), Endpoint.GetProductEntitlementMapping, Endpoint.GetAmazonReceipt("test-user-id", "test-receipt-id"), Endpoint.PostAttributes("test-user-id"), diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowDetailHttpProcessorTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowDetailHttpProcessorTest.kt new file mode 100644 index 0000000000..007c901d7c --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowDetailHttpProcessorTest.kt @@ -0,0 +1,87 @@ +package com.revenuecat.purchases.common.workflows + +import com.revenuecat.purchases.InternalRevenueCatAPI +import com.revenuecat.purchases.VerificationResult +import com.revenuecat.purchases.common.networking.HTTPResult +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.json.JSONException +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.io.IOException + +@OptIn(InternalRevenueCatAPI::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +class WorkflowDetailHttpProcessorTest { + + private val fetcher = WorkflowCdnFetcher { url -> + when (url) { + "https://cdn.example/w.json" -> """{"id":"from_cdn"}""" + else -> error("unexpected url $url") + } + } + + private val processor = WorkflowDetailHttpProcessor(fetcher) + + private fun httpResult(responseCode: Int, payload: String) = HTTPResult( + responseCode = responseCode, + payload = payload, + origin = HTTPResult.Origin.BACKEND, + requestDate = null, + verificationResult = VerificationResult.NOT_REQUESTED, + isLoadShedderResponse = false, + isFallbackURL = false, + ) + + @Test + fun `returns original result when not successful`() { + val raw = httpResult(500, "{}") + val out = processor.process(raw) + assertThat(out.httpResult).isSameAs(raw) + assertThat(out.enrolledVariants).isNull() + } + + @Test + fun `inline unwraps data and parses enrolled_variants`() { + val raw = httpResult( + 200, + """{"action":"inline","data":{"id":"wf1"},"enrolled_variants":{"a":"b"}}""", + ) + val out = processor.process(raw) + assertThat(out.httpResult.responseCode).isEqualTo(200) + assertThat(out.httpResult.payload).isEqualTo("""{"id":"wf1"}""") + assertThat(out.enrolledVariants).containsExactlyEntriesOf(mapOf("a" to "b")) + } + + @Test + fun `use_cdn fetches payload and preserves enrolled_variants`() { + val raw = httpResult( + 200, + """{"action":"use_cdn","url":"https://cdn.example/w.json","enrolled_variants":{"x":"y"}}""", + ) + val out = processor.process(raw) + assertThat(out.httpResult.payload).isEqualTo("""{"id":"from_cdn"}""") + assertThat(out.enrolledVariants).containsExactlyEntriesOf(mapOf("x" to "y")) + } + + @Test + fun `unknown action throws`() { + val raw = httpResult(200, """{"action":"other"}""") + assertThatThrownBy { processor.process(raw) } + .isInstanceOf(JSONException::class.java) + .hasMessageContaining("other") + } + + @Test + fun `use_cdn propagates IOException from fetcher`() { + val failing = WorkflowDetailHttpProcessor( + WorkflowCdnFetcher { throw IOException("network") }, + ) + val raw = httpResult(200, """{"action":"use_cdn","url":"https://x"}""") + assertThatThrownBy { failing.process(raw) } + .isInstanceOf(IOException::class.java) + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowJsonParserTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowJsonParserTest.kt new file mode 100644 index 0000000000..4fe6f13dc5 --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/common/workflows/WorkflowJsonParserTest.kt @@ -0,0 +1,93 @@ +package com.revenuecat.purchases.common.workflows + +import com.revenuecat.purchases.InternalRevenueCatAPI +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@OptIn(InternalRevenueCatAPI::class) +@RunWith(JUnit4::class) +class WorkflowJsonParserTest { + + @Test + fun `parseWorkflowsListResponse maps workflows and ui_config`() { + val json = """ + { + "workflows": [ + { "id": "wf_1", "display_name": "Flow A" } + ], + "ui_config": { + "app": { "colors": {}, "fonts": {} }, + "localizations": {}, + "variable_config": {} + } + } + """.trimIndent() + + val parsed = WorkflowJsonParser.parseWorkflowsListResponse(json) + + assertThat(parsed.workflows).hasSize(1) + assertThat(parsed.workflows.single().id).isEqualTo("wf_1") + assertThat(parsed.workflows.single().displayName).isEqualTo("Flow A") + } + + @Test + fun `parsePublishedWorkflow maps steps, triggers, and trigger_actions`() { + val json = """ + { + "id": "wf_test", + "display_name": "Test", + "initial_step_id": "step_1", + "steps": { + "step_1": { + "id": "step_1", + "type": "screen", + "screen_id": "pw458e23295b7841f8", + "param_values": { + "experiment_id": "expeae100d588", + "experiment_variant": "b", + "is_last_variant_step": true + }, + "triggers": [ + { + "name": "Button", + "type": "on_press", + "action_id": "btn_wagcLsIVjN", + "component_id": "wagcLsIVjN" + } + ], + "outputs": {}, + "trigger_actions": { + "btn_wagcLsIVjN": { "type": "step", "step_id": "step_2" } + }, + "metadata": null + } + }, + "screens": {}, + "ui_config": { + "app": { "colors": {}, "fonts": {} }, + "localizations": {}, + "variable_config": {} + }, + "content_max_width": 100, + "metadata": {} + } + """.trimIndent() + + val parsed = WorkflowJsonParser.parsePublishedWorkflow(json) + + assertThat(parsed.id).isEqualTo("wf_test") + assertThat(parsed.initialStepId).isEqualTo("step_1") + assertThat(parsed.contentMaxWidth).isEqualTo(100) + + val step = parsed.steps["step_1"]!! + assertThat(step.screenId).isEqualTo("pw458e23295b7841f8") + assertThat(step.triggers).hasSize(1) + assertThat(step.triggers[0].name).isEqualTo("Button") + assertThat(step.triggers[0].type).isEqualTo("on_press") + assertThat(step.triggers[0].actionId).isEqualTo("btn_wagcLsIVjN") + assertThat(step.triggers[0].componentId).isEqualTo("wagcLsIVjN") + assertThat(step.triggerActions["btn_wagcLsIVjN"]?.resolvedTargetStepId).isEqualTo("step_2") + } +}