SyncOutcomeTest.kt

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