diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluationTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluationTest.kt new file mode 100644 index 0000000000..8dbb7a590f --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/model/torState/TorRelayEvaluationTest.kt @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.torState + +import com.vitorpamplona.amethyst.ui.tor.TorType +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TorRelayEvaluationTest { + // Helper relay URLs + private val clearnetRelay = NormalizedRelayUrl("wss://relay.damus.io/") + private val onionRelay = NormalizedRelayUrl("wss://abc123.onion/") + private val localhostRelay = NormalizedRelayUrl("ws://127.0.0.1:8080/") + private val localhostNameRelay = NormalizedRelayUrl("ws://localhost:8080/") + private val localNetworkRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/") + private val dmRelay = NormalizedRelayUrl("wss://dm.relay.com/") + private val trustedRelay = NormalizedRelayUrl("wss://trusted.relay.com/") + + private fun buildEvaluation( + torType: TorType = TorType.INTERNAL, + onionViaTor: Boolean = true, + dmViaTor: Boolean = true, + newViaTor: Boolean = true, + trustedViaTor: Boolean = false, + dmRelays: Set = setOf(dmRelay), + trustedRelays: Set = setOf(trustedRelay), + ) = TorRelayEvaluation( + torSettings = + TorRelaySettings( + torType = torType, + onionRelaysViaTor = onionViaTor, + dmRelaysViaTor = dmViaTor, + newRelaysViaTor = newViaTor, + trustedRelaysViaTor = trustedViaTor, + ), + trustedRelayList = trustedRelays, + dmRelayList = dmRelays, + ) + + // --- Tor OFF: always false --- + + @Test + fun torOff_clearnetRelay_returnsFalse() { + val eval = buildEvaluation(torType = TorType.OFF) + assertFalse(eval.useTor(clearnetRelay)) + } + + @Test + fun torOff_onionRelay_returnsFalse() { + val eval = buildEvaluation(torType = TorType.OFF) + assertFalse(eval.useTor(onionRelay)) + } + + @Test + fun torOff_dmRelay_returnsFalse() { + val eval = buildEvaluation(torType = TorType.OFF) + assertFalse(eval.useTor(dmRelay)) + } + + // --- Localhost: always false regardless of Tor --- + + @Test + fun localhost127_alwaysFalse() { + val eval = buildEvaluation(torType = TorType.INTERNAL) + assertFalse(eval.useTor(localhostRelay)) + } + + @Test + fun localhostName_alwaysFalse() { + val eval = buildEvaluation(torType = TorType.INTERNAL) + assertFalse(eval.useTor(localhostNameRelay)) + } + + @Test + fun localNetwork192_alwaysFalse() { + val eval = buildEvaluation(torType = TorType.INTERNAL) + assertFalse(eval.useTor(localNetworkRelay)) + } + + // --- .onion relays --- + + @Test + fun onionRelay_torInternal_onionEnabled_returnsTrue() { + val eval = buildEvaluation(torType = TorType.INTERNAL, onionViaTor = true) + assertTrue(eval.useTor(onionRelay)) + } + + @Test + fun onionRelay_torInternal_onionDisabled_returnsFalse() { + val eval = buildEvaluation(torType = TorType.INTERNAL, onionViaTor = false) + assertFalse(eval.useTor(onionRelay)) + } + + @Test + fun onionRelay_torExternal_onionEnabled_returnsTrue() { + val eval = buildEvaluation(torType = TorType.EXTERNAL, onionViaTor = true) + assertTrue(eval.useTor(onionRelay)) + } + + // --- DM relays --- + + @Test + fun dmRelay_dmViaTorEnabled_returnsTrue() { + val eval = buildEvaluation(dmViaTor = true) + assertTrue(eval.useTor(dmRelay)) + } + + @Test + fun dmRelay_dmViaTorDisabled_returnsFalse() { + val eval = buildEvaluation(dmViaTor = false) + assertFalse(eval.useTor(dmRelay)) + } + + // --- Trusted relays --- + + @Test + fun trustedRelay_trustedViaTorEnabled_returnsTrue() { + val eval = buildEvaluation(trustedViaTor = true) + assertTrue(eval.useTor(trustedRelay)) + } + + @Test + fun trustedRelay_trustedViaTorDisabled_returnsFalse() { + val eval = buildEvaluation(trustedViaTor = false) + assertFalse(eval.useTor(trustedRelay)) + } + + // --- New/unknown relays --- + + @Test + fun unknownRelay_newViaTorEnabled_returnsTrue() { + val eval = buildEvaluation(newViaTor = true) + assertTrue(eval.useTor(clearnetRelay)) + } + + @Test + fun unknownRelay_newViaTorDisabled_returnsFalse() { + val eval = buildEvaluation(newViaTor = false) + assertFalse(eval.useTor(clearnetRelay)) + } + + // --- Priority: .onion > DM > trusted > new --- + + @Test + fun onionRelay_inDmList_treatedAsOnionNotDm() { + // If a relay is both .onion AND in DM list, .onion takes precedence + val onionDmRelay = NormalizedRelayUrl("wss://dmrelay.onion/") + val eval = + buildEvaluation( + onionViaTor = false, + dmViaTor = true, + dmRelays = setOf(onionDmRelay), + ) + // onion check happens first, and it's disabled → false + assertFalse(eval.useTor(onionDmRelay)) + } + + @Test + fun relay_inBothDmAndTrusted_dmTakesPrecedence() { + val bothRelay = NormalizedRelayUrl("wss://both.relay.com/") + val eval = + buildEvaluation( + dmViaTor = true, + trustedViaTor = false, + dmRelays = setOf(bothRelay), + trustedRelays = setOf(bothRelay), + ) + // DM check happens before trusted check + assertTrue(eval.useTor(bothRelay)) + } + + @Test + fun relay_inBothDmAndTrusted_dmDisabled_doesNotFallToTrusted() { + val bothRelay = NormalizedRelayUrl("wss://both.relay.com/") + val eval = + buildEvaluation( + dmViaTor = false, + trustedViaTor = true, + dmRelays = setOf(bothRelay), + trustedRelays = setOf(bothRelay), + ) + // DM check matches first, dm is disabled → false + // Does NOT fall through to trusted check + assertFalse(eval.useTor(bothRelay)) + } + + // --- Empty relay lists --- + + @Test + fun emptyRelayLists_allRelaysAreNew() { + val eval = + buildEvaluation( + newViaTor = true, + dmRelays = emptySet(), + trustedRelays = emptySet(), + ) + assertTrue(eval.useTor(clearnetRelay)) + assertTrue(eval.useTor(dmRelay)) // Not in DM list, treated as new + assertTrue(eval.useTor(trustedRelay)) // Not in trusted list, treated as new + } + + // --- External Tor mode --- + + @Test + fun torExternal_newViaTor_returnsTrue() { + val eval = buildEvaluation(torType = TorType.EXTERNAL, newViaTor = true) + assertTrue(eval.useTor(clearnetRelay)) + } + + @Test + fun torExternal_localhostStillFalse() { + val eval = buildEvaluation(torType = TorType.EXTERNAL) + assertFalse(eval.useTor(localhostRelay)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelaysTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelaysTest.kt new file mode 100644 index 0000000000..9be10f5fbe --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactoryForRelaysTest.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.net.InetSocketAddress +import java.net.Proxy + +/** + * Tests for SOCKS proxy creation and timeout logic. + * + * Note: OkHttpClientFactoryForRelays cannot be instantiated in unit tests because + * its constructor calls android.os.Build (isEmulator check). These tests verify + * the proxy and timeout logic directly using the same patterns the factory uses. + * This documents the behavior we must preserve during extraction to commons. + */ +class OkHttpClientFactoryForRelaysTest { + // --- buildLocalSocksProxy logic (tested directly) --- + + @Test + fun socksProxy_withPort_returnsSocksType() { + val proxy = buildLocalSocksProxy(9050) + assertEquals(Proxy.Type.SOCKS, proxy.type()) + } + + @Test + fun socksProxy_withPort_usesLocalhost() { + val proxy = buildLocalSocksProxy(9050) + val addr = proxy.address() as InetSocketAddress + assertEquals("127.0.0.1", addr.hostString) + } + + @Test + fun socksProxy_withPort_usesGivenPort() { + val proxy = buildLocalSocksProxy(9050) + val addr = proxy.address() as InetSocketAddress + assertEquals(9050, addr.port) + } + + @Test + fun socksProxy_customPort_usesGivenPort() { + val proxy = buildLocalSocksProxy(9150) // Tor Browser port + val addr = proxy.address() as InetSocketAddress + assertEquals(9150, addr.port) + } + + @Test + fun socksProxy_nullPort_fallsBackToDefault9050() { + // DOCUMENTS CURRENT BEHAVIOR: null falls back to 9050 + // This is a security concern flagged in the plan — will be fixed during extraction + val proxy = buildLocalSocksProxy(null) + val addr = proxy.address() as InetSocketAddress + assertEquals(OkHttpClientFactoryForRelays.DEFAULT_SOCKS_PORT, addr.port) + } + + // --- Timeout logic --- + + @Test + fun timeout_mobile_returns30Seconds() { + assertEquals(OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_MOBILE_SECS, buildTimeout(true)) + } + + @Test + fun timeout_wifi_returns10Seconds() { + assertEquals(OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_WIFI_SECS, buildTimeout(false)) + } + + // --- Timeout multiplier with proxy --- + + @Test + fun timeoutWithProxy_tripled() { + val base = 10 + val withProxy = computeTimeout(base, hasProxy = true) + assertEquals(30, withProxy) + } + + @Test + fun timeoutWithoutProxy_unchanged() { + val base = 10 + val withoutProxy = computeTimeout(base, hasProxy = false) + assertEquals(10, withoutProxy) + } + + @Test + fun readWriteTimeout_tripleOfConnectTimeout() { + // Both factories do: readTimeout = connectTimeout * 3 + val connectSeconds = 30 + assertEquals(90, connectSeconds * 3) + } + + // Note: OkHttpClient.Builder tests removed — OkHttp internals depend on + // Android platform classes in this module's test classpath. + // These will be tested in desktopApp/jvmTest after extraction. + + // --- Constants --- + + @Test + fun defaultSocksPort_is9050() { + assertEquals(9050, OkHttpClientFactoryForRelays.DEFAULT_SOCKS_PORT) + } + + @Test + fun defaultIsMobile_isFalse() { + assertEquals(false, OkHttpClientFactoryForRelays.DEFAULT_IS_MOBILE) + } + + @Test + fun defaultTimeoutWifi_is10() { + assertEquals(10, OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_WIFI_SECS) + } + + @Test + fun defaultTimeoutMobile_is30() { + assertEquals(30, OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_MOBILE_SECS) + } + + // --- Helper functions matching factory logic --- + + private fun buildLocalSocksProxy(port: Int?): Proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port ?: OkHttpClientFactoryForRelays.DEFAULT_SOCKS_PORT)) + + private fun buildTimeout(isMobile: Boolean): Int = + if (isMobile) { + OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_MOBILE_SECS + } else { + OkHttpClientFactoryForRelays.DEFAULT_TIMEOUT_ON_WIFI_SECS + } + + private fun computeTimeout( + baseSeconds: Int, + hasProxy: Boolean, + ): Int = if (hasProxy) baseSeconds * 3 else baseSeconds +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt new file mode 100644 index 0000000000..5f972ece35 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsTest.kt @@ -0,0 +1,294 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.tor + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TorSettingsTest { + // --- parseTorType --- + + @Test + fun parseTorType_code0_returnsOff() { + assertEquals(TorType.OFF, parseTorType(0)) + } + + @Test + fun parseTorType_code1_returnsInternal() { + assertEquals(TorType.INTERNAL, parseTorType(1)) + } + + @Test + fun parseTorType_code2_returnsExternal() { + assertEquals(TorType.EXTERNAL, parseTorType(2)) + } + + @Test + fun parseTorType_null_defaultsToInternal() { + assertEquals(TorType.INTERNAL, parseTorType(null)) + } + + @Test + fun parseTorType_unknownCode_defaultsToInternal() { + assertEquals(TorType.INTERNAL, parseTorType(99)) + } + + @Test + fun parseTorType_negativeCode_defaultsToInternal() { + assertEquals(TorType.INTERNAL, parseTorType(-1)) + } + + // --- TorType screenCode consistency --- + + @Test + fun torType_screenCodes_areUnique() { + val codes = TorType.entries.map { it.screenCode } + assertEquals(codes.size, codes.toSet().size) + } + + @Test + fun torType_allValues_roundTripViaParse() { + TorType.entries.forEach { type -> + assertEquals(type, parseTorType(type.screenCode)) + } + } + + // --- parseTorPresetType --- + + @Test + fun parseTorPresetType_code0_returnsOnlyWhenNeeded() { + assertEquals(TorPresetType.ONLY_WHEN_NEEDED, parseTorPresetType(0)) + } + + @Test + fun parseTorPresetType_code1_returnsDefault() { + assertEquals(TorPresetType.DEFAULT, parseTorPresetType(1)) + } + + @Test + fun parseTorPresetType_code2_returnsSmallPayloads() { + assertEquals(TorPresetType.SMALL_PAYLOADS, parseTorPresetType(2)) + } + + @Test + fun parseTorPresetType_code3_returnsFullPrivacy() { + assertEquals(TorPresetType.FULL_PRIVACY, parseTorPresetType(3)) + } + + @Test + fun parseTorPresetType_unknownCode_defaultsToCustom() { + assertEquals(TorPresetType.CUSTOM, parseTorPresetType(99)) + } + + @Test + fun parseTorPresetType_null_defaultsToCustom() { + assertEquals(TorPresetType.CUSTOM, parseTorPresetType(null)) + } + + @Test + fun torPresetType_screenCodes_areUnique() { + val codes = TorPresetType.entries.map { it.screenCode } + assertEquals(codes.size, codes.toSet().size) + } + + // --- Preset definitions --- + + @Test + fun onlyWhenNeededPreset_onlyOnionEnabled() { + assertTrue(torOnlyWhenNeededPreset.onionRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.trustedRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.urlPreviewsViaTor) + assertFalse(torOnlyWhenNeededPreset.profilePicsViaTor) + assertFalse(torOnlyWhenNeededPreset.imagesViaTor) + assertFalse(torOnlyWhenNeededPreset.videosViaTor) + assertFalse(torOnlyWhenNeededPreset.moneyOperationsViaTor) + assertFalse(torOnlyWhenNeededPreset.nip05VerificationsViaTor) + assertFalse(torOnlyWhenNeededPreset.mediaUploadsViaTor) + } + + @Test + fun defaultPreset_onionDmNewEnabled() { + assertTrue(torDefaultPreset.onionRelaysViaTor) + assertTrue(torDefaultPreset.dmRelaysViaTor) + assertTrue(torDefaultPreset.newRelaysViaTor) + assertFalse(torDefaultPreset.trustedRelaysViaTor) + assertFalse(torDefaultPreset.urlPreviewsViaTor) + assertFalse(torDefaultPreset.imagesViaTor) + assertFalse(torDefaultPreset.videosViaTor) + assertFalse(torDefaultPreset.moneyOperationsViaTor) + assertFalse(torDefaultPreset.nip05VerificationsViaTor) + assertFalse(torDefaultPreset.mediaUploadsViaTor) + } + + @Test + fun smallPayloadsPreset_addsPreviewsNip05Money() { + assertTrue(torSmallPayloadsPreset.onionRelaysViaTor) + assertTrue(torSmallPayloadsPreset.dmRelaysViaTor) + assertTrue(torSmallPayloadsPreset.newRelaysViaTor) + assertTrue(torSmallPayloadsPreset.trustedRelaysViaTor) + assertTrue(torSmallPayloadsPreset.urlPreviewsViaTor) + assertTrue(torSmallPayloadsPreset.profilePicsViaTor) + assertFalse(torSmallPayloadsPreset.imagesViaTor) + assertFalse(torSmallPayloadsPreset.videosViaTor) + assertTrue(torSmallPayloadsPreset.moneyOperationsViaTor) + assertTrue(torSmallPayloadsPreset.nip05VerificationsViaTor) + assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) + } + + @Test + fun fullPrivacyPreset_allEnabled() { + assertTrue(torFullyPrivate.onionRelaysViaTor) + assertTrue(torFullyPrivate.dmRelaysViaTor) + assertTrue(torFullyPrivate.newRelaysViaTor) + assertTrue(torFullyPrivate.trustedRelaysViaTor) + assertTrue(torFullyPrivate.urlPreviewsViaTor) + assertTrue(torFullyPrivate.profilePicsViaTor) + assertTrue(torFullyPrivate.imagesViaTor) + assertTrue(torFullyPrivate.videosViaTor) + assertTrue(torFullyPrivate.moneyOperationsViaTor) + assertTrue(torFullyPrivate.nip05VerificationsViaTor) + assertTrue(torFullyPrivate.mediaUploadsViaTor) + } + + // --- Preset hierarchy: each level is a superset of the previous --- + + @Test + fun presets_areIncreasing_defaultSupersetOfOnlyWhenNeeded() { + // Default enables DM + new relays on top of onlyWhenNeeded + assertTrue(torDefaultPreset.dmRelaysViaTor) + assertTrue(torDefaultPreset.newRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.dmRelaysViaTor) + assertFalse(torOnlyWhenNeededPreset.newRelaysViaTor) + } + + @Test + fun presets_areIncreasing_fullPrivacySupersetOfSmallPayloads() { + // Full privacy adds images, videos, media uploads + assertTrue(torFullyPrivate.imagesViaTor) + assertTrue(torFullyPrivate.videosViaTor) + assertTrue(torFullyPrivate.mediaUploadsViaTor) + assertFalse(torSmallPayloadsPreset.imagesViaTor) + assertFalse(torSmallPayloadsPreset.videosViaTor) + assertFalse(torSmallPayloadsPreset.mediaUploadsViaTor) + } + + // --- whichPreset --- + + @Test + fun whichPreset_matchesOnlyWhenNeeded() { + assertEquals(TorPresetType.ONLY_WHEN_NEEDED, whichPreset(torOnlyWhenNeededPreset)) + } + + @Test + fun whichPreset_matchesDefault() { + assertEquals(TorPresetType.DEFAULT, whichPreset(torDefaultPreset)) + } + + @Test + fun whichPreset_matchesSmallPayloads() { + assertEquals(TorPresetType.SMALL_PAYLOADS, whichPreset(torSmallPayloadsPreset)) + } + + @Test + fun whichPreset_matchesFullPrivacy() { + assertEquals(TorPresetType.FULL_PRIVACY, whichPreset(torFullyPrivate)) + } + + @Test + fun whichPreset_returnsCustomForMixedSettings() { + val mixed = + TorSettings( + onionRelaysViaTor = true, + dmRelaysViaTor = true, + newRelaysViaTor = false, // differs from DEFAULT + trustedRelaysViaTor = true, // differs from DEFAULT + ) + assertEquals(TorPresetType.CUSTOM, whichPreset(mixed)) + } + + @Test + fun whichPreset_ignoresProfilePicsInComparison() { + // profilePicsViaTor is commented out in isPreset() + val withProfilePics = torDefaultPreset.copy(profilePicsViaTor = true) + assertEquals(TorPresetType.DEFAULT, whichPreset(withProfilePics)) + } + + @Test + fun whichPreset_ignoresTorTypeAndPort() { + // whichPreset only compares boolean flags, not torType/port + val withExternal = torDefaultPreset.copy(torType = TorType.EXTERNAL, externalSocksPort = 1234) + assertEquals(TorPresetType.DEFAULT, whichPreset(withExternal)) + } + + // --- isPreset --- + + @Test + fun isPreset_exactMatch_returnsTrue() { + assertTrue(isPreset(torFullyPrivate, torFullyPrivate)) + } + + @Test + fun isPreset_differentFlag_returnsFalse() { + val modified = torFullyPrivate.copy(imagesViaTor = false) + assertFalse(isPreset(modified, torFullyPrivate)) + } + + @Test + fun isPreset_torTypeDifference_ignored() { + val withOff = torDefaultPreset.copy(torType = TorType.OFF) + assertTrue(isPreset(withOff, torDefaultPreset)) + } + + // --- TorSettings data class --- + + @Test + fun torSettings_defaultValues() { + val defaults = TorSettings() + assertEquals(TorType.INTERNAL, defaults.torType) + assertEquals(9050, defaults.externalSocksPort) + assertTrue(defaults.onionRelaysViaTor) + assertTrue(defaults.dmRelaysViaTor) + assertTrue(defaults.newRelaysViaTor) + assertFalse(defaults.trustedRelaysViaTor) + } + + @Test + fun torSettings_equality_worksForDistinctUntilChanged() { + val a = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) + val b = TorSettings(torType = TorType.INTERNAL, externalSocksPort = 9050) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun torSettings_copy_changesOneField() { + val original = TorSettings() + val modified = original.copy(torType = TorType.OFF) + assertEquals(TorType.OFF, modified.torType) + assertEquals(original.externalSocksPort, modified.externalSocksPort) + assertNotEquals(original, modified) + } +}