Harden driver app realtime release
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package pl.firmatpp.kierowca.data
|
||||
|
||||
import java.io.IOException
|
||||
import java.net.UnknownHostException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -16,6 +17,23 @@ class ApiErrorMapperTest {
|
||||
assertEquals("Nie udało się połączyć z serwerem. Operacja nie została potwierdzona.", error.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsUnknownHostToActionableOfflineMessage() {
|
||||
val error = ApiErrorMapper.map(UnknownHostException("api-intranet.firmatpp.pl"))
|
||||
|
||||
assertEquals(ApiErrorKind.Network, error.kind)
|
||||
assertTrue(error.retryable)
|
||||
assertEquals(
|
||||
"Brak internetu lub połączenia z serwerem. Sprawdź zasięg i spróbuj ponownie.",
|
||||
error.message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotReportExpectedNetworkFailuresAsNonFatal() {
|
||||
assertFalse(ApiErrorMapper.shouldReportNonFatal(UnknownHostException("api-intranet.firmatpp.pl")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsMobileApiProblemCodes() {
|
||||
val error = ApiErrorMapper.mapProblem(
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package pl.firmatpp.kierowca.data
|
||||
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AppPreferencesStoreTest {
|
||||
@get:Rule
|
||||
val temporaryFolder = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun startsWithMaterial3ThemeMode() = runTest {
|
||||
val store = preferencesStore("default.preferences_pb")
|
||||
|
||||
assertEquals(AppThemeMode.Material3, store.themeMode.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistsSelectedThemeMode() = runTest {
|
||||
val file = temporaryFolder.newFile("theme.preferences_pb")
|
||||
val store = preferencesStore(file)
|
||||
|
||||
store.setThemeMode(AppThemeMode.Material3)
|
||||
|
||||
assertEquals(AppThemeMode.Material3, store.themeMode.first())
|
||||
}
|
||||
|
||||
private fun TestScope.preferencesStore(fileName: String): AppPreferencesStore =
|
||||
preferencesStore(temporaryFolder.newFile(fileName))
|
||||
|
||||
private fun TestScope.preferencesStore(file: File): AppPreferencesStore {
|
||||
val dataStore = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
return AppPreferencesStore(dataStore)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package pl.firmatpp.kierowca.sync
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okio.ByteString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import pl.firmatpp.kierowca.data.model.BroadcastAuthResponse
|
||||
import pl.firmatpp.kierowca.data.model.RealtimeConfigDto
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class DriverLiveSyncClientTest {
|
||||
private val config = RealtimeConfigDto(
|
||||
reverbEnabled = true,
|
||||
reverbAppKey = "app-key",
|
||||
reverbWsBaseUrl = "wss://example.test/app",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun reconnectsWithBackoffAfterSocketFailure() = runTest {
|
||||
val factory = FakeWebSocketFactory()
|
||||
val client = liveClient(factory = factory, scope = backgroundScope)
|
||||
|
||||
client.start("driver-1", config)
|
||||
factory.sockets.single().fail()
|
||||
|
||||
advanceTimeBy(999)
|
||||
assertEquals(1, factory.sockets.size)
|
||||
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
assertEquals(2, factory.sockets.size)
|
||||
|
||||
factory.sockets.last().fail()
|
||||
advanceTimeBy(1_999)
|
||||
assertEquals(2, factory.sockets.size)
|
||||
|
||||
advanceTimeBy(1)
|
||||
runCurrent()
|
||||
assertEquals(3, factory.sockets.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pausesReconnectWhileOfflineAndReconnectsImmediatelyWhenNetworkReturns() = runTest {
|
||||
val factory = FakeWebSocketFactory()
|
||||
val client = liveClient(factory = factory, scope = backgroundScope)
|
||||
|
||||
client.start("driver-1", config)
|
||||
client.onNetworkAvailable(false)
|
||||
factory.sockets.single().fail()
|
||||
|
||||
advanceTimeBy(10_000)
|
||||
assertEquals(1, factory.sockets.size)
|
||||
assertTrue(factory.sockets.single().closed)
|
||||
|
||||
client.onNetworkAvailable(true)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(2, factory.sockets.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foregroundEnsureConnectedRestartsWhenSocketIsDisconnected() = runTest {
|
||||
val factory = FakeWebSocketFactory()
|
||||
val client = liveClient(factory = factory, scope = backgroundScope)
|
||||
|
||||
client.start("driver-1", config)
|
||||
factory.sockets.single().fail()
|
||||
client.ensureConnected()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(2, factory.sockets.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retriesWhenPrivateChannelSubscriptionAuthFails() = runTest {
|
||||
val gateway = FakeLiveSyncGateway(authFailure = RuntimeException("offline"))
|
||||
val factory = FakeWebSocketFactory()
|
||||
val client = liveClient(gateway = gateway, factory = factory, scope = backgroundScope)
|
||||
|
||||
client.start("driver-1", config)
|
||||
factory.sockets.single().message(connectionEstablished("socket-1"))
|
||||
advanceUntilIdle()
|
||||
advanceTimeBy(1_000)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(2, factory.sockets.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun respondsToPusherPingWithPong() = runTest {
|
||||
val factory = FakeWebSocketFactory()
|
||||
val client = liveClient(factory = factory, scope = backgroundScope)
|
||||
|
||||
client.start("driver-1", config)
|
||||
factory.sockets.single().message("""{"event":"pusher:ping"}""")
|
||||
|
||||
assertTrue(factory.sockets.single().sent.any { it.contains("pusher:pong") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun closesAndReconnectsWhenConnectedSocketStopsReceivingMessages() = runTest {
|
||||
val factory = FakeWebSocketFactory()
|
||||
val client = liveClient(factory = factory, scope = backgroundScope)
|
||||
|
||||
client.start("driver-1", config)
|
||||
factory.sockets.single().message(connectionEstablished("socket-1"))
|
||||
advanceUntilIdle()
|
||||
factory.sockets.single().message("""{"event":"pusher_internal:subscription_succeeded","channel":"private-driver-mobile.driver-1"}""")
|
||||
|
||||
advanceTimeBy(30_000)
|
||||
runCurrent()
|
||||
|
||||
assertTrue(factory.sockets.first().closed)
|
||||
assertEquals(2, factory.sockets.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresCallbacksFromOldSocketAfterReconnect() = runTest {
|
||||
val factory = FakeWebSocketFactory()
|
||||
val client = liveClient(factory = factory, scope = backgroundScope)
|
||||
|
||||
client.start("driver-1", config)
|
||||
val oldSocket = factory.sockets.single()
|
||||
oldSocket.fail()
|
||||
client.ensureConnected()
|
||||
assertEquals(2, factory.sockets.size)
|
||||
|
||||
oldSocket.fail()
|
||||
client.ensureConnected()
|
||||
|
||||
assertEquals(2, factory.sockets.size)
|
||||
}
|
||||
|
||||
private fun liveClient(
|
||||
gateway: FakeLiveSyncGateway = FakeLiveSyncGateway(),
|
||||
factory: FakeWebSocketFactory,
|
||||
scope: CoroutineScope,
|
||||
): DriverLiveSyncClient =
|
||||
DriverLiveSyncClient(
|
||||
gateway = gateway,
|
||||
onConnected = {},
|
||||
onHint = {},
|
||||
webSocketFactory = factory,
|
||||
scope = scope,
|
||||
reconnectDelaysMs = listOf(1_000L, 2_000L),
|
||||
staleTimeoutMs = 30_000L,
|
||||
)
|
||||
|
||||
private fun connectionEstablished(socketId: String): String =
|
||||
"""{"event":"pusher:connection_established","data":"{\"socket_id\":\"$socketId\"}"}"""
|
||||
|
||||
private class FakeLiveSyncGateway(
|
||||
private val authFailure: Throwable? = null,
|
||||
) : DriverLiveSyncGateway {
|
||||
override suspend fun broadcastAuth(socketId: String, channelName: String): BroadcastAuthResponse {
|
||||
authFailure?.let { throw it }
|
||||
return BroadcastAuthResponse(auth = "auth-token")
|
||||
}
|
||||
|
||||
override suspend fun storeRealtimeStatus(status: String, socketId: String?, error: String?) = Unit
|
||||
}
|
||||
|
||||
private class FakeWebSocketFactory : LiveWebSocketFactory {
|
||||
val sockets = mutableListOf<FakeWebSocket>()
|
||||
|
||||
override fun newWebSocket(url: String, listener: WebSocketListener): WebSocket {
|
||||
val socket = FakeWebSocket(listener)
|
||||
sockets += socket
|
||||
return socket
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeWebSocket(
|
||||
private val listener: WebSocketListener,
|
||||
) : WebSocket {
|
||||
val sent = mutableListOf<String>()
|
||||
var closed = false
|
||||
|
||||
fun fail() {
|
||||
listener.onFailure(this, RuntimeException("boom"), null)
|
||||
}
|
||||
|
||||
fun message(text: String) {
|
||||
listener.onMessage(this, text)
|
||||
}
|
||||
|
||||
override fun request(): Request = Request.Builder().url("wss://example.test/app").build()
|
||||
|
||||
override fun queueSize(): Long = 0L
|
||||
|
||||
override fun send(text: String): Boolean {
|
||||
sent += text
|
||||
return true
|
||||
}
|
||||
|
||||
override fun send(bytes: ByteString): Boolean = true
|
||||
|
||||
override fun close(code: Int, reason: String?): Boolean {
|
||||
closed = true
|
||||
return true
|
||||
}
|
||||
|
||||
override fun cancel() {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,22 @@ class DriverUiRulesTest {
|
||||
assertFalse(shouldShowDispatchSheetReminderCard(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explainsOfflineStateWithoutCachedSync() {
|
||||
assertEquals(
|
||||
"Brak połączenia z serwerem. Sprawdź internet i spróbuj ponownie.",
|
||||
offlineStaleBannerMessage(isOnline = false, isStale = false, syncLabel = "brak zapisanej synchronizacji"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun explainsStaleCachedDataWhenOnline() {
|
||||
assertEquals(
|
||||
"Dane mogą być nieaktualne. Ostatnia synchronizacja: 12:30.",
|
||||
offlineStaleBannerMessage(isOnline = true, isStale = true, syncLabel = "12:30"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun labelsDispatchSheetActionByStatusAndLocalUploadQueue() {
|
||||
assertEquals("Zrób zdjęcie", dispatchSheetPrimaryActionLabel(dispatchReminder(status = "missing"), hasQueuedUpload = false))
|
||||
|
||||
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.ui
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import pl.firmatpp.kierowca.ui.theme.AppThemeMode
|
||||
|
||||
class DriverUiStateTest {
|
||||
@Test
|
||||
@@ -12,4 +13,11 @@ class DriverUiStateTest {
|
||||
assertEquals(DriverScreen.Initializing, state.screen)
|
||||
assertTrue(state.loading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun startsWithMaterial3ThemeMode() {
|
||||
val state = DriverUiState()
|
||||
|
||||
assertEquals(AppThemeMode.Material3, state.themeMode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package pl.firmatpp.kierowca.ui.theme
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AppThemeModeTest {
|
||||
@Test
|
||||
fun parsesStoredMaterial3Value() {
|
||||
assertEquals(AppThemeMode.Material3, AppThemeMode.fromStoredValue("material3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackToCurrentForUnknownStoredValue() {
|
||||
assertEquals(AppThemeMode.Material3, AppThemeMode.fromStoredValue("future-theme"))
|
||||
assertEquals(AppThemeMode.Material3, AppThemeMode.fromStoredValue(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun storesStablePreferenceValues() {
|
||||
assertEquals("current", AppThemeMode.Current.storedValue)
|
||||
assertEquals("material3", AppThemeMode.Material3.storedValue)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user