package com.peachpasswords.sync import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * The indicator light is the whole point of this vocabulary, so the mapping is * swept exhaustively rather than sampled: a reason added later has to be given * a place here or the sweep fails. */ class SyncOutcomeTest { private val succeeded = setOf( SyncOutcomeReason.COMPLETED_CHANGES, SyncOutcomeReason.COMPLETED_NO_CHANGES, ) @Test fun `only a real success is ever green, even with devices paired`() { for (reason in SyncOutcomeReason.entries) { val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 5) if (reason in succeeded) { assertEquals("$reason must be ok", SyncIndicator.OK, indicator) } else { assertNotEquals("$reason must never resolve to ok", SyncIndicator.OK, indicator) } } } /** * The set itself, not just its self-consistency with the light: moving a * reason out of it would otherwise flip red to unlit with every other test * still green. "Paired, but nothing here can accept a sync" in particular * is a problem, not a quiet state. */ @Test fun `the failing outcomes are exactly these`() { assertEquals( setOf( SyncOutcomeReason.NO_ELIGIBLE_HOST, SyncOutcomeReason.VAULT_LOCKED, SyncOutcomeReason.RELAY_UNREACHABLE, SyncOutcomeReason.HANDSHAKE_FAILED, SyncOutcomeReason.VAULT_ID_MISMATCH, SyncOutcomeReason.SYNC_TIMEOUT, SyncOutcomeReason.RUN_FAILED, ), SyncOutcomeReason.entries.filter { it.isFailure }.toSet(), ) } @Test fun `every failure is red and nothing else is`() { for (reason in SyncOutcomeReason.entries) { val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 1) assertEquals( "$reason: isFailure and the red light must agree", reason.isFailure, indicator == SyncIndicator.ERROR, ) } } @Test fun `a run in flight pulses whatever the last outcome was`() { for (reason in SyncOutcomeReason.entries + null) { assertEquals( SyncIndicator.BUSY, syncIndicator(SyncOutcomeSummary(reason = reason, syncing = true), pairedCount = 0), ) } } @Test fun `a successful pair with nothing attempted is green, and nothing paired is unlit`() { assertEquals(SyncIndicator.OK, syncIndicator(SyncOutcomeSummary(), pairedCount = 1)) assertEquals(SyncIndicator.IDLE, syncIndicator(SyncOutcomeSummary(), pairedCount = 0)) // Paired but switched off is not a problem and not health — unlit. assertEquals( SyncIndicator.IDLE, syncIndicator(SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_DISABLED), pairedCount = 1), ) } @Test fun `a sync that could not run reads differently from one that found nothing`() { val ranAndFoundNothing = describeSyncOutcome( SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_NO_CHANGES), ) val couldNotRun = describeSyncOutcome( SyncOutcomeSummary(reason = SyncOutcomeReason.NO_ELIGIBLE_HOST, detail = "Pixel [type=android]"), ) assertEquals("Up to date — nothing to send or receive", ranAndFoundNothing) assertEquals( "Paired, but no device here can accept a sync (Pixel [type=android])", couldNotRun, ) } /** Word for word the browser's `COPY` table — the two clients sit side by side. */ @Test fun `copy matches the browser`() { fun copy(reason: SyncOutcomeReason) = describeSyncOutcome(SyncOutcomeSummary(reason = reason)) assertEquals("Not synced yet", describeSyncOutcome(SyncOutcomeSummary())) assertEquals("Synced", copy(SyncOutcomeReason.COMPLETED_CHANGES)) assertEquals("Up to date — nothing to send or receive", copy(SyncOutcomeReason.COMPLETED_NO_CHANGES)) assertEquals("No devices paired yet", copy(SyncOutcomeReason.NO_PAIRED_DEVICES)) assertEquals("Paired, but no device here can accept a sync", copy(SyncOutcomeReason.NO_ELIGIBLE_HOST)) assertEquals("Sync is switched off", copy(SyncOutcomeReason.SYNC_DISABLED)) assertEquals("Vault was locked — unlock and sync again", copy(SyncOutcomeReason.VAULT_LOCKED)) assertEquals("Couldn't reach the relay", copy(SyncOutcomeReason.RELAY_UNREACHABLE)) assertEquals("Reached the relay, but the device didn't answer", copy(SyncOutcomeReason.HANDSHAKE_FAILED)) assertEquals("That device holds a different vault", copy(SyncOutcomeReason.VAULT_ID_MISMATCH)) assertEquals("The device stopped responding mid-sync", copy(SyncOutcomeReason.SYNC_TIMEOUT)) assertEquals("Sync failed", copy(SyncOutcomeReason.RUN_FAILED)) } @Test fun `a completed sync says what moved`() { assertEquals( "Synced — 3 sent, 1 received", describeSyncOutcome( SyncOutcomeSummary( reason = SyncOutcomeReason.COMPLETED_CHANGES, entriesSent = 3, entriesReceived = 1, ), ), ) } @Test fun `the label always carries the colour's claim in words`() { assertEquals( "Sync problem — Couldn't reach the relay", syncIndicatorLabel( SyncIndicator.ERROR, SyncOutcomeSummary(reason = SyncOutcomeReason.RELAY_UNREACHABLE), ), ) assertEquals("Not syncing — Not synced yet", syncIndicatorLabel(SyncIndicator.IDLE, SyncOutcomeSummary())) assertTrue(syncIndicatorLabel(SyncIndicator.BUSY, SyncOutcomeSummary()).startsWith("Syncing")) assertTrue(syncIndicatorLabel(SyncIndicator.OK, SyncOutcomeSummary()).startsWith("Sync healthy")) } @Test fun `opaque session errors are classified, not lumped into one failure`() { assertEquals(SyncOutcomeReason.VAULT_LOCKED, classifySyncError("VAULT_LOCKED")) assertEquals(SyncOutcomeReason.VAULT_ID_MISMATCH, classifySyncError("vault_id_mismatch")) assertEquals(SyncOutcomeReason.SYNC_TIMEOUT, classifySyncError("session timed out after 30s")) assertEquals( SyncOutcomeReason.HANDSHAKE_FAILED, classifySyncError("peer identity does not match authenticated KK key"), ) assertEquals( SyncOutcomeReason.HANDSHAKE_FAILED, classifySyncError("reconnect capability does not match relay room"), ) assertEquals(SyncOutcomeReason.RELAY_UNREACHABLE, classifySyncError("relay_closed_1006")) assertEquals(SyncOutcomeReason.RUN_FAILED, classifySyncError("something nobody predicted")) } @Test fun `a session outcome becomes the user-facing one`() { assertEquals( SyncOutcomeReason.COMPLETED_NO_CHANGES, SyncOutcome(SyncOutcomeStatus.COMPLETED).toSummary().reason, ) assertEquals( SyncOutcomeReason.COMPLETED_CHANGES, SyncOutcome(SyncOutcomeStatus.COMPLETED, entriesReceived = 2).toSummary().reason, ) // A deletion that crossed the wire is a change, not "nothing to do". assertEquals( SyncOutcomeReason.COMPLETED_CHANGES, SyncOutcome(SyncOutcomeStatus.COMPLETED, tombstonesSent = 1).toSummary().reason, ) assertEquals( SyncOutcomeReason.VAULT_ID_MISMATCH, SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH).toSummary().reason, ) assertEquals( SyncOutcomeReason.SYNC_TIMEOUT, SyncOutcome(SyncOutcomeStatus.TIMEOUT, error = "session timed out after 30s").toSummary().reason, ) assertEquals( SyncOutcomeReason.RELAY_UNREACHABLE, SyncOutcome(SyncOutcomeStatus.ERRORED, error = "relay_receive_queue_full").toSummary().reason, ) } @Test fun `the reason for a failure survives being killed`() { val failure = SyncOutcomeSummary( reason = SyncOutcomeReason.HANDSHAKE_FAILED, detail = "peer identity does not match authenticated KK key", entriesSent = 4, entriesReceived = 2, finishedAt = 1_700_000_000_000L, ) assertEquals(failure, decodeSyncOutcome(encodeSyncOutcome(failure))) } @Test fun `a process killed mid-sync does not come back claiming to be syncing`() { val inFlight = SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_CHANGES, syncing = true) val restored = decodeSyncOutcome(encodeSyncOutcome(inFlight)) assertEquals(false, restored.syncing) assertEquals(SyncOutcomeReason.COMPLETED_CHANGES, restored.reason) } @Test fun `nothing stored, or something unreadable, restores as nothing attempted`() { assertNull(decodeSyncOutcome(null).reason) assertNull(decodeSyncOutcome("{not json").reason) assertNull(decodeSyncOutcome("""{"reason":"A_REASON_FROM_THE_FUTURE"}""").reason) } }