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 333435363738 @Test fun `the failing outcomes are exactly these`() {39 assertEquals(40 setOf(41 SyncOutcomeReason.NO_ELIGIBLE_HOST,42 SyncOutcomeReason.PRIVATE_RELAY_UNSET,43 SyncOutcomeReason.VAULT_LOCKED,44 45 46 SyncOutcomeReason.FOREGROUND_START_BLOCKED,47 SyncOutcomeReason.RELAY_UNREACHABLE,48 SyncOutcomeReason.HANDSHAKE_FAILED,49 SyncOutcomeReason.VAULT_ID_MISMATCH,50 SyncOutcomeReason.SYNC_TIMEOUT,51 SyncOutcomeReason.RUN_FAILED,52 ),53 SyncOutcomeReason.entries.filter { it.isFailure }.toSet(),54 )55 }5657 @Test fun `every failure is red and nothing else is`() {58 for (reason in SyncOutcomeReason.entries) {59 val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 1)60 assertEquals(61 "$reason: isFailure and the red light must agree",62 reason.isFailure,63 indicator == SyncIndicator.ERROR,64 )65 }66 }6768 @Test fun `a run in flight pulses whatever the last outcome was`() {69 for (reason in SyncOutcomeReason.entries + null) {70 assertEquals(71 SyncIndicator.BUSY,72 syncIndicator(SyncOutcomeSummary(reason = reason, syncing = true), pairedCount = 0),73 )74 }75 }7677 @Test fun `a successful pair with nothing attempted is green, and nothing paired is unlit`() {78 assertEquals(SyncIndicator.OK, syncIndicator(SyncOutcomeSummary(), pairedCount = 1))79 assertEquals(SyncIndicator.IDLE, syncIndicator(SyncOutcomeSummary(), pairedCount = 0))80 81 assertEquals(82 SyncIndicator.IDLE,83 syncIndicator(SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_DISABLED), pairedCount = 1),84 )85 }8687 @Test fun `a sync that could not run reads differently from one that found nothing`() {88 val ranAndFoundNothing = describeSyncOutcome(89 SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_NO_CHANGES),90 )91 val couldNotRun = describeSyncOutcome(92 SyncOutcomeSummary(reason = SyncOutcomeReason.NO_ELIGIBLE_HOST, detail = "Pixel [type=android]"),93 )94 assertEquals("Up to date — nothing to send or receive", ranAndFoundNothing)95 assertEquals(96 "Paired, but no device here can accept a sync (Pixel [type=android])",97 couldNotRun,98 )99 }100101 102 @Test fun `copy matches the browser`() {103 fun copy(reason: SyncOutcomeReason) = describeSyncOutcome(SyncOutcomeSummary(reason = reason))104 assertEquals("Not synced yet", describeSyncOutcome(SyncOutcomeSummary()))105 assertEquals("Synced", copy(SyncOutcomeReason.COMPLETED_CHANGES))106 assertEquals("Up to date — nothing to send or receive", copy(SyncOutcomeReason.COMPLETED_NO_CHANGES))107 assertEquals("No devices paired yet", copy(SyncOutcomeReason.NO_PAIRED_DEVICES))108 assertEquals("Paired, but no device here can accept a sync", copy(SyncOutcomeReason.NO_ELIGIBLE_HOST))109 assertEquals("Sync is switched off", copy(SyncOutcomeReason.SYNC_DISABLED))110 assertEquals("Vault was locked — unlock and sync again", copy(SyncOutcomeReason.VAULT_LOCKED))111 112 assertEquals(113 "Android blocked Peach from starting sync in the background — open Peach and sync again",114 copy(SyncOutcomeReason.FOREGROUND_START_BLOCKED),115 )116 assertEquals("Couldn't reach the relay", copy(SyncOutcomeReason.RELAY_UNREACHABLE))117 assertEquals("Reached the relay, but the device didn't answer", copy(SyncOutcomeReason.HANDSHAKE_FAILED))118 assertEquals("That device holds a different vault", copy(SyncOutcomeReason.VAULT_ID_MISMATCH))119 assertEquals("The device stopped responding mid-sync", copy(SyncOutcomeReason.SYNC_TIMEOUT))120 assertEquals("Sync failed", copy(SyncOutcomeReason.RUN_FAILED))121 }122123 @Test fun `a completed sync says what moved`() {124 assertEquals(125 "Synced — 3 sent, 1 received",126 describeSyncOutcome(127 SyncOutcomeSummary(128 reason = SyncOutcomeReason.COMPLETED_CHANGES,129 entriesSent = 3,130 entriesReceived = 1,131 ),132 ),133 )134 }135136 @Test fun `the label always carries the colour's claim in words`() {137 assertEquals(138 "Sync problem — Couldn't reach the relay",139 syncIndicatorLabel(140 SyncIndicator.ERROR,141 SyncOutcomeSummary(reason = SyncOutcomeReason.RELAY_UNREACHABLE),142 ),143 )144 assertEquals("Not syncing — Not synced yet", syncIndicatorLabel(SyncIndicator.IDLE, SyncOutcomeSummary()))145 assertTrue(syncIndicatorLabel(SyncIndicator.BUSY, SyncOutcomeSummary()).startsWith("Syncing"))146 assertTrue(syncIndicatorLabel(SyncIndicator.OK, SyncOutcomeSummary()).startsWith("Sync healthy"))147 }148149 @Test fun `opaque session errors are classified, not lumped into one failure`() {150 assertEquals(SyncOutcomeReason.VAULT_LOCKED, classifySyncError("VAULT_LOCKED"))151 assertEquals(SyncOutcomeReason.VAULT_ID_MISMATCH, classifySyncError("vault_id_mismatch"))152 assertEquals(SyncOutcomeReason.SYNC_TIMEOUT, classifySyncError("session timed out after 30s"))153 assertEquals(154 SyncOutcomeReason.HANDSHAKE_FAILED,155 classifySyncError("peer identity does not match authenticated KK key"),156 )157 assertEquals(158 SyncOutcomeReason.HANDSHAKE_FAILED,159 classifySyncError("reconnect capability does not match relay room"),160 )161 assertEquals(SyncOutcomeReason.RELAY_UNREACHABLE, classifySyncError("relay_closed_1006"))162 assertEquals(SyncOutcomeReason.RUN_FAILED, classifySyncError("something nobody predicted"))163 }164165 @Test fun `a session outcome becomes the user-facing one`() {166 assertEquals(167 SyncOutcomeReason.COMPLETED_NO_CHANGES,168 SyncOutcome(SyncOutcomeStatus.COMPLETED).toSummary().reason,169 )170 assertEquals(171 SyncOutcomeReason.COMPLETED_CHANGES,172 SyncOutcome(SyncOutcomeStatus.COMPLETED, entriesReceived = 2).toSummary().reason,173 )174 175 assertEquals(176 SyncOutcomeReason.COMPLETED_CHANGES,177 SyncOutcome(SyncOutcomeStatus.COMPLETED, tombstonesSent = 1).toSummary().reason,178 )179 assertEquals(180 SyncOutcomeReason.VAULT_ID_MISMATCH,181 SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH).toSummary().reason,182 )183 assertEquals(184 SyncOutcomeReason.SYNC_TIMEOUT,185 SyncOutcome(SyncOutcomeStatus.TIMEOUT, error = "session timed out after 30s").toSummary().reason,186 )187 assertEquals(188 SyncOutcomeReason.RELAY_UNREACHABLE,189 SyncOutcome(SyncOutcomeStatus.ERRORED, error = "relay_receive_queue_full").toSummary().reason,190 )191 }192193 @Test fun `the reason for a failure survives being killed`() {194 val failure = SyncOutcomeSummary(195 reason = SyncOutcomeReason.HANDSHAKE_FAILED,196 detail = "peer identity does not match authenticated KK key",197 entriesSent = 4,198 entriesReceived = 2,199 finishedAt = 1_700_000_000_000L,200 )201 assertEquals(failure, decodeSyncOutcome(encodeSyncOutcome(failure)))202 }203204 @Test fun `a process killed mid-sync does not come back claiming to be syncing`() {205 val inFlight = SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_CHANGES, syncing = true)206 val restored = decodeSyncOutcome(encodeSyncOutcome(inFlight))207 assertEquals(false, restored.syncing)208 assertEquals(SyncOutcomeReason.COMPLETED_CHANGES, restored.reason)209 }210211 @Test fun `nothing stored, or something unreadable, restores as nothing attempted`() {212 assertNull(decodeSyncOutcome(null).reason)213 assertNull(decodeSyncOutcome("{not json").reason)214 assertNull(decodeSyncOutcome("""{"reason":"A_REASON_FROM_THE_FUTURE"}""").reason)215 }216}