SyncOutcomeTest.kt

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