SyncOutcomeTest.kt

11.9 KB · raw

1package com.peachpasswords.sync23import org.junit.Assert.assertEquals4import org.junit.Assert.assertNotEquals5import org.junit.Assert.assertNull6import org.junit.Assert.assertTrue7import org.junit.Test89/**10 * The indicator light is the whole point of this vocabulary, so the mapping is11 * swept exhaustively rather than sampled: a reason added later has to be given12 * a place here or the sweep fails.13 */14class 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    /**33     * The set itself, not just its self-consistency with the light: moving a34     * reason out of it would otherwise flip red to unlit with every other test35     * still green. "Paired, but nothing here can accept a sync" in particular36     * is a problem, not a quiet state.37     *38     * Network-class outcomes (relay/handshake/timeout/connection_lost) live in39     * [SyncOutcomeReason.isWarning] and resolve to the amber light, not red —40     * the background loop keeps trying, so a transient miss is a warning, not41     * a problem. VAULT_LOCKED joined them in v2.9.35 for the same reason: the42     * loop retries once the vault unlocks, so amber, not red.43     */44    @Test fun `the failing outcomes are exactly these`() {45        assertEquals(46            setOf(47                SyncOutcomeReason.NO_ELIGIBLE_HOST,48                SyncOutcomeReason.PRIVATE_RELAY_UNSET,49                // Android refused to start the runner: nothing synced, and the50                // person can do something about it.51                SyncOutcomeReason.FOREGROUND_START_BLOCKED,52                SyncOutcomeReason.VAULT_ID_MISMATCH,53                SyncOutcomeReason.PEER_PROTOCOL_OUTDATED,54                // §11: a device that owes a secure reset, or whose generation55                // cannot be read, has not synced and must be told so.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        // Paired but switched off is not a problem and not health — unlit.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    /** Word for word the browser's `COPY` table — the two clients sit side by side. */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        // Android-only, so there is no browser line to match — see the enum.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        // A deletion that crossed the wire is a change, not "nothing to do".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}