SyncOutcomeTest.kt

11.0 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.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        // Paired but switched off is not a problem and not health — unlit.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    /** Word for word the browser's `COPY` table — the two clients sit side by side. */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        // Android-only, so there is no browser line to match — see the enum.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        // A deletion that crossed the wire is a change, not "nothing to do".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}