Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
279 changes: 279 additions & 0 deletions WebViewLocalStateStorageTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
//
// WebViewLocalStateStorageTests.swift
// MindboxTests
//
// Created by Sergei Semko on 3/11/26.
// Copyright © 2026 Mindbox. All rights reserved.
//

import Testing
@testable import Mindbox

@Suite("WebViewLocalStateStorage", .tags(.webView))
struct WebViewLocalStateStorageTests {

private let testSuiteName = "cloud.Mindbox.test.webview.localState"
private let keyPrefix = Constants.WebViewLocalState.keyPrefix

private func makeSUT() -> (sut: WebViewLocalStateStorage, defaults: UserDefaults, persistence: MockPersistenceStorage) {
let persistence = MockPersistenceStorage()
let defaults = UserDefaults(suiteName: testSuiteName)!
defaults.removePersistentDomain(forName: testSuiteName)
let sut = WebViewLocalStateStorage(dataDefaults: defaults, persistenceStorage: persistence)
return (sut, defaults, persistence)
}

// MARK: - get

@Test("get returns default version and empty data when storage is empty")
func getEmptyStorage() {
let (sut, _, _) = makeSUT()

let state = sut.get(keys: [])

#expect(state.version == Constants.WebViewLocalState.defaultVersion)
#expect(state.data.isEmpty)
}

@Test("get returns all stored keys when keys array is empty")
func getAllKeys() {
let (sut, defaults, _) = makeSUT()
defaults.set("value1", forKey: "\(keyPrefix)key1")
defaults.set("value2", forKey: "\(keyPrefix)key2")

let state = sut.get(keys: [])

#expect(state.data.count == 2)
#expect(state.data["key1"] == "value1")
#expect(state.data["key2"] == "value2")
}

@Test("get returns only requested keys")
func getSpecificKeys() {
let (sut, defaults, _) = makeSUT()
defaults.set("value1", forKey: "\(keyPrefix)key1")
defaults.set("value2", forKey: "\(keyPrefix)key2")
defaults.set("value3", forKey: "\(keyPrefix)key3")

let state = sut.get(keys: ["key1", "key3"])

#expect(state.data.count == 2)
#expect(state.data["key1"] == "value1")
#expect(state.data["key3"] == "value3")
}

@Test("get omits missing keys from data")
func getMissingKeys() {
let (sut, defaults, _) = makeSUT()
defaults.set("value1", forKey: "\(keyPrefix)key1")

let state = sut.get(keys: ["key1", "missing"])

#expect(state.data.count == 1)
#expect(state.data["key1"] == "value1")
#expect(state.data["missing"] == nil)
}

@Test("get returns current version from persistence")
func getCurrentVersion() {
let (sut, _, persistence) = makeSUT()
persistence.webViewLocalStateVersion = 5

let state = sut.get(keys: [])

#expect(state.version == 5)
}

@Test("get returns default version when persistence version is nil")
func getDefaultVersion() {
let (sut, _, persistence) = makeSUT()
persistence.webViewLocalStateVersion = nil

let state = sut.get(keys: [])

#expect(state.version == Constants.WebViewLocalState.defaultVersion)
}

// MARK: - set

@Test("set stores values in UserDefaults")
func setStoresValues() {
let (sut, defaults, _) = makeSUT()

_ = sut.set(data: ["key1": "value1", "key2": "value2"])

#expect(defaults.string(forKey: "\(keyPrefix)key1") == "value1")
#expect(defaults.string(forKey: "\(keyPrefix)key2") == "value2")
}

@Test("set removes key when value is nil")
func setRemovesNilKey() {
let (sut, defaults, _) = makeSUT()
defaults.set("value1", forKey: "\(keyPrefix)key1")

_ = sut.set(data: ["key1": nil])

#expect(defaults.string(forKey: "\(keyPrefix)key1") == nil)
}

@Test("set updates existing values")
func setUpdatesValues() {
let (sut, defaults, _) = makeSUT()
defaults.set("old", forKey: "\(keyPrefix)key1")

let state = sut.set(data: ["key1": "new"])

#expect(defaults.string(forKey: "\(keyPrefix)key1") == "new")
#expect(state.data["key1"] == "new")
}

@Test("set returns only affected keys")
func setReturnsAffectedKeys() {
let (sut, defaults, _) = makeSUT()
defaults.set("existing", forKey: "\(keyPrefix)existing")

let state = sut.set(data: ["key1": "value1"])

#expect(state.data.count == 1)
#expect(state.data["key1"] == "value1")
#expect(state.data["existing"] == nil)
}

@Test("set does not change version")
func setPreservesVersion() {
let (sut, _, persistence) = makeSUT()
persistence.webViewLocalStateVersion = 3

let state = sut.set(data: ["key1": "value1"])

#expect(state.version == 3)
#expect(persistence.webViewLocalStateVersion == 3)
}

@Test("set stores each key as separate UserDefaults entry")
func setSeparateEntries() {
let (sut, defaults, _) = makeSUT()

_ = sut.set(data: ["firstKey": "firstValue", "secondKey": "secondValue"])

#expect(defaults.string(forKey: "\(keyPrefix)firstKey") == "firstValue")
#expect(defaults.string(forKey: "\(keyPrefix)secondKey") == "secondValue")
}

// MARK: - initialize

@Test("initialize stores version in PersistenceStorage")
func initStoresVersion() {
let (sut, _, persistence) = makeSUT()

_ = sut.initialize(version: 7, data: ["key": "value"])

#expect(persistence.webViewLocalStateVersion == 7)
}

@Test("initialize stores data and returns it")
func initStoresAndReturnsData() throws {
let (sut, defaults, _) = makeSUT()

let state = try #require(sut.initialize(version: 2, data: ["key1": "value1", "key2": "value2"]))

#expect(state.version == 2)
#expect(state.data["key1"] == "value1")
#expect(state.data["key2"] == "value2")
#expect(defaults.string(forKey: "\(keyPrefix)key1") == "value1")
#expect(defaults.string(forKey: "\(keyPrefix)key2") == "value2")
}

@Test("initialize rejects zero version")
func initRejectsZero() {
let (sut, _, _) = makeSUT()

#expect(sut.initialize(version: 0, data: ["key": "value"]) == nil)
}

@Test("initialize rejects negative version")
func initRejectsNegative() {
let (sut, _, _) = makeSUT()

#expect(sut.initialize(version: -1, data: ["key": "value"]) == nil)
}

@Test("initialize removes keys with nil values")
func initRemovesNilKeys() {
let (sut, defaults, _) = makeSUT()
defaults.set("value1", forKey: "\(keyPrefix)key1")

let state = sut.initialize(version: 2, data: ["key1": nil])

#expect(state != nil)
#expect(defaults.string(forKey: "\(keyPrefix)key1") == nil)
}

@Test("initialize merges with existing data")
func initMergesData() {
let (sut, defaults, _) = makeSUT()
defaults.set("existing", forKey: "\(keyPrefix)old")

let state = sut.initialize(version: 3, data: ["new": "value"])

#expect(state != nil)
#expect(defaults.string(forKey: "\(keyPrefix)old") == "existing")
#expect(defaults.string(forKey: "\(keyPrefix)new") == "value")
}

@Test("initialize does not store version on rejection")
func initPreservesVersionOnReject() {
let (sut, _, persistence) = makeSUT()
persistence.webViewLocalStateVersion = 5

_ = sut.initialize(version: 0, data: ["key": "value"])

#expect(persistence.webViewLocalStateVersion == 5)
}

// MARK: - Integration

@Test("full flow: init → set → get")
func fullFlow() throws {
let (sut, _, _) = makeSUT()

let initState = try #require(sut.initialize(version: 2, data: ["key1": "value1", "key2": "value2"]))
#expect(initState.version == 2)

let setState = sut.set(data: ["key1": "updated", "key2": nil, "key3": "value3"])
#expect(setState.version == 2)

let getState = sut.get(keys: [])
#expect(getState.version == 2)
#expect(getState.data["key1"] == "updated")
#expect(getState.data["key2"] == nil)
#expect(getState.data["key3"] == "value3")
}

@Test("get after set with null returns empty for deleted key")
func setNullThenGet() {
let (sut, _, _) = makeSUT()

_ = sut.set(data: ["key1": "value1"])
_ = sut.set(data: ["key1": nil])

let state = sut.get(keys: ["key1"])
#expect(state.data.isEmpty)
}

@Test("prefix isolation: non-prefixed keys and Apple system keys are filtered out")
func prefixIsolation() {
let (sut, defaults, _) = makeSUT()
defaults.set("foreign", forKey: "foreignKey")
defaults.set("value", forKey: "\(keyPrefix)myKey")

let state = sut.get(keys: [])

#expect(state.data.count == 1)
#expect(state.data["myKey"] == "value")
#expect(state.data["foreignKey"] == nil)
#expect(state.data["AKLastLocale"] == nil)
#expect(state.data["AppleLocale"] == nil)
#expect(state.data["NSInterfaceStyle"] == nil)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ internal class DataCollector(
private val providers: MutableMap<String, Provider> by lazy {
mutableMapOf<String, Provider>(
KEY_DEVICE_UUID to Provider.string(MindboxPreferences.deviceUuid),
KEY_LOCAL_STATE_VERSION to Provider.number(MindboxPreferences.localStateVersion),
KEY_ENDPOINT_ID to Provider.string(configuration.endpointId),
KEY_IN_APP_ID to Provider.string(inAppId),
KEY_INSETS to createInsetsPayload(inAppInsets),
Expand Down Expand Up @@ -76,6 +77,7 @@ internal class DataCollector(
private const val KEY_TRACK_VISIT_REQUEST_URL = "trackVisitRequestUrl"
private const val KEY_USER_VISIT_COUNT = "userVisitCount"
private const val KEY_VERSION = "version"
private const val KEY_LOCAL_STATE_VERSION = "localStateVersion"
private const val VALUE_PLATFORM = "android"
private const val VALUE_THEME_DARK = "dark"
private const val VALUE_THEME_LIGHT = "light"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ public enum class WebViewAction {

@SerializedName("navigationIntercepted")
NAVIGATION_INTERCEPTED,

@SerializedName("localState.get")
LOCAL_STATE_GET,

@SerializedName("localState.set")
LOCAL_STATE_SET,

@SerializedName("localState.init")
LOCAL_STATE_INIT,
}

@InternalMindboxApi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ internal class WebViewInAppViewHolder(
private val linkRouter: WebViewLinkRouter by lazy {
MindboxWebViewLinkRouter(appContext)
}
private val localStateStore: WebViewLocalStateStore by lazy {
WebViewLocalStateStore(appContext)
}

override fun bind() {}

Expand Down Expand Up @@ -132,6 +135,9 @@ internal class WebViewInAppViewHolder(
register(WebViewAction.ASYNC_OPERATION, ::handleAsyncOperationAction)
register(WebViewAction.OPEN_LINK, ::handleOpenLinkAction)
registerSuspend(WebViewAction.SYNC_OPERATION, ::handleSyncOperationAction)
registerSuspend(WebViewAction.LOCAL_STATE_GET, ::handleLocalStateGetAction)
registerSuspend(WebViewAction.LOCAL_STATE_SET, ::handleLocalStateSetAction)
registerSuspend(WebViewAction.LOCAL_STATE_INIT, ::handleLocalStateInitAction)
register(WebViewAction.READY) {
handleReadyAction(
configuration = configuration,
Expand Down Expand Up @@ -249,6 +255,21 @@ internal class WebViewInAppViewHolder(
return operationExecutor.executeSyncOperation(message.payload)
}

private fun handleLocalStateGetAction(message: BridgeMessage.Request): String {
val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD
return localStateStore.getState(payload)
}

private fun handleLocalStateSetAction(message: BridgeMessage.Request): String {
val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD
return localStateStore.setState(payload)
}

private fun handleLocalStateInitAction(message: BridgeMessage.Request): String {
val payload: String = message.payload ?: BridgeMessage.EMPTY_PAYLOAD
return localStateStore.initState(payload)
}

private fun createWebViewController(layer: Layer.WebViewLayer): WebViewController {
mindboxLogI("Creating WebView for In-App: ${wrapper.inAppType.inAppId} with layer ${layer.type}")
val controller: WebViewController = WebViewController.create(currentDialog.context, BuildConfig.DEBUG)
Expand Down
Loading
Loading