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 333435363738394041424344 @Test fun `the failing outcomes are exactly these`() {45 assertEquals(46 setOf(47 SyncOutcomeReason.NO_ELIGIBLE_HOST,48 SyncOutcomeReason.PRIVATE_RELAY_UNSET,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 the network class plus a locked vault`() {60 assertEquals(61 setOf(62 SyncOutcomeReason.VAULT_LOCKED,63 SyncOutcomeReason.RELAY_UNREACHABLE,64 SyncOutcomeReason.HANDSHAKE_FAILED,65 SyncOutcomeReason.SYNC_TIMEOUT,66 SyncOutcomeReason.CONNECTION_LOST,67 ),68 SyncOutcomeReason.entries.filter { it.isWarning }.toSet(),69 )70 }7172 @Test fun `every failure is red and every warning is amber, and nothing else is either`() {73 for (reason in SyncOutcomeReason.entries) {74 val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 1)75 assertEquals(76 "$reason: isFailure and the red light must agree",77 reason.isFailure,78 indicator == SyncIndicator.ERROR,79 )80 assertEquals(81 "$reason: isWarning and the amber light must agree",82 reason.isWarning,83 indicator == SyncIndicator.WARN,84 )85 }86 }8788 @Test fun `a run in flight pulses whatever the last outcome was`() {89 for (reason in SyncOutcomeReason.entries + null) {90 assertEquals(91 SyncIndicator.BUSY,92 syncIndicator(SyncOutcomeSummary(reason = reason, syncing = true), pairedCount = 0),93 )94 }95 }9697 @Test fun `a successful pair with nothing attempted is green, and nothing paired is unlit`() {98 assertEquals(SyncIndicator.OK, syncIndicator(SyncOutcomeSummary(), pairedCount = 1))99 assertEquals(SyncIndicator.IDLE, syncIndicator(SyncOutcomeSummary(), pairedCount = 0))100 101 assertEquals(102 SyncIndicator.IDLE,103 syncIndicator(SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_DISABLED), pairedCount = 1),104 )105 }106107 @Test fun `a sync that could not run reads differently from one that found nothing`() {108 val ranAndFoundNothing = describeSyncOutcome(109 SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_NO_CHANGES),110 )111 val couldNotRun = describeSyncOutcome(112 SyncOutcomeSummary(reason = SyncOutcomeReason.NO_ELIGIBLE_HOST, detail = "Pixel [type=android]"),113 )114 assertEquals("Up to date — nothing to send or receive", ranAndFoundNothing)115 assertEquals(116 "Paired, but no device here can accept a sync (Pixel [type=android])",117 couldNotRun,118 )119 }120121 122 @Test fun `copy matches the browser`() {123 fun copy(reason: SyncOutcomeReason) = describeSyncOutcome(SyncOutcomeSummary(reason = reason))124 assertEquals("Not synced yet", describeSyncOutcome(SyncOutcomeSummary()))125 assertEquals("Synced", copy(SyncOutcomeReason.COMPLETED_CHANGES))126 assertEquals("Up to date — nothing to send or receive", copy(SyncOutcomeReason.COMPLETED_NO_CHANGES))127 assertEquals("No devices paired yet", copy(SyncOutcomeReason.NO_PAIRED_DEVICES))128 assertEquals("Paired, but no device here can accept a sync", copy(SyncOutcomeReason.NO_ELIGIBLE_HOST))129 assertEquals("Sync is switched off", copy(SyncOutcomeReason.SYNC_DISABLED))130 assertEquals("Vault was locked — unlock and sync again", copy(SyncOutcomeReason.VAULT_LOCKED))131 132 assertEquals(133 "Android blocked Peach from starting sync in the background — open Peach and sync again",134 copy(SyncOutcomeReason.FOREGROUND_START_BLOCKED),135 )136 assertEquals("Couldn't reach the relay", copy(SyncOutcomeReason.RELAY_UNREACHABLE))137 assertEquals("Reached the relay, but the device didn't answer", copy(SyncOutcomeReason.HANDSHAKE_FAILED))138 assertEquals("That device holds a different vault", copy(SyncOutcomeReason.VAULT_ID_MISMATCH))139 assertEquals("The device stopped responding mid-sync", copy(SyncOutcomeReason.SYNC_TIMEOUT))140 assertEquals("The connection dropped mid-sync — will retry", copy(SyncOutcomeReason.CONNECTION_LOST))141 assertEquals("Sync failed", copy(SyncOutcomeReason.RUN_FAILED))142 }143144 @Test fun `a completed sync says what moved`() {145 assertEquals(146 "Synced — 3 sent, 1 received",147 describeSyncOutcome(148 SyncOutcomeSummary(149 reason = SyncOutcomeReason.COMPLETED_CHANGES,150 entriesSent = 3,151 entriesReceived = 1,152 ),153 ),154 )155 }156157 @Test fun `the label always carries the colour's claim in words`() {158 assertEquals(159 "Sync problem — Couldn't reach the relay",160 syncIndicatorLabel(161 SyncIndicator.ERROR,162 SyncOutcomeSummary(reason = SyncOutcomeReason.RELAY_UNREACHABLE),163 ),164 )165 assertEquals("Not syncing — Not synced yet", syncIndicatorLabel(SyncIndicator.IDLE, SyncOutcomeSummary()))166 assertTrue(syncIndicatorLabel(SyncIndicator.BUSY, SyncOutcomeSummary()).startsWith("Syncing"))167 assertTrue(syncIndicatorLabel(SyncIndicator.OK, SyncOutcomeSummary()).startsWith("Sync healthy"))168 }169170 @Test fun `opaque session errors are classified, not lumped into one failure`() {171 assertEquals(SyncOutcomeReason.VAULT_LOCKED, classifySyncError("VAULT_LOCKED"))172 assertEquals(SyncOutcomeReason.VAULT_ID_MISMATCH, classifySyncError("vault_id_mismatch"))173 assertEquals(SyncOutcomeReason.SYNC_TIMEOUT, classifySyncError("session timed out after 30s"))174 assertEquals(175 SyncOutcomeReason.HANDSHAKE_FAILED,176 classifySyncError("peer identity does not match authenticated KK key"),177 )178 assertEquals(179 SyncOutcomeReason.HANDSHAKE_FAILED,180 classifySyncError("reconnect capability does not match relay room"),181 )182 assertEquals(SyncOutcomeReason.RELAY_UNREACHABLE, classifySyncError("relay_closed_1006"))183 assertEquals(SyncOutcomeReason.CONNECTION_LOST, classifySyncError("WebSocket closed"))184 assertEquals(SyncOutcomeReason.CONNECTION_LOST, classifySyncError("connection closed by peer"))185 assertEquals(SyncOutcomeReason.RUN_FAILED, classifySyncError("something nobody predicted"))186 }187188 @Test fun `a session outcome becomes the user-facing one`() {189 assertEquals(190 SyncOutcomeReason.COMPLETED_NO_CHANGES,191 SyncOutcome(SyncOutcomeStatus.COMPLETED).toSummary().reason,192 )193 assertEquals(194 SyncOutcomeReason.COMPLETED_CHANGES,195 SyncOutcome(SyncOutcomeStatus.COMPLETED, entriesReceived = 2).toSummary().reason,196 )197 198 assertEquals(199 SyncOutcomeReason.COMPLETED_CHANGES,200 SyncOutcome(SyncOutcomeStatus.COMPLETED, tombstonesSent = 1).toSummary().reason,201 )202 assertEquals(203 SyncOutcomeReason.VAULT_ID_MISMATCH,204 SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH).toSummary().reason,205 )206 assertEquals(207 SyncOutcomeReason.SYNC_TIMEOUT,208 SyncOutcome(SyncOutcomeStatus.TIMEOUT, error = "session timed out after 30s").toSummary().reason,209 )210 assertEquals(211 SyncOutcomeReason.RELAY_UNREACHABLE,212 SyncOutcome(SyncOutcomeStatus.ERRORED, error = "relay_receive_queue_full").toSummary().reason,213 )214 }215216 @Test fun `the reason for a failure survives being killed`() {217 val failure = SyncOutcomeSummary(218 reason = SyncOutcomeReason.HANDSHAKE_FAILED,219 detail = "peer identity does not match authenticated KK key",220 entriesSent = 4,221 entriesReceived = 2,222 finishedAt = 1_700_000_000_000L,223 )224 assertEquals(failure, decodeSyncOutcome(encodeSyncOutcome(failure)))225 }226227 @Test fun `a process killed mid-sync does not come back claiming to be syncing`() {228 val inFlight = SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_CHANGES, syncing = true)229 val restored = decodeSyncOutcome(encodeSyncOutcome(inFlight))230 assertEquals(false, restored.syncing)231 assertEquals(SyncOutcomeReason.COMPLETED_CHANGES, restored.reason)232 }233234 @Test fun `nothing stored, or something unreadable, restores as nothing attempted`() {235 assertNull(decodeSyncOutcome(null).reason)236 assertNull(decodeSyncOutcome("{not json").reason)237 assertNull(decodeSyncOutcome("""{"reason":"A_REASON_FROM_THE_FUTURE"}""").reason)238 }239}