1package com.peachpasswords.sync23import org.junit.Assert.assertEquals4import org.junit.Assert.assertNotEquals5import org.junit.Assert.assertNull6import org.junit.Assert.assertTrue7import org.junit.Test891011121314class SyncOutcomeTest {1516 private val succeeded = setOf(17 SyncOutcomeReason.COMPLETED_CHANGES,18 SyncOutcomeReason.COMPLETED_NO_CHANGES,19 )2021 @Test fun `only a real success is ever green, even with devices paired`() {22 for (reason in SyncOutcomeReason.entries) {23 val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 5)24 if (reason in succeeded) {25 assertEquals("$reason must be ok", SyncIndicator.OK, indicator)26 } else {27 assertNotEquals("$reason must never resolve to ok", SyncIndicator.OK, indicator)28 }29 }30 }3132 3334353637383940414243 @Test fun `the failing outcomes are exactly these`() {44 assertEquals(45 setOf(46 SyncOutcomeReason.NO_ELIGIBLE_HOST,47 SyncOutcomeReason.PRIVATE_RELAY_UNSET,48 SyncOutcomeReason.VAULT_LOCKED,49 50 51 SyncOutcomeReason.FOREGROUND_START_BLOCKED,52 SyncOutcomeReason.VAULT_ID_MISMATCH,53 SyncOutcomeReason.RUN_FAILED,54 ),55 SyncOutcomeReason.entries.filter { it.isFailure }.toSet(),56 )57 }5859 @Test fun `the warning outcomes are exactly the network class`() {60 assertEquals(61 setOf(62 SyncOutcomeReason.RELAY_UNREACHABLE,63 SyncOutcomeReason.HANDSHAKE_FAILED,64 SyncOutcomeReason.SYNC_TIMEOUT,65 SyncOutcomeReason.CONNECTION_LOST,66 ),67 SyncOutcomeReason.entries.filter { it.isWarning }.toSet(),68 )69 }7071 @Test fun `every failure is red and every warning is amber, and nothing else is either`() {72 for (reason in SyncOutcomeReason.entries) {73 val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 1)74 assertEquals(75 "$reason: isFailure and the red light must agree",76 reason.isFailure,77 indicator == SyncIndicator.ERROR,78 )79 assertEquals(80 "$reason: isWarning and the amber light must agree",81 reason.isWarning,82 indicator == SyncIndicator.WARN,83 )84 }85 }8687 @Test fun `a run in flight pulses whatever the last outcome was`() {88 for (reason in SyncOutcomeReason.entries + null) {89 assertEquals(90 SyncIndicator.BUSY,91 syncIndicator(SyncOutcomeSummary(reason = reason, syncing = true), pairedCount = 0),92 )93 }94 }9596 @Test fun `a successful pair with nothing attempted is green, and nothing paired is unlit`() {97 assertEquals(SyncIndicator.OK, syncIndicator(SyncOutcomeSummary(), pairedCount = 1))98 assertEquals(SyncIndicator.IDLE, syncIndicator(SyncOutcomeSummary(), pairedCount = 0))99 100 assertEquals(101 SyncIndicator.IDLE,102 syncIndicator(SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_DISABLED), pairedCount = 1),103 )104 }105106 @Test fun `a sync that could not run reads differently from one that found nothing`() {107 val ranAndFoundNothing = describeSyncOutcome(108 SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_NO_CHANGES),109 )110 val couldNotRun = describeSyncOutcome(111 SyncOutcomeSummary(reason = SyncOutcomeReason.NO_ELIGIBLE_HOST, detail = "Pixel [type=android]"),112 )113 assertEquals("Up to date — nothing to send or receive", ranAndFoundNothing)114 assertEquals(115 "Paired, but no device here can accept a sync (Pixel [type=android])",116 couldNotRun,117 )118 }119120 121 @Test fun `copy matches the browser`() {122 fun copy(reason: SyncOutcomeReason) = describeSyncOutcome(SyncOutcomeSummary(reason = reason))123 assertEquals("Not synced yet", describeSyncOutcome(SyncOutcomeSummary()))124 assertEquals("Synced", copy(SyncOutcomeReason.COMPLETED_CHANGES))125 assertEquals("Up to date — nothing to send or receive", copy(SyncOutcomeReason.COMPLETED_NO_CHANGES))126 assertEquals("No devices paired yet", copy(SyncOutcomeReason.NO_PAIRED_DEVICES))127 assertEquals("Paired, but no device here can accept a sync", copy(SyncOutcomeReason.NO_ELIGIBLE_HOST))128 assertEquals("Sync is switched off", copy(SyncOutcomeReason.SYNC_DISABLED))129 assertEquals("Vault was locked — unlock and sync again", copy(SyncOutcomeReason.VAULT_LOCKED))130 131 assertEquals(132 "Android blocked Peach from starting sync in the background — open Peach and sync again",133 copy(SyncOutcomeReason.FOREGROUND_START_BLOCKED),134 )135 assertEquals("Couldn't reach the relay", copy(SyncOutcomeReason.RELAY_UNREACHABLE))136 assertEquals("Reached the relay, but the device didn't answer", copy(SyncOutcomeReason.HANDSHAKE_FAILED))137 assertEquals("That device holds a different vault", copy(SyncOutcomeReason.VAULT_ID_MISMATCH))138 assertEquals("The device stopped responding mid-sync", copy(SyncOutcomeReason.SYNC_TIMEOUT))139 assertEquals("The connection dropped mid-sync — will retry", copy(SyncOutcomeReason.CONNECTION_LOST))140 assertEquals("Sync failed", copy(SyncOutcomeReason.RUN_FAILED))141 }142143 @Test fun `a completed sync says what moved`() {144 assertEquals(145 "Synced — 3 sent, 1 received",146 describeSyncOutcome(147 SyncOutcomeSummary(148 reason = SyncOutcomeReason.COMPLETED_CHANGES,149 entriesSent = 3,150 entriesReceived = 1,151 ),152 ),153 )154 }155156 @Test fun `the label always carries the colour's claim in words`() {157 assertEquals(158 "Sync problem — Couldn't reach the relay",159 syncIndicatorLabel(160 SyncIndicator.ERROR,161 SyncOutcomeSummary(reason = SyncOutcomeReason.RELAY_UNREACHABLE),162 ),163 )164 assertEquals("Not syncing — Not synced yet", syncIndicatorLabel(SyncIndicator.IDLE, SyncOutcomeSummary()))165 assertTrue(syncIndicatorLabel(SyncIndicator.BUSY, SyncOutcomeSummary()).startsWith("Syncing"))166 assertTrue(syncIndicatorLabel(SyncIndicator.OK, SyncOutcomeSummary()).startsWith("Sync healthy"))167 }168169 @Test fun `opaque session errors are classified, not lumped into one failure`() {170 assertEquals(SyncOutcomeReason.VAULT_LOCKED, classifySyncError("VAULT_LOCKED"))171 assertEquals(SyncOutcomeReason.VAULT_ID_MISMATCH, classifySyncError("vault_id_mismatch"))172 assertEquals(SyncOutcomeReason.SYNC_TIMEOUT, classifySyncError("session timed out after 30s"))173 assertEquals(174 SyncOutcomeReason.HANDSHAKE_FAILED,175 classifySyncError("peer identity does not match authenticated KK key"),176 )177 assertEquals(178 SyncOutcomeReason.HANDSHAKE_FAILED,179 classifySyncError("reconnect capability does not match relay room"),180 )181 assertEquals(SyncOutcomeReason.RELAY_UNREACHABLE, classifySyncError("relay_closed_1006"))182 assertEquals(SyncOutcomeReason.CONNECTION_LOST, classifySyncError("WebSocket closed"))183 assertEquals(SyncOutcomeReason.CONNECTION_LOST, classifySyncError("connection closed by peer"))184 assertEquals(SyncOutcomeReason.RUN_FAILED, classifySyncError("something nobody predicted"))185 }186187 @Test fun `a session outcome becomes the user-facing one`() {188 assertEquals(189 SyncOutcomeReason.COMPLETED_NO_CHANGES,190 SyncOutcome(SyncOutcomeStatus.COMPLETED).toSummary().reason,191 )192 assertEquals(193 SyncOutcomeReason.COMPLETED_CHANGES,194 SyncOutcome(SyncOutcomeStatus.COMPLETED, entriesReceived = 2).toSummary().reason,195 )196 197 assertEquals(198 SyncOutcomeReason.COMPLETED_CHANGES,199 SyncOutcome(SyncOutcomeStatus.COMPLETED, tombstonesSent = 1).toSummary().reason,200 )201 assertEquals(202 SyncOutcomeReason.VAULT_ID_MISMATCH,203 SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH).toSummary().reason,204 )205 assertEquals(206 SyncOutcomeReason.SYNC_TIMEOUT,207 SyncOutcome(SyncOutcomeStatus.TIMEOUT, error = "session timed out after 30s").toSummary().reason,208 )209 assertEquals(210 SyncOutcomeReason.RELAY_UNREACHABLE,211 SyncOutcome(SyncOutcomeStatus.ERRORED, error = "relay_receive_queue_full").toSummary().reason,212 )213 }214215 @Test fun `the reason for a failure survives being killed`() {216 val failure = SyncOutcomeSummary(217 reason = SyncOutcomeReason.HANDSHAKE_FAILED,218 detail = "peer identity does not match authenticated KK key",219 entriesSent = 4,220 entriesReceived = 2,221 finishedAt = 1_700_000_000_000L,222 )223 assertEquals(failure, decodeSyncOutcome(encodeSyncOutcome(failure)))224 }225226 @Test fun `a process killed mid-sync does not come back claiming to be syncing`() {227 val inFlight = SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_CHANGES, syncing = true)228 val restored = decodeSyncOutcome(encodeSyncOutcome(inFlight))229 assertEquals(false, restored.syncing)230 assertEquals(SyncOutcomeReason.COMPLETED_CHANGES, restored.reason)231 }232233 @Test fun `nothing stored, or something unreadable, restores as nothing attempted`() {234 assertNull(decodeSyncOutcome(null).reason)235 assertNull(decodeSyncOutcome("{not json").reason)236 assertNull(decodeSyncOutcome("""{"reason":"A_REASON_FROM_THE_FUTURE"}""").reason)237 }238}