feat: harden offline sync and add diagnostics

This commit is contained in:
admin
2026-07-10 23:58:07 +02:00
parent 0564101e9e
commit 89235fbb46
34 changed files with 1828 additions and 197 deletions
@@ -0,0 +1,17 @@
package pl.firmatpp.kierowca.data.sync
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class NetworkMonitorTest {
@Test
fun captivePortalIsNotTreatedAsOnline() {
assertFalse(hasValidatedInternetCapabilities(hasInternet = true, hasValidated = false))
}
@Test
fun validatedInternetIsOnline() {
assertTrue(hasValidatedInternetCapabilities(hasInternet = true, hasValidated = true))
}
}
@@ -0,0 +1,39 @@
package pl.firmatpp.kierowca.diagnostics
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DiagnosticSnapshotProviderTest {
@Test
fun secureIdentifierSummaryDoesNotExposeWholeToken() {
val token = "secret-session-token-123456"
val summary = secureIdentifierSummary(token)
assertFalse(summary.contains(token))
assertTrue(summary.contains("długość ${token.length}"))
assertTrue(summary.contains("końcówka …3456"))
assertTrue(summary.contains("SHA-256"))
}
@Test
fun copiedReportExplainsThatCredentialsAreRedacted() {
val snapshot = DiagnosticSnapshot(
generatedAtEpochMillis = 1_700_000_000_000,
sections = listOf(
DiagnosticSection(
title = "Sesja",
entries = listOf(DiagnosticEntry("Token", secureIdentifierSummary("top-secret"))),
),
),
)
val report = snapshot.asPlainText()
assertTrue(report.contains("[Sesja]"))
assertTrue(report.contains("Token: obecny"))
assertTrue(report.contains("nie zawiera danych pozwalających przejąć sesję"))
assertFalse(report.contains("top-secret"))
}
}
@@ -81,6 +81,23 @@ class DriverLiveSyncClientTest {
assertEquals(2, factory.sockets.size)
}
@Test
fun backgroundClosesSocketAndForegroundReconnects() = runTest {
val factory = FakeWebSocketFactory()
val client = liveClient(factory = factory, scope = backgroundScope)
client.start("driver-1", config)
client.setForeground(false)
assertTrue(factory.sockets.single().closed)
assertEquals(LiveSyncConnectionState.Stopped, client.connectionState.value)
client.setForeground(true)
advanceUntilIdle()
assertEquals(2, factory.sockets.size)
}
@Test
fun retriesWhenPrivateChannelSubscriptionAuthFails() = runTest {
val gateway = FakeLiveSyncGateway(authFailure = RuntimeException("offline"))
@@ -0,0 +1,24 @@
package pl.firmatpp.kierowca.sync
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DriverRuntimeSyncStateTest {
@After
fun reset() {
DriverRuntimeSyncState.foreground = false
DriverRuntimeSyncState.realtimeConnected = false
}
@Test
fun websocketSuppressesDuplicateFcmSyncOnlyWhenForegroundAndConnected() {
DriverRuntimeSyncState.foreground = true
DriverRuntimeSyncState.realtimeConnected = true
assertTrue(DriverRuntimeSyncState.websocketOwnsForegroundHints())
DriverRuntimeSyncState.realtimeConnected = false
assertFalse(DriverRuntimeSyncState.websocketOwnsForegroundHints())
}
}
@@ -261,6 +261,32 @@ class DriverUiRulesTest {
)
}
@Test
fun healthyOnlineStateDoesNotShowOfflineBanner() {
assertEquals(
null,
connectionStatusBannerMessage(
isOnline = true,
isStale = false,
serverConnectionState = ServerConnectionState.Reachable,
showRealtimeReconnecting = false,
syncLabel = "12:30",
),
)
}
@Test
fun serverFailureAndRealtimeReconnectAreNotCalledOffline() {
assertEquals(
"Internet działa, ale serwer jest chwilowo niedostępny. Pokazujemy dane z 12:30.",
connectionStatusBannerMessage(true, true, ServerConnectionState.Degraded, false, "12:30"),
)
assertEquals(
"Ponowne łączenie. Dane są uzgadniane z serwerem.",
connectionStatusBannerMessage(true, false, ServerConnectionState.Reachable, true, "12:30"),
)
}
@Test
fun explainsStaleCachedDataWhenOnline() {
assertEquals(
@@ -321,7 +347,7 @@ class DriverUiRulesTest {
val steps = routeFlowSteps(route, emptyList())
val action = routeLifecyclePrimaryAction(route, "2026-06-30", today)
assertEquals(listOf("Zdjęcia załadunku", "Waga załadunku", "W trasie", "Rozładunek"), steps.map { it.title })
assertEquals(listOf("Zdjęcia załadunku", "Tonaż załadunku", "W trasie", "Rozładunek"), steps.map { it.title })
assertEquals(RouteFlowStepState.Todo, steps[0].state)
assertEquals(RouteFlowStepState.Todo, steps[1].state)
assertEquals(RouteFlowStepState.Todo, steps[2].state)
@@ -393,12 +419,22 @@ class DriverUiRulesTest {
assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state)
assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text)
assertTrue(callout?.isConflict ?: false)
assertFalse(callout?.canCorrect ?: true)
}
@Test
fun permanentlyRejectedRouteActionOffersDataCorrection() {
val callout = routeSyncCallout(
listOf(action(RouteActionType.Start, RouteActionStatus.FailedPermanent, "Brakuje wymaganych danych.")),
)
assertTrue(callout?.canCorrect ?: false)
}
@Test
fun routeStageSubmitBlockerExplainsMissingRequirements() {
assertEquals("Podaj wagę.", routeStageSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Waga musi być większa od zera.", routeStageSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Podaj tonaż.", routeStageSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Tonaż musi być większy od zera.", routeStageSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Dodaj co najmniej jedno zdjęcie etapu.", routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 0))
assertEquals(null, routeStageSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1))
}
@@ -415,9 +451,9 @@ class DriverUiRulesTest {
@Test
fun loadingWeightStepRequiresPositiveWeightAndPreviousLoadingPhoto() {
assertEquals("Podaj wagę.", loadingWeightSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Podaj poprawną wagę.", loadingWeightSubmitBlocker("abc", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Waga musi być większa od zera.", loadingWeightSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Podaj tonaż.", loadingWeightSubmitBlocker("", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Podaj poprawny tonaż.", loadingWeightSubmitBlocker("abc", serverPhotoCount = 1, localUploadCount = 0))
assertEquals("Tonaż musi być większy od zera.", loadingWeightSubmitBlocker("0", serverPhotoCount = 1, localUploadCount = 0))
assertEquals(
"Dodaj co najmniej jedno zdjęcie załadunku.",
loadingWeightSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 0),
@@ -425,6 +461,61 @@ class DriverUiRulesTest {
assertEquals(null, loadingWeightSubmitBlocker("12,5", serverPhotoCount = 0, localUploadCount = 1))
}
@Test
fun optionalStageInputsAreVisibleButDoNotBlockConfirmation() {
assertEquals(
null,
routeStageSubmitBlocker(
weightText = "",
serverPhotoCount = 0,
localUploadCount = 0,
weightRequirement = RouteStageRequirement.Optional,
photoRequirement = RouteStageRequirement.Optional,
),
)
assertEquals(
"Podaj poprawny tonaż.",
routeStageSubmitBlocker(
weightText = "abc",
serverPhotoCount = 0,
localUploadCount = 0,
weightRequirement = RouteStageRequirement.Optional,
photoRequirement = RouteStageRequirement.Optional,
),
)
}
@Test
fun disabledStageInputsAreHiddenAndDoNotBlockConfirmation() {
assertEquals(RouteStageRequirement.Required, normalizeRouteStageRequirement(null))
assertEquals(RouteStageRequirement.Required, normalizeRouteStageRequirement("unknown"))
assertFalse(routeStageRequirementIsVisible(RouteStageRequirement.Disabled))
assertTrue(routeStageRequirementIsVisible(RouteStageRequirement.Optional))
assertTrue(routeStageRequirementIsRequired(RouteStageRequirement.Required))
assertEquals(
null,
routeStageSubmitBlocker(
weightText = "invalid value is ignored",
serverPhotoCount = 0,
localUploadCount = 0,
weightRequirement = RouteStageRequirement.Disabled,
photoRequirement = RouteStageRequirement.Disabled,
),
)
}
@Test
fun disabledLoadingStepsAreRemovedFromRouteFlow() {
val steps = routeFlowSteps(
route(status = "ZAPLANOWANA"),
emptyList(),
loadingPhotoRequirement = RouteStageRequirement.Disabled,
loadingWeightRequirement = RouteStageRequirement.Disabled,
)
assertEquals(listOf("W trasie", "Rozładunek"), steps.map { it.title })
}
private fun route(
status: String = "ZAPLANOWANA",
loadingWeight: Double? = null,
@@ -3,6 +3,7 @@ package pl.firmatpp.kierowca.ui
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
import pl.firmatpp.kierowca.data.model.DriverLeaveRequestDto
import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity
@@ -26,6 +27,21 @@ class DriverUiStateTest {
assertEquals(AppThemeMode.Material3, state.themeMode)
}
@Test
fun networkTimeoutDoesNotChangeValidatedNetworkState() {
val state = DriverUiState(
isOnline = true,
serverConnectionState = ServerConnectionState.Reachable,
lastServerSyncAtEpochMillis = 123L,
)
val failed = state.withApiError(IOException("timeout"))
assertTrue(failed.isOnline)
assertTrue(failed.isStale)
assertEquals(ServerConnectionState.Degraded, failed.serverConnectionState)
}
@Test
fun refreshingLeaveRequestsKeepsDetailScreenOpenAndUpdatesSelectedRequest() {
val state = DriverUiState(