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.PEER_PROTOCOL_OUTDATED,54 55 56 SyncOutcomeReason.RESET_REQUIRED,57 SyncOutcomeReason.SYNC_GENERATION_UNKNOWN,58 SyncOutcomeReason.TRUST_DATA_CORRUPTED,59 SyncOutcomeReason.RUN_FAILED,60 ),61 SyncOutcomeReason.entries.filter { it.isFailure }.toSet(),62 )63 }6465 @Test fun `the warning outcomes are the network class plus a locked vault`() {66 assertEquals(67 setOf(68 SyncOutcomeReason.VAULT_LOCKED,69 SyncOutcomeReason.RELAY_UNREACHABLE,70 SyncOutcomeReason.HANDSHAKE_FAILED,71 SyncOutcomeReason.SYNC_TIMEOUT,72 SyncOutcomeReason.CONNECTION_LOST,73 ),74 SyncOutcomeReason.entries.filter { it.isWarning }.toSet(),75 )76 }7778 @Test fun `every failure is red and every warning is amber, and nothing else is either`() {79 for (reason in SyncOutcomeReason.entries) {80 val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 1)81 assertEquals(82 "$reason: isFailure and the red light must agree",83 reason.isFailure,84 indicator == SyncIndicator.ERROR,85 )86 assertEquals(87 "$reason: isWarning and the amber light must agree",88 reason.isWarning,89 indicator == SyncIndicator.WARN,90 )91 }92 }9394 @Test fun `a run in flight pulses whatever the last outcome was`() {95 for (reason in SyncOutcomeReason.entries + null) {96 assertEquals(97 SyncIndicator.BUSY,98 syncIndicator(SyncOutcomeSummary(reason = reason, syncing = true), pairedCount = 0),99 )100 }101 }102103 @Test fun `a successful pair with nothing attempted is green, and nothing paired is unlit`() {104 assertEquals(SyncIndicator.OK, syncIndicator(SyncOutcomeSummary(), pairedCount = 1))105 assertEquals(SyncIndicator.IDLE, syncIndicator(SyncOutcomeSummary(), pairedCount = 0))106 107 assertEquals(108 SyncIndicator.IDLE,109 syncIndicator(SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_DISABLED), pairedCount = 1),110 )111 }112113 @Test fun `a sync that could not run reads differently from one that found nothing`() {114 val ranAndFoundNothing = describeSyncOutcome(115 SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_NO_CHANGES),116 )117 val couldNotRun = describeSyncOutcome(118 SyncOutcomeSummary(reason = SyncOutcomeReason.NO_ELIGIBLE_HOST, detail = "Pixel [type=android]"),119 )120 assertEquals("Up to date — nothing to send or receive", ranAndFoundNothing)121 assertEquals(122 "Paired, but no device here can accept a sync (Pixel [type=android])",123 couldNotRun,124 )125 }126127 128 @Test fun `copy matches the browser`() {129 fun copy(reason: SyncOutcomeReason) = describeSyncOutcome(SyncOutcomeSummary(reason = reason))130 assertEquals("Not synced yet", describeSyncOutcome(SyncOutcomeSummary()))131 assertEquals("Synced", copy(SyncOutcomeReason.COMPLETED_CHANGES))132 assertEquals("Up to date — nothing to send or receive", copy(SyncOutcomeReason.COMPLETED_NO_CHANGES))133 assertEquals("No devices paired yet", copy(SyncOutcomeReason.NO_PAIRED_DEVICES))134 assertEquals("Paired, but no device here can accept a sync", copy(SyncOutcomeReason.NO_ELIGIBLE_HOST))135 assertEquals("Sync is switched off", copy(SyncOutcomeReason.SYNC_DISABLED))136 assertEquals("Vault was locked — unlock and sync again", copy(SyncOutcomeReason.VAULT_LOCKED))137 138 assertEquals(139 "Android blocked Peach from starting sync in the background — open Peach and sync again",140 copy(SyncOutcomeReason.FOREGROUND_START_BLOCKED),141 )142 assertEquals("Couldn't reach the relay", copy(SyncOutcomeReason.RELAY_UNREACHABLE))143 assertEquals("Reached the relay, but the device didn't answer", copy(SyncOutcomeReason.HANDSHAKE_FAILED))144 assertEquals("That device holds a different vault", copy(SyncOutcomeReason.VAULT_ID_MISMATCH))145 assertEquals(146 "Update Peach on both devices, then re-pair them",147 copy(SyncOutcomeReason.PEER_PROTOCOL_OUTDATED),148 )149 assertEquals("The device stopped responding mid-sync", copy(SyncOutcomeReason.SYNC_TIMEOUT))150 assertEquals("The connection dropped mid-sync — will retry", copy(SyncOutcomeReason.CONNECTION_LOST))151 assertEquals("Device trust data is damaged — re-pair your devices after updating", copy(SyncOutcomeReason.TRUST_DATA_CORRUPTED))152 assertEquals("Sync failed", copy(SyncOutcomeReason.RUN_FAILED))153 }154155 @Test fun `a completed sync says what moved`() {156 assertEquals(157 "Synced — 3 sent, 1 received",158 describeSyncOutcome(159 SyncOutcomeSummary(160 reason = SyncOutcomeReason.COMPLETED_CHANGES,161 entriesSent = 3,162 entriesReceived = 1,163 ),164 ),165 )166 }167168 @Test fun `the label always carries the colour's claim in words`() {169 assertEquals(170 "Sync problem — Couldn't reach the relay",171 syncIndicatorLabel(172 SyncIndicator.ERROR,173 SyncOutcomeSummary(reason = SyncOutcomeReason.RELAY_UNREACHABLE),174 ),175 )176 assertEquals("Not syncing — Not synced yet", syncIndicatorLabel(SyncIndicator.IDLE, SyncOutcomeSummary()))177 assertTrue(syncIndicatorLabel(SyncIndicator.BUSY, SyncOutcomeSummary()).startsWith("Syncing"))178 assertTrue(syncIndicatorLabel(SyncIndicator.OK, SyncOutcomeSummary()).startsWith("Sync healthy"))179 }180181 @Test fun `opaque session errors are classified, not lumped into one failure`() {182 assertEquals(SyncOutcomeReason.VAULT_LOCKED, classifySyncError("VAULT_LOCKED"))183 assertEquals(SyncOutcomeReason.VAULT_ID_MISMATCH, classifySyncError("vault_id_mismatch"))184 assertEquals(SyncOutcomeReason.PEER_PROTOCOL_OUTDATED, classifySyncError("PEER_PROTOCOL_OUTDATED"))185 assertEquals(SyncOutcomeReason.SYNC_TIMEOUT, classifySyncError("session timed out after 30s"))186 assertEquals(187 SyncOutcomeReason.HANDSHAKE_FAILED,188 classifySyncError("peer identity does not match authenticated KK key"),189 )190 assertEquals(191 SyncOutcomeReason.HANDSHAKE_FAILED,192 classifySyncError("reconnect capability does not match relay room"),193 )194 assertEquals(SyncOutcomeReason.RELAY_UNREACHABLE, classifySyncError("relay_closed_1006"))195 assertEquals(SyncOutcomeReason.CONNECTION_LOST, classifySyncError("connection closed by peer"))196 assertEquals(SyncOutcomeReason.TRUST_DATA_CORRUPTED, classifySyncError("trust_data_corrupted:paired_devices:decode_failed"))197 assertEquals(SyncOutcomeReason.RUN_FAILED, classifySyncError("something nobody predicted"))198 }199200 @Test fun `a session outcome becomes the user-facing one`() {201 assertEquals(202 SyncOutcomeReason.COMPLETED_NO_CHANGES,203 SyncOutcome(SyncOutcomeStatus.COMPLETED).toSummary().reason,204 )205 assertEquals(206 SyncOutcomeReason.COMPLETED_CHANGES,207 SyncOutcome(SyncOutcomeStatus.COMPLETED, entriesReceived = 2).toSummary().reason,208 )209 210 assertEquals(211 SyncOutcomeReason.COMPLETED_CHANGES,212 SyncOutcome(SyncOutcomeStatus.COMPLETED, tombstonesSent = 1).toSummary().reason,213 )214 assertEquals(215 SyncOutcomeReason.VAULT_ID_MISMATCH,216 SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH).toSummary().reason,217 )218 assertEquals(219 SyncOutcomeReason.SYNC_TIMEOUT,220 SyncOutcome(SyncOutcomeStatus.TIMEOUT, error = "session timed out after 30s").toSummary().reason,221 )222 assertEquals(223 SyncOutcomeReason.RELAY_UNREACHABLE,224 SyncOutcome(SyncOutcomeStatus.ERRORED, error = "relay_receive_queue_full").toSummary().reason,225 )226 assertEquals(227 SyncOutcomeReason.PEER_PROTOCOL_OUTDATED,228 SyncOutcome(SyncOutcomeStatus.ERRORED, error = "PEER_PROTOCOL_OUTDATED").toSummary().reason,229 )230 }231232 @Test fun `the reason for a failure survives being killed`() {233 val failure = SyncOutcomeSummary(234 reason = SyncOutcomeReason.HANDSHAKE_FAILED,235 detail = "peer identity does not match authenticated KK key",236 entriesSent = 4,237 entriesReceived = 2,238 finishedAt = 1_700_000_000_000L,239 )240 assertEquals(failure, decodeSyncOutcome(encodeSyncOutcome(failure)))241 }242243 @Test fun `a process killed mid-sync does not come back claiming to be syncing`() {244 val inFlight = SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_CHANGES, syncing = true)245 val restored = decodeSyncOutcome(encodeSyncOutcome(inFlight))246 assertEquals(false, restored.syncing)247 assertEquals(SyncOutcomeReason.COMPLETED_CHANGES, restored.reason)248 }249250 @Test fun `nothing stored, or something unreadable, restores as nothing attempted`() {251 assertNull(decodeSyncOutcome(null).reason)252 assertNull(decodeSyncOutcome("{not json").reason)253 assertNull(decodeSyncOutcome("""{"reason":"A_REASON_FROM_THE_FUTURE"}""").reason)254 }255}