From 6d7666a5a74fcff38c7ef6ec6f63ecdd76ee39ca Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 7 Sep 2026 13:21:32 +0200 Subject: [PATCH 1/4] Fix product reference names restored from cached config --- .../CachedProductPricesWebViewTest.kt | 89 +++++++++++ .../sdk/models/product/ProductItem.kt | 7 +- .../CachedProductPricesReproductionTest.kt | 151 ++++++++++++++++++ 3 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt new file mode 100644 index 000000000..010b75103 --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt @@ -0,0 +1,89 @@ +package com.superwall.sdk.paywall.view.webview.templating + +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.product.ProductItem +import com.superwall.sdk.models.product.ProductVariable +import com.superwall.sdk.network.JsonFactory +import com.superwall.sdk.paywall.view.webview.templating.models.Variables +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +/** + * Uses the real config and template serializers and a real emulator WebView. + * The HTML is a minimal price-binding fixture, not the customer's hosted paywall runtime. + */ +@RunWith(AndroidJUnit4::class) +class CachedProductPricesWebViewTest { + private val json = JsonFactory.JSON + + @Test + fun freshConfigDisplaysOfferPrice() { + assertEquals("€23.99", renderOfferPrice(cached = false)) + } + + @Test + fun diskCachedConfigDisplaysOfferPrice() { + // Fails on 2.8.2: price data exists but the "offer" binding is absent. + assertEquals("€23.99", renderOfferPrice(cached = true)) + } + + private fun renderOfferPrice(cached: Boolean): String { + val item = json.decodeFromString( + """{"reference_name":"offer","store_product":{"store":"PLAY_STORE", + "product_identifier":"yearly19_3_dc","base_plan_identifier":"p1y", + "offer":{"type":"SPECIFIED","offer_identifier":"introprice"}}}""", + ) + val paywall = Paywall.stub().copy(productVariables = null, swProductVariablesTemplate = null) + paywall.productItems = listOf(item) + val fresh = Config.stub().copy(paywalls = listOf(paywall)) + val config = if (cached) json.decodeFromString(Config.serializer(), json.encodeToString(Config.serializer(), fresh)) else fresh + val product = config.paywalls.single().productItems.single() + val variables = Variables( + listOf(ProductVariable(product.name, mapOf("price" to "€23.99"))), + emptyMap(), emptyMap(), emptyMap(), + ) + val payload = json.encodeToString(Variables.serializer(), variables) + val instrumentation = InstrumentationRegistry.getInstrumentation() + val finished = CountDownLatch(1) + val output = AtomicReference() + lateinit var webView: WebView + instrumentation.runOnMainSync { + webView = WebView(instrumentation.targetContext) + webView.settings.javaScriptEnabled = true + webView.webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView, url: String?) { + view.evaluateJavascript("document.getElementById('price').textContent") { + output.set(it) + finished.countDown() + } + } + } + webView.loadDataWithBaseURL( + "https://localhost/", """ + + """.trimIndent(), "text/html", "UTF-8", null, + ) + } + try { + assertTrue("WebView did not finish loading", finished.await(20, TimeUnit.SECONDS)) + return json.parseToJsonElement(output.get()).jsonPrimitive.content + } finally { + instrumentation.runOnMainSync { webView.destroy() } + } + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt b/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt index 650c704e0..a3751549a 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/product/ProductItem.kt @@ -481,8 +481,11 @@ object ProductItemSerializer : KSerializer { ?: throw SerializationException("This class can be loaded only by Json") val jsonObject = jsonInput.decodeJsonElement().jsonObject - // Extract fields using the expected names during deserialization - val name = jsonObject["reference_name"]?.jsonPrimitive?.content ?: "" + // API responses use reference_name; serialized cache entries use product. + val name = + jsonObject["reference_name"]?.jsonPrimitive?.content + ?: jsonObject["product"]?.jsonPrimitive?.content + ?: "" val storeProductJsonObject = jsonObject["store_product"]?.jsonObject ?: throw SerializationException("Missing store_product") diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt new file mode 100644 index 000000000..0e2bf8062 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt @@ -0,0 +1,151 @@ +package com.superwall.sdk.paywall.request + +import TemplateLogic +import com.superwall.sdk.config.PaywallPreload +import com.superwall.sdk.dependencies.VariablesFactory +import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.IOScope +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.customer.CustomerInfo +import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.product.ProductItem +import com.superwall.sdk.network.JsonFactory +import com.superwall.sdk.network.Network +import com.superwall.sdk.paywall.view.webview.templating.models.Variables +import com.superwall.sdk.store.StoreManager +import com.superwall.sdk.store.abstractions.product.StoreProduct +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Regression coverage for product reference names lost from cached config in 2.8.2. */ +class CachedProductPricesReproductionTest { + private val json = JsonFactory.JSON + private val names = listOf("primary", "secondary", "offer") + private val ids = listOf("yearly23:yearly23-base:yearly23-trial", "monthly23:monthly23-base:monthly23-trial", "yearly19_3_dc:p1y:introprice") + private val prices = listOf("€39.99", "€4.99", "€23.99") + + private fun freshConfig(): Config { + val items = ids.mapIndexed { index, id -> + val parts = id.split(":") + json.decodeFromString( + """{"reference_name":"${names[index]}","sw_composite_product_id":"$id", + "store_product":{"store":"PLAY_STORE","product_identifier":"${parts[0]}", + "base_plan_identifier":"${parts[1]}","offer":{"type":"SPECIFIED","offer_identifier":"${parts[2]}"}}}""", + ) + } + val paywall = Paywall.stub().copy(productVariables = null, swProductVariablesTemplate = null) + paywall.productItems = items + return Config.stub().copy(paywalls = listOf(paywall)) + } + + private fun restoredConfig(fresh: Config): Config = + json.decodeFromString(Config.serializer(), json.encodeToString(Config.serializer(), fresh)) + + @Test + fun `product reference name survives serialization`() { + val original = freshConfig().paywalls.single().productItems.last() + val restored = json.decodeFromString(ProductItem.serializer(), json.encodeToString(ProductItem.serializer(), original)) + assertEquals(original.fullProductId, restored.fullProductId) + assertEquals("Product cache round-trip must preserve the template reference", "offer", restored.name) + } + + @Test + fun `whole config round trip preserves all product references`() { + val restored = restoredConfig(freshConfig()) + assertEquals(ids, restored.paywalls.single().productItems.map { it.fullProductId }) + assertEquals(names, restored.paywalls.single().productItems.map { it.name }) + } + + @Test + fun `fresh config supplies named prices to webview`() = runTest { + val fresh = freshConfig() + val harness = Harness(fresh) + assertNamedPrices(harness.templates(harness.load())) + } + + @Test + fun `same build refresh repairs prices after loading disk cached config`() = runTest { + val fresh = freshConfig() + val restored = restoredConfig(fresh) + val harness = Harness(restored) + val cached = harness.load() + val before = harness.templates(cached) + val loadedPrices = before[1].jsonObject["variables"]!!.jsonObject["products"]!!.jsonArray.map { + it.jsonObject.values.single().jsonObject["price"]!!.jsonPrimitive.content + } + assertEquals("Product prices must be loaded before the config refresh", prices, loadedPrices) + println("Product bindings before refresh: ${before[0]}") + harness.config = fresh + // The same production diff + invalidation used by ConfigState.RefreshConfig in 2.8.2. + val changed = PaywallPreload.changedPaywallIds(restored, fresh) + assertTrue("Fixture must keep the same paywall build", changed.isEmpty()) + harness.manager.removeCachedPaywalls(changed) + assertNamedPrices(harness.templates(harness.load())) + } + + @Test + fun `281 full request cache reset recovers named prices`() = runTest { + val fresh = freshConfig() + val harness = Harness(restoredConfig(fresh)) + harness.load() + harness.config = fresh + // 2.8.1 invalidated every request entry on config refresh. + harness.manager.resetCache() + assertNamedPrices(harness.templates(harness.load())) + } + + private fun assertNamedPrices(templates: JsonArray) { + val productRefs = templates[0].jsonObject["products"]!!.jsonArray.map { it.jsonObject["product"]!!.jsonPrimitive.content } + val variables = templates[1].jsonObject["variables"]!!.jsonObject["products"]!!.jsonArray + assertEquals("WebView product references must be addressable by name", names, productRefs) + names.forEachIndexed { index, name -> + assertEquals(prices[index], variables[index].jsonObject[name]!!.jsonObject["price"]!!.jsonPrimitive.content) + } + } + + private inner class Harness(var config: Config) { + private val store = StoreManager(mockk(relaxed = true), mockk(relaxed = true), { mockk(relaxed = true) }, track = {}) + private val factory = mockk { + every { makeDeviceInfo() } returns mockk { every { locale } returns "en_GB" } + every { makeStaticPaywall(any(), any()) } answers { config.paywalls.single() } + every { activePaywallId() } returns null + every { currentCustomerInfo() } returns CustomerInfo.empty() + } + val manager = PaywallRequestManager( + store, mockk(), factory, IOScope(Dispatchers.Unconfined), + track = {}, getGlobalOverrides = { emptyMap() }, trackScope = IOScope(Dispatchers.Unconfined), + ) + init { + ids.forEachIndexed { index, id -> + store.cacheProduct(id, mockk(relaxed = true) { + every { fullIdentifier } returns id + every { attributes } returns mapOf("identifier" to id, "price" to prices[index]) + }) + } + } + suspend fun load(): Paywall { + val result = manager.getPaywall(PaywallRequest(null, ResponseIdentifiers(config.paywalls.single().identifier), PaywallRequest.Overrides(null, false), false, "register", 0)) + check(result is Either.Success) { "Product request failed: $result" } + assertNull("Prices must load without billing errors", result.value.productsLoadingInfo.failAt) + return result.value + } + suspend fun templates(paywall: Paywall): JsonArray { + val variablesFactory = mockk() + coEvery { variablesFactory.makeJsonVariables(any(), any(), any()) } coAnswers { + Variables(firstArg(), emptyMap(), emptyMap(), emptyMap()).templated() + } + return json.parseToJsonElement(TemplateLogic.getBase64EncodedTemplates(json, paywall, null, variablesFactory) { it }).jsonArray + } + } +} From dfc1c8e5a534cbf55f01cd42b9a9c3050446d3df Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 7 Sep 2026 13:22:31 +0200 Subject: [PATCH 2/4] Document cached product price fix in unreleased changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e98d66d1d..d9f770821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## Unreleased + +- Fix prices not showing when product/offers are fetched from cache + ## 2.8.2 ## Enhancements From 7af3aabb3c637d3bdd300505bd35da0de20180a7 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Mon, 7 Sep 2026 13:23:58 +0200 Subject: [PATCH 3/4] Format cached product price regression tests --- .../CachedProductPricesWebViewTest.kt | 48 +++-- .../CachedProductPricesReproductionTest.kt | 173 ++++++++++++------ 2 files changed, 147 insertions(+), 74 deletions(-) diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt index 010b75103..022c7211a 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/view/webview/templating/CachedProductPricesWebViewTest.kt @@ -39,20 +39,28 @@ class CachedProductPricesWebViewTest { } private fun renderOfferPrice(cached: Boolean): String { - val item = json.decodeFromString( - """{"reference_name":"offer","store_product":{"store":"PLAY_STORE", + val item = + json.decodeFromString( + """{"reference_name":"offer","store_product":{"store":"PLAY_STORE", "product_identifier":"yearly19_3_dc","base_plan_identifier":"p1y", "offer":{"type":"SPECIFIED","offer_identifier":"introprice"}}}""", - ) + ) val paywall = Paywall.stub().copy(productVariables = null, swProductVariablesTemplate = null) paywall.productItems = listOf(item) val fresh = Config.stub().copy(paywalls = listOf(paywall)) val config = if (cached) json.decodeFromString(Config.serializer(), json.encodeToString(Config.serializer(), fresh)) else fresh - val product = config.paywalls.single().productItems.single() - val variables = Variables( - listOf(ProductVariable(product.name, mapOf("price" to "€23.99"))), - emptyMap(), emptyMap(), emptyMap(), - ) + val product = + config.paywalls + .single() + .productItems + .single() + val variables = + Variables( + listOf(ProductVariable(product.name, mapOf("price" to "€23.99"))), + emptyMap(), + emptyMap(), + emptyMap(), + ) val payload = json.encodeToString(Variables.serializer(), variables) val instrumentation = InstrumentationRegistry.getInstrumentation() val finished = CountDownLatch(1) @@ -61,22 +69,30 @@ class CachedProductPricesWebViewTest { instrumentation.runOnMainSync { webView = WebView(instrumentation.targetContext) webView.settings.javaScriptEnabled = true - webView.webViewClient = object : WebViewClient() { - override fun onPageFinished(view: WebView, url: String?) { - view.evaluateJavascript("document.getElementById('price').textContent") { - output.set(it) - finished.countDown() + webView.webViewClient = + object : WebViewClient() { + override fun onPageFinished( + view: WebView, + url: String?, + ) { + view.evaluateJavascript("document.getElementById('price').textContent") { + output.set(it) + finished.countDown() + } } } - } webView.loadDataWithBaseURL( - "https://localhost/", """ + "https://localhost/", + """ - """.trimIndent(), "text/html", "UTF-8", null, + """.trimIndent(), + "text/html", + "UTF-8", + null, ) } try { diff --git a/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt b/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt index 0e2bf8062..701ddac56 100644 --- a/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/paywall/request/CachedProductPricesReproductionTest.kt @@ -32,18 +32,20 @@ import org.junit.Test class CachedProductPricesReproductionTest { private val json = JsonFactory.JSON private val names = listOf("primary", "secondary", "offer") - private val ids = listOf("yearly23:yearly23-base:yearly23-trial", "monthly23:monthly23-base:monthly23-trial", "yearly19_3_dc:p1y:introprice") + private val ids = + listOf("yearly23:yearly23-base:yearly23-trial", "monthly23:monthly23-base:monthly23-trial", "yearly19_3_dc:p1y:introprice") private val prices = listOf("€39.99", "€4.99", "€23.99") private fun freshConfig(): Config { - val items = ids.mapIndexed { index, id -> - val parts = id.split(":") - json.decodeFromString( - """{"reference_name":"${names[index]}","sw_composite_product_id":"$id", + val items = + ids.mapIndexed { index, id -> + val parts = id.split(":") + json.decodeFromString( + """{"reference_name":"${names[index]}","sw_composite_product_id":"$id", "store_product":{"store":"PLAY_STORE","product_identifier":"${parts[0]}", "base_plan_identifier":"${parts[1]}","offer":{"type":"SPECIFIED","offer_identifier":"${parts[2]}"}}}""", - ) - } + ) + } val paywall = Paywall.stub().copy(productVariables = null, swProductVariablesTemplate = null) paywall.productItems = items return Config.stub().copy(paywalls = listOf(paywall)) @@ -54,7 +56,12 @@ class CachedProductPricesReproductionTest { @Test fun `product reference name survives serialization`() { - val original = freshConfig().paywalls.single().productItems.last() + val original = + freshConfig() + .paywalls + .single() + .productItems + .last() val restored = json.decodeFromString(ProductItem.serializer(), json.encodeToString(ProductItem.serializer(), original)) assertEquals(original.fullProductId, restored.fullProductId) assertEquals("Product cache round-trip must preserve the template reference", "offer", restored.name) @@ -63,83 +70,133 @@ class CachedProductPricesReproductionTest { @Test fun `whole config round trip preserves all product references`() { val restored = restoredConfig(freshConfig()) - assertEquals(ids, restored.paywalls.single().productItems.map { it.fullProductId }) - assertEquals(names, restored.paywalls.single().productItems.map { it.name }) + assertEquals( + ids, + restored.paywalls + .single() + .productItems + .map { it.fullProductId }, + ) + assertEquals( + names, + restored.paywalls + .single() + .productItems + .map { it.name }, + ) } @Test - fun `fresh config supplies named prices to webview`() = runTest { - val fresh = freshConfig() - val harness = Harness(fresh) - assertNamedPrices(harness.templates(harness.load())) - } + fun `fresh config supplies named prices to webview`() = + runTest { + val fresh = freshConfig() + val harness = Harness(fresh) + assertNamedPrices(harness.templates(harness.load())) + } @Test - fun `same build refresh repairs prices after loading disk cached config`() = runTest { - val fresh = freshConfig() - val restored = restoredConfig(fresh) - val harness = Harness(restored) - val cached = harness.load() - val before = harness.templates(cached) - val loadedPrices = before[1].jsonObject["variables"]!!.jsonObject["products"]!!.jsonArray.map { - it.jsonObject.values.single().jsonObject["price"]!!.jsonPrimitive.content + fun `same build refresh repairs prices after loading disk cached config`() = + runTest { + val fresh = freshConfig() + val restored = restoredConfig(fresh) + val harness = Harness(restored) + val cached = harness.load() + val before = harness.templates(cached) + val loadedPrices = + before[1].jsonObject["variables"]!!.jsonObject["products"]!!.jsonArray.map { + it.jsonObject.values + .single() + .jsonObject["price"]!! + .jsonPrimitive.content + } + assertEquals("Product prices must be loaded before the config refresh", prices, loadedPrices) + println("Product bindings before refresh: ${before[0]}") + harness.config = fresh + // The same production diff + invalidation used by ConfigState.RefreshConfig in 2.8.2. + val changed = PaywallPreload.changedPaywallIds(restored, fresh) + assertTrue("Fixture must keep the same paywall build", changed.isEmpty()) + harness.manager.removeCachedPaywalls(changed) + assertNamedPrices(harness.templates(harness.load())) } - assertEquals("Product prices must be loaded before the config refresh", prices, loadedPrices) - println("Product bindings before refresh: ${before[0]}") - harness.config = fresh - // The same production diff + invalidation used by ConfigState.RefreshConfig in 2.8.2. - val changed = PaywallPreload.changedPaywallIds(restored, fresh) - assertTrue("Fixture must keep the same paywall build", changed.isEmpty()) - harness.manager.removeCachedPaywalls(changed) - assertNamedPrices(harness.templates(harness.load())) - } @Test - fun `281 full request cache reset recovers named prices`() = runTest { - val fresh = freshConfig() - val harness = Harness(restoredConfig(fresh)) - harness.load() - harness.config = fresh - // 2.8.1 invalidated every request entry on config refresh. - harness.manager.resetCache() - assertNamedPrices(harness.templates(harness.load())) - } + fun `281 full request cache reset recovers named prices`() = + runTest { + val fresh = freshConfig() + val harness = Harness(restoredConfig(fresh)) + harness.load() + harness.config = fresh + // 2.8.1 invalidated every request entry on config refresh. + harness.manager.resetCache() + assertNamedPrices(harness.templates(harness.load())) + } private fun assertNamedPrices(templates: JsonArray) { val productRefs = templates[0].jsonObject["products"]!!.jsonArray.map { it.jsonObject["product"]!!.jsonPrimitive.content } val variables = templates[1].jsonObject["variables"]!!.jsonObject["products"]!!.jsonArray assertEquals("WebView product references must be addressable by name", names, productRefs) names.forEachIndexed { index, name -> - assertEquals(prices[index], variables[index].jsonObject[name]!!.jsonObject["price"]!!.jsonPrimitive.content) + assertEquals( + prices[index], + variables[index] + .jsonObject[name]!! + .jsonObject["price"]!! + .jsonPrimitive.content, + ) } } - private inner class Harness(var config: Config) { + private inner class Harness( + var config: Config, + ) { private val store = StoreManager(mockk(relaxed = true), mockk(relaxed = true), { mockk(relaxed = true) }, track = {}) - private val factory = mockk { - every { makeDeviceInfo() } returns mockk { every { locale } returns "en_GB" } - every { makeStaticPaywall(any(), any()) } answers { config.paywalls.single() } - every { activePaywallId() } returns null - every { currentCustomerInfo() } returns CustomerInfo.empty() - } - val manager = PaywallRequestManager( - store, mockk(), factory, IOScope(Dispatchers.Unconfined), - track = {}, getGlobalOverrides = { emptyMap() }, trackScope = IOScope(Dispatchers.Unconfined), - ) + private val factory = + mockk { + every { makeDeviceInfo() } returns mockk { every { locale } returns "en_GB" } + every { makeStaticPaywall(any(), any()) } answers { config.paywalls.single() } + every { activePaywallId() } returns null + every { currentCustomerInfo() } returns CustomerInfo.empty() + } + val manager = + PaywallRequestManager( + store, + mockk(), + factory, + IOScope(Dispatchers.Unconfined), + track = {}, + getGlobalOverrides = { emptyMap() }, + trackScope = IOScope(Dispatchers.Unconfined), + ) + init { ids.forEachIndexed { index, id -> - store.cacheProduct(id, mockk(relaxed = true) { - every { fullIdentifier } returns id - every { attributes } returns mapOf("identifier" to id, "price" to prices[index]) - }) + store.cacheProduct( + id, + mockk(relaxed = true) { + every { fullIdentifier } returns id + every { attributes } returns mapOf("identifier" to id, "price" to prices[index]) + }, + ) } } + suspend fun load(): Paywall { - val result = manager.getPaywall(PaywallRequest(null, ResponseIdentifiers(config.paywalls.single().identifier), PaywallRequest.Overrides(null, false), false, "register", 0)) + val result = + manager.getPaywall( + PaywallRequest( + null, + ResponseIdentifiers(config.paywalls.single().identifier), + PaywallRequest.Overrides(null, false), + false, + "register", + 0, + ), + ) check(result is Either.Success) { "Product request failed: $result" } assertNull("Prices must load without billing errors", result.value.productsLoadingInfo.failAt) return result.value } + suspend fun templates(paywall: Paywall): JsonArray { val variablesFactory = mockk() coEvery { variablesFactory.makeJsonVariables(any(), any(), any()) } coAnswers { From e3c3aa6cc25e7ed594beac88ae69545b25a17daa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 7 Sep 2026 11:41:54 +0000 Subject: [PATCH 4/4] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 2f2376c36..01318ccb1 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches36.4% \ No newline at end of file +branches36.5% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 9076cc635..924227022 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage45.6% \ No newline at end of file +coverage45.8% \ No newline at end of file