diff --git a/app/src/test/kotlin/org/libremail/contacts/ContactsPermissionManagerTest.kt b/app/src/test/kotlin/org/libremail/contacts/ContactsPermissionManagerTest.kt new file mode 100644 index 0000000..3c65a90 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/contacts/ContactsPermissionManagerTest.kt @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.contacts + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import androidx.core.content.ContextCompat +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ContactsPermissionManagerTest { + + private val context = mockk(relaxed = true) { + every { packageName } returns "org.libremail.app" + } + private val manager = ContactsPermissionManager(context) + + @After + fun tearDown() = unmockkAll() + + @Test + fun `hasPermission is true only when READ_CONTACTS is granted`() { + mockkStatic(ContextCompat::class) + every { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) } returns + PackageManager.PERMISSION_GRANTED + + assertTrue(manager.hasPermission()) + } + + @Test + fun `hasPermission is false when READ_CONTACTS is denied`() { + mockkStatic(ContextCompat::class) + every { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) } returns + PackageManager.PERMISSION_DENIED + + assertFalse(manager.hasPermission()) + } + + @Test + fun `settingsIntent deep-links to this app's system details screen`() { + mockkConstructor(Intent::class) + mockkStatic(Uri::class) + every { Uri.fromParts("package", "org.libremail.app", null) } returns mockk(relaxed = true) + + assertNotNull(manager.settingsIntent()) + + // The intent is scoped to this package via a package: URI so the user lands on LibreMail's page. + verify { Uri.fromParts("package", "org.libremail.app", null) } + } +} diff --git a/app/src/test/kotlin/org/libremail/contacts/ContactsRepositoryTest.kt b/app/src/test/kotlin/org/libremail/contacts/ContactsRepositoryTest.kt new file mode 100644 index 0000000..bd717f9 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/contacts/ContactsRepositoryTest.kt @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.contacts + +import android.content.ContentResolver +import android.content.Context +import android.database.Cursor +import android.provider.ContactsContract.CommonDataKinds.Email +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * [ContactsRepository] resolves recipient-autocomplete suggestions from `ContactsContract`. The + * `ContentResolver`/`Cursor` are mocked (the real provider needs a device), which is enough to lock in + * the query-shape, the too-short-query short-circuit, de-duplication, the name fallback, the result + * cap, and the fail-soft behaviour when the provider throws. + */ +class ContactsRepositoryTest { + + private val resolver = mockk() + private val context = mockk { every { contentResolver } returns resolver } + private val repository = ContactsRepository(context) + + private fun cursorOf(addresses: List, names: List): Cursor { + val cursor = mockk(relaxed = true) + every { cursor.getColumnIndexOrThrow(Email.ADDRESS) } returns 0 + every { cursor.getColumnIndexOrThrow(Email.DISPLAY_NAME_PRIMARY) } returns 1 + // Advance exactly once per seeded row, then stop — never an unbounded "always true" cursor. + every { cursor.moveToNext() } returnsMany (List(addresses.size) { true } + false) + every { cursor.getString(0) } returnsMany addresses + every { cursor.getString(1) } returnsMany names + return cursor + } + + private fun stubQuery(cursor: Cursor?) { + every { resolver.query(any(), any(), any(), any(), any()) } returns cursor + } + + @Test + fun `a query under two characters short-circuits without touching the provider`() = runTest { + assertEquals(emptyList(), repository.search("a")) + verify(exactly = 0) { resolver.query(any(), any(), any(), any(), any()) } + } + + @Test + fun `maps matching rows, de-duplicates by email, and falls back to the email as the name`() = runTest { + // Row 2 duplicates row 1 (case-insensitively); row 3 has a blank address; row 4 has no name. + // getString(name) is only read for the rows that pass the address checks (rows 1 and 4). + stubQuery( + cursorOf( + addresses = listOf("alice@example.org", "ALICE@example.org", " ", "bob@example.org"), + names = listOf("Alice", null), + ), + ) + + val results = repository.search("al") + + assertEquals( + listOf( + ContactSuggestion("Alice", "alice@example.org"), + ContactSuggestion("bob@example.org", "bob@example.org"), + ), + results, + ) + } + + @Test + fun `caps the number of suggestions returned`() = runTest { + val many = (1..20).map { "user$it@example.org" } + stubQuery(cursorOf(addresses = many, names = List(20) { "User $it" })) + + assertEquals(8, repository.search("user").size) + } + + @Test + fun `a null cursor yields no suggestions`() = runTest { + stubQuery(null) + + assertEquals(emptyList(), repository.search("query")) + } + + @Test + fun `a provider failure fails soft to an empty list`() = runTest { + every { resolver.query(any(), any(), any(), any(), any()) } throws SecurityException("no permission") + + assertEquals(emptyList(), repository.search("query")) + } + + @Test + fun `ContactSuggestion value semantics`() { + val suggestion = ContactSuggestion("Alice", "alice@example.org") + + val (name, email) = suggestion + assertEquals("Alice", name) + assertEquals("alice@example.org", email) + assertEquals(suggestion, suggestion.copy()) + assertEquals(suggestion.hashCode(), suggestion.copy().hashCode()) + assertTrue(suggestion.toString().contains("Alice")) + assertNotEquals(suggestion, suggestion.copy(name = "Bob")) + assertNotEquals(suggestion, suggestion.copy(email = "bob@example.org")) + } +} diff --git a/app/src/test/kotlin/org/libremail/power/AndroidBatteryStatusProviderTest.kt b/app/src/test/kotlin/org/libremail/power/AndroidBatteryStatusProviderTest.kt new file mode 100644 index 0000000..941a0f3 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/power/AndroidBatteryStatusProviderTest.kt @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.power + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import androidx.core.content.ContextCompat +import app.cash.turbine.test +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class AndroidBatteryStatusProviderTest { + + private val context = mockk(relaxed = true) + private val provider = AndroidBatteryStatusProvider(context) + + @After + fun tearDown() = unmockkAll() + + private fun batteryManager(percent: Int, charging: Boolean) = mockk { + every { getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) } returns percent + every { isCharging } returns charging + } + + private fun batteryIntent(level: Int, scale: Int, status: Int) = mockk { + every { getIntExtra(BatteryManager.EXTRA_LEVEL, -1) } returns level + every { getIntExtra(BatteryManager.EXTRA_SCALE, -1) } returns scale + every { getIntExtra(BatteryManager.EXTRA_STATUS, -1) } returns status + } + + @Test + fun `current reads the one-shot capacity and charging state`() { + every { context.getSystemService(BatteryManager::class.java) } returns batteryManager(55, charging = true) + + assertEquals(BatteryStatus(55, isCharging = true), provider.current()) + } + + @Test + fun `current degrades to ASSUME_OK when the reported capacity is out of range`() { + every { context.getSystemService(BatteryManager::class.java) } returns batteryManager(-1, charging = false) + + assertEquals(BatteryStatus.ASSUME_OK.percent, provider.current().percent) + } + + @Test + fun `current degrades to ASSUME_OK when there is no battery service`() { + every { context.getSystemService(BatteryManager::class.java) } returns null + + assertEquals(BatteryStatus.ASSUME_OK, provider.current()) + } + + @Test + fun `status emits the sticky snapshot immediately, then each live battery change`() = runTest { + mockkStatic(ContextCompat::class) + mockkConstructor(IntentFilter::class) + val receiver = slot() + every { ContextCompat.registerReceiver(any(), capture(receiver), any(), any()) } returns + batteryIntent(level = 50, scale = 100, status = BatteryManager.BATTERY_STATUS_CHARGING) + + provider.status().test { + assertEquals(BatteryStatus(50, isCharging = true), awaitItem()) + + receiver.captured.onReceive( + context, + batteryIntent(level = 80, scale = 100, status = BatteryManager.BATTERY_STATUS_DISCHARGING), + ) + assertEquals(BatteryStatus(80, isCharging = false), awaitItem()) + + cancelAndIgnoreRemainingEvents() + } + + // The receiver is unregistered when collection stops so the registration lasts exactly as long + // as the flow is collected. + verify { context.unregisterReceiver(any()) } + } + + @Test + fun `status maps unreadable battery extras to the ASSUME_OK percent and treats FULL as charging`() = runTest { + mockkStatic(ContextCompat::class) + mockkConstructor(IntentFilter::class) + every { ContextCompat.registerReceiver(any(), any(), any(), any()) } returns + batteryIntent(level = -1, scale = 0, status = BatteryManager.BATTERY_STATUS_FULL) + + provider.status().test { + val status = awaitItem() + assertEquals(BatteryStatus.ASSUME_OK.percent, status.percent) + assertTrue(status.isCharging) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `status falls back to a one-shot read when there is no sticky broadcast`() = runTest { + mockkStatic(ContextCompat::class) + mockkConstructor(IntentFilter::class) + every { ContextCompat.registerReceiver(any(), any(), any(), any()) } returns null + every { context.getSystemService(BatteryManager::class.java) } returns batteryManager(42, charging = false) + + provider.status().test { + assertEquals(BatteryStatus(42, isCharging = false), awaitItem()) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/app/src/test/kotlin/org/libremail/push/BatteryOptimizationManagerInstanceTest.kt b/app/src/test/kotlin/org/libremail/push/BatteryOptimizationManagerInstanceTest.kt new file mode 100644 index 0000000..342bf75 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/push/BatteryOptimizationManagerInstanceTest.kt @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.PowerManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.junit.After +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Exercises [BatteryOptimizationManager]'s Context-backed methods with the Android statics/constructors + * mocked. The pure fallback-selection logic behind [BatteryOptimizationManager.settingsIntent] has its + * own exhaustive coverage in [BatteryOptimizationManagerTest]; these lock in the manager's own wiring. + */ +class BatteryOptimizationManagerInstanceTest { + + private val context = mockk(relaxed = true) { + every { packageName } returns "org.libremail.app" + } + private val manager = BatteryOptimizationManager(context) + + @After + fun tearDown() = unmockkAll() + + @Test + fun `isSupported reads the platform version gate`() { + // JVM Build.VERSION.SDK_INT is 0, so the API-23 gate reports unsupported here; the call still + // exercises the branch (the live device path is covered by the instrumented battery step test). + assertFalse(manager.isSupported) + } + + @Test + fun `isIgnoringBatteryOptimizations reflects the Doze allowlist`() { + val powerManager = mockk { + every { isIgnoringBatteryOptimizations("org.libremail.app") } returns true + } + every { context.getSystemService(PowerManager::class.java) } returns powerManager + + assertTrue(manager.isIgnoringBatteryOptimizations()) + } + + @Test + fun `isIgnoringBatteryOptimizations is false when no power service is available`() { + every { context.getSystemService(PowerManager::class.java) } returns null + + assertFalse(manager.isIgnoringBatteryOptimizations()) + } + + @Test + fun `candidateIntents offers app-details first, then the battery-optimization list`() { + mockkConstructor(Intent::class) + mockkStatic(Uri::class) + every { Uri.fromParts(any(), any(), any()) } returns mockk(relaxed = true) + + assertEquals(2, manager.candidateIntents().size) + } + + @Test + fun `settingsIntent returns the first resolvable candidate`() { + mockkConstructor(Intent::class) + mockkStatic(Uri::class) + every { Uri.fromParts(any(), any(), any()) } returns mockk(relaxed = true) + every { anyConstructed().resolveActivity(any()) } returns mockk() + + assertNotNull(manager.settingsIntent()) + } + + @Test + fun `settingsIntent falls back to the last candidate when none resolve`() { + mockkConstructor(Intent::class) + mockkStatic(Uri::class) + every { Uri.fromParts(any(), any(), any()) } returns mockk(relaxed = true) + every { anyConstructed().resolveActivity(any()) } returns null + + assertNotNull(manager.settingsIntent()) + } +} diff --git a/app/src/test/kotlin/org/libremail/push/IdlePushManagerTest.kt b/app/src/test/kotlin/org/libremail/push/IdlePushManagerTest.kt new file mode 100644 index 0000000..732a046 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/push/IdlePushManagerTest.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import android.content.Context +import android.content.Intent +import androidx.core.content.ContextCompat +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkConstructor +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Test + +class IdlePushManagerTest { + + private val context = mockk(relaxed = true) + private val manager = IdlePushManager(context) + + @After + fun tearDown() = unmockkAll() + + @Test + fun `start launches the idle foreground service`() { + mockkConstructor(Intent::class) + mockkStatic(ContextCompat::class) + every { ContextCompat.startForegroundService(any(), any()) } just Runs + + manager.start() + + verify { ContextCompat.startForegroundService(context, any()) } + } + + @Test + fun `start swallows a background-start rejection so periodic sync still covers mail`() { + mockkConstructor(Intent::class) + mockkStatic(ContextCompat::class) + every { ContextCompat.startForegroundService(any(), any()) } throws + IllegalStateException("cannot start FGS from background") + + // No exception escapes: the runCatching keeps a rejected foreground start from crashing the app. + manager.start() + + verify { ContextCompat.startForegroundService(context, any()) } + } + + @Test + fun `stop tears down the idle service`() { + mockkConstructor(Intent::class) + + manager.stop() + + verify { context.stopService(any()) } + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/AppLogTest.kt b/app/src/test/kotlin/org/libremail/reporting/AppLogTest.kt new file mode 100644 index 0000000..10a0696 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/AppLogTest.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.util.Log +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals + +/** + * [AppLog] mirrors every Logcat call into the process [RingLogBuffer] so recent activity can be + * attached to a crash report. `android.util.Log` is a no-op stub in JVM tests, so it is statically + * mocked; the assertions are on what lands in the buffer. + */ +class AppLogTest { + + private val buffer = RingLogBuffer() + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.d(any(), any()) } returns 0 + every { Log.i(any(), any()) } returns 0 + every { Log.w(any(), any()) } returns 0 + every { Log.e(any(), any(), any()) } returns 0 + AppLog.install(buffer) + } + + @After + fun tearDown() = unmockkAll() + + @Test + fun `each level records its line into the buffer and forwards to Logcat`() { + AppLog.d("Tag", "debug line") + AppLog.i("Tag", "info line") + AppLog.w("Tag", "warn line") + AppLog.e("Tag", "error line") + AppLog.e("Tag", "error with cause", IllegalStateException("boom")) + + val snapshot = buffer.snapshot() + assertEquals( + listOf('D', 'I', 'W', 'E', 'E'), + snapshot.map { it.level }, + ) + assertEquals( + listOf("debug line", "info line", "warn line", "error line", "error with cause"), + snapshot.map { it.message }, + ) + verify { Log.d("Tag", "debug line") } + verify { Log.i("Tag", "info line") } + verify { Log.w("Tag", "warn line") } + verify { Log.e("Tag", "error line", null) } + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/AppVersionProviderTest.kt b/app/src/test/kotlin/org/libremail/reporting/AppVersionProviderTest.kt new file mode 100644 index 0000000..824b6e1 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/AppVersionProviderTest.kt @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals + +class AppVersionProviderTest { + + private fun context(pm: PackageManager): Context = mockk { + every { packageName } returns "org.libremail.app" + every { packageManager } returns pm + } + + @Test + fun `resolves the version name and code from the package info`() { + val info = mockk(relaxed = true) + info.versionName = "1.2.3" + every { info.longVersionCode } returns 42L + val pm = mockk { every { getPackageInfo("org.libremail.app", 0) } returns info } + + val provider = AppVersionProvider(context(pm)) + + assertEquals("1.2.3", provider.versionName) + assertEquals(42L, provider.versionCode) + } + + @Test + fun `falls back to unknown when the package reports no version name`() { + val info = mockk(relaxed = true) + info.versionName = null + every { info.longVersionCode } returns 7L + val pm = mockk { every { getPackageInfo(any(), any()) } returns info } + + val provider = AppVersionProvider(context(pm)) + + assertEquals("unknown", provider.versionName) + assertEquals(7L, provider.versionCode) + } + + @Test + fun `tolerates a lookup failure and degrades to unknown`() { + val pm = mockk { + every { getPackageInfo(any(), any()) } throws PackageManager.NameNotFoundException() + } + + val provider = AppVersionProvider(context(pm)) + + assertEquals("unknown", provider.versionName) + assertEquals(0L, provider.versionCode) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/CrashReporterInstallTest.kt b/app/src/test/kotlin/org/libremail/reporting/CrashReporterInstallTest.kt new file mode 100644 index 0000000..32354a7 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/CrashReporterInstallTest.kt @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.libremail.data.settings.SettingsRepository +import kotlin.test.assertEquals + +/** + * Covers [CrashReporter.install]: the installed handler must persist the crash locally AND still chain + * to the previous handler so the OS crash dialog/termination is preserved. (The no-previous branch + * calls `Process.killProcess` + `exitProcess`, which cannot be exercised in a JVM unit test without + * tearing down the test process, so it stays for the instrumented suite.) Added as a separate file so + * it doesn't collide with the in-flight [CrashReporterTest] changes. + */ +class CrashReporterInstallTest { + + @get:Rule + val tempFolder = TemporaryFolder() + + private val appVersion = mockk { + every { versionName } returns "0.1.0" + every { versionCode } returns 1L + } + private val settingsRepository = mockk() + + private var original: Thread.UncaughtExceptionHandler? = null + + @Before + fun saveHandler() { + original = Thread.getDefaultUncaughtExceptionHandler() + } + + @After + fun restoreHandler() { + Thread.setDefaultUncaughtExceptionHandler(original) + } + + @Test + fun `install persists the crash and still chains to the previous handler`() { + val previous = mockk(relaxed = true) + Thread.setDefaultUncaughtExceptionHandler(previous) + val store = ReportStore(tempFolder.root) + val buffer = RingLogBuffer() + val collector = DiagnosticsCollector(appVersion, settingsRepository, buffer) + val reporter = CrashReporter(collector, store, buffer) + + reporter.install() + val installed = requireNotNull(Thread.getDefaultUncaughtExceptionHandler()) + val thread = Thread.currentThread() + val crash = IllegalStateException("uncaught boom") + installed.uncaughtException(thread, crash) + + // Persisted for next-launch review... + assertEquals(1, store.reports.value.size) + assertEquals(ReportKind.CRASH, store.reports.value.single().kind) + // ...and the OS's original handler still ran, so the system crash still surfaces. + verify { previous.uncaughtException(thread, crash) } + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/LogEntryTest.kt b/app/src/test/kotlin/org/libremail/reporting/LogEntryTest.kt new file mode 100644 index 0000000..e7ffbf4 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/LogEntryTest.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class LogEntryTest { + + @Test + fun `formatted renders the timestamp, level, tag, and message`() { + val formatted = LogEntry(timeMillis = 0L, level = 'W', tag = "Sync", message = "hello").formatted() + + assertTrue(formatted.contains("W/Sync: hello"), formatted) + assertTrue(formatted.contains("1970-01-01"), formatted) + } + + @Test + fun `carries value semantics`() { + val entry = LogEntry(timeMillis = 5L, level = 'I', tag = "Tag", message = "msg") + + assertEquals(5L, entry.timeMillis) + assertEquals('I', entry.level) + assertEquals("Tag", entry.tag) + assertEquals("msg", entry.message) + + assertEquals(entry, entry.copy()) + assertEquals(entry.hashCode(), entry.copy().hashCode()) + assertTrue(entry.toString().contains("msg")) + assertNotEquals(entry, entry.copy(message = "other")) + assertNotEquals(entry, entry.copy(level = 'E')) + assertNotEquals(entry, entry.copy(timeMillis = 6L)) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/ReportSubmitterTest.kt b/app/src/test/kotlin/org/libremail/reporting/ReportSubmitterTest.kt new file mode 100644 index 0000000..9774c64 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/ReportSubmitterTest.kt @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import androidx.work.WorkInfo +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class ReportSubmitterTest { + + private val scheduler = mockk(relaxed = true) + private val submitter = ReportSubmitter(scheduler) + + private fun workInfo(state: WorkInfo.State) = mockk { every { this@mockk.state } returns state } + + private suspend fun status(vararg states: WorkInfo.State): SubmitStatus { + every { scheduler.statusFlow("rid") } returns flowOf(states.map { workInfo(it) }) + return submitter.status("rid").first() + } + + @Test + fun `submission is disabled when no ingest endpoint is configured in this build`() { + // The default build ships an empty DEBUG_REPORT_ENDPOINT, so nothing can ever be transmitted. + assertFalse(submitter.isEnabled) + } + + @Test + fun `submit enqueues an upload for the report`() { + submitter.submit("rid") + + verify(exactly = 1) { scheduler.enqueue("rid") } + } + + @Test + fun `no work yet reads as idle`() = runTest { + assertEquals(SubmitStatus.IDLE, status()) + } + + @Test + fun `all-succeeded work reads as succeeded`() = runTest { + assertEquals(SubmitStatus.SUCCEEDED, status(WorkInfo.State.SUCCEEDED)) + } + + @Test + fun `a failed or cancelled attempt reads as failed`() = runTest { + assertEquals(SubmitStatus.FAILED, status(WorkInfo.State.FAILED)) + assertEquals(SubmitStatus.FAILED, status(WorkInfo.State.CANCELLED)) + // A failure anywhere in the set dominates even a concurrent success. + assertEquals(SubmitStatus.FAILED, status(WorkInfo.State.SUCCEEDED, WorkInfo.State.FAILED)) + } + + @Test + fun `in-flight or not-yet-complete work reads as submitting`() = runTest { + assertEquals(SubmitStatus.SUBMITTING, status(WorkInfo.State.RUNNING)) + assertEquals(SubmitStatus.SUBMITTING, status(WorkInfo.State.ENQUEUED)) + // Partly done but not all succeeded is still in progress. + assertEquals(SubmitStatus.SUBMITTING, status(WorkInfo.State.SUCCEEDED, WorkInfo.State.RUNNING)) + } + + @Test + fun `SubmitStatus enumerates the coarse submission states`() { + assertEquals( + listOf("IDLE", "SUBMITTING", "SUCCEEDED", "FAILED"), + SubmitStatus.entries.map { it.name }, + ) + assertEquals(SubmitStatus.FAILED, SubmitStatus.valueOf("FAILED")) + } +} diff --git a/app/src/test/kotlin/org/libremail/reporting/ReportUploadWorkerTest.kt b/app/src/test/kotlin/org/libremail/reporting/ReportUploadWorkerTest.kt new file mode 100644 index 0000000..52e6050 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/reporting/ReportUploadWorkerTest.kt @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.reporting + +import androidx.work.ListenableWorker.Result +import androidx.work.workDataOf +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +/** + * The transmit path only runs when a `DEBUG_REPORT_ENDPOINT` is configured — the default build ships + * an empty one (the ingest server is out of scope for this repo), so these cover the reachable control + * flow: a missing report id, a report deleted before the job ran, and the "no endpoint configured" + * short-circuit that returns a clear failure instead of a silent no-op. + */ +class ReportUploadWorkerTest { + + private val store = mockk(relaxed = true) + + private fun worker(inputId: String?) = ReportUploadWorker( + mockk(relaxed = true), + mockk(relaxed = true) { + every { inputData } returns + if (inputId == null) workDataOf() else workDataOf(ReportUploadWorker.KEY_REPORT_ID to inputId) + }, + store, + ) + + private fun report(id: String) = DebugReport( + id = id, + createdAtMillis = 1L, + kind = ReportKind.CRASH, + appVersionName = "0.1.0", + appVersionCode = 1, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel", + stackTrace = "boom", + settings = emptyMap(), + logs = emptyList(), + ) + + @Test + fun `a missing report id succeeds without work`() = runTest { + assertEquals(Result.success(), worker(inputId = null).doWork()) + } + + @Test + fun `a report discarded before the job runs succeeds without work`() = runTest { + every { store.find("gone") } returns null + + assertEquals(Result.success(), worker(inputId = "gone").doWork()) + } + + @Test + fun `a configured report fails cleanly when no ingest endpoint is set in this build`() = runTest { + every { store.find("rid") } returns report("rid") + + // No endpoint configured -> a clear failure the UI can steer away from, never a silent success. + assertEquals(Result.failure(), worker(inputId = "rid").doWork()) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModelTest.kt new file mode 100644 index 0000000..9cc131b --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModelTest.kt @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.util.Log +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.auth.OAuthResult +import org.libremail.auth.OutlookAuthManager +import org.libremail.domain.repository.AccountRepository +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class AccountSetupViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + private fun viewModel( + outlookAuthManager: OutlookAuthManager = mockk(relaxed = true), + accountRepository: AccountRepository = mockk(relaxed = true), + ) = AccountSetupViewModel(outlookAuthManager, accountRepository) + + @Test + fun `isOutlookConfigured reflects the auth manager`() { + val manager = mockk(relaxed = true) + every { manager.isConfigured } returns true + + assertTrue(viewModel(outlookAuthManager = manager).isOutlookConfigured) + } + + @Test + fun `outlookAuthIntent wraps a successfully built intent`() { + val manager = mockk(relaxed = true) + every { manager.createAuthIntent() } returns mockk() + + assertTrue(viewModel(outlookAuthManager = manager).outlookAuthIntent().isSuccess) + } + + @Test + fun `outlookAuthIntent captures a thrown build failure instead of crashing`() { + val manager = mockk(relaxed = true) + every { manager.createAuthIntent() } throws ActivityNotFoundException("no browser") + + assertTrue(viewModel(outlookAuthManager = manager).outlookAuthIntent().isFailure) + } + + @Test + fun `a missing browser is reported with a browser-specific message`() { + val vm = viewModel() + + vm.onOutlookLaunchFailed(mockk(relaxed = true)) + + assertEquals("No web browser is available for Microsoft sign-in", vm.state.value.error) + assertEquals(SetupStatus.IDLE, vm.state.value.status) + } + + @Test + fun `a generic launch failure surfaces its own message`() { + val vm = viewModel() + + vm.onOutlookLaunchFailed(IllegalStateException("appauth exploded")) + + assertEquals("appauth exploded", vm.state.value.error) + } + + @Test + fun `a launch failure with no message uses a generic fallback`() { + val vm = viewModel() + + vm.onOutlookLaunchFailed(RuntimeException()) + + assertEquals("Couldn't start Microsoft sign-in", vm.state.value.error) + } + + @Test + fun `a cancelled sign-in (null result) surfaces a cancellation message`() { + val vm = viewModel() + + vm.onOutlookResult(null) + + assertEquals("Microsoft sign-in was cancelled", vm.state.value.error) + assertEquals(SetupStatus.IDLE, vm.state.value.status) + } + + @Test + fun `a successful redirect exchanges the token and records the added account`() = runTest(dispatcher) { + val manager = mockk(relaxed = true) + coEvery { manager.exchangeToken(any()) } returns + OAuthResult(email = "me@outlook.com", accessToken = "tok", authStateJson = "{}") + val accounts = mockk(relaxed = true) + coEvery { accounts.addOutlookAccount("me@outlook.com", "tok", "{}") } returns Result.success(listOf("INBOX")) + val vm = viewModel(outlookAuthManager = manager, accountRepository = accounts) + + vm.onOutlookResult(mockk(relaxed = true)) + advanceUntilIdle() + + assertEquals(SetupStatus.DONE, vm.state.value.status) + assertEquals("outlook:me@outlook.com", vm.state.value.addedAccountId) + assertNull(vm.state.value.error) + coVerify { accounts.addOutlookAccount("me@outlook.com", "tok", "{}") } + } + + @Test + fun `a failed token exchange returns to idle with the error surfaced`() = runTest(dispatcher) { + mockkStatic(Log::class) + every { Log.d(any(), any(), any()) } returns 0 + val manager = mockk(relaxed = true) + coEvery { manager.exchangeToken(any()) } throws IllegalStateException("Token exchange failed") + val vm = viewModel(outlookAuthManager = manager) + + vm.onOutlookResult(mockk(relaxed = true)) + advanceUntilIdle() + + assertEquals(SetupStatus.IDLE, vm.state.value.status) + assertEquals("Token exchange failed", vm.state.value.error) + assertNull(vm.state.value.addedAccountId) + } + + @Test + fun `a persistence failure after exchange returns to idle with an error`() = runTest(dispatcher) { + mockkStatic(Log::class) + every { Log.d(any(), any(), any()) } returns 0 + val manager = mockk(relaxed = true) + coEvery { manager.exchangeToken(any()) } returns + OAuthResult(email = "me@outlook.com", accessToken = "tok", authStateJson = "{}") + val accounts = mockk(relaxed = true) + coEvery { accounts.addOutlookAccount(any(), any(), any()) } returns + Result.failure(RuntimeException("IMAP verification failed")) + val vm = viewModel(outlookAuthManager = manager, accountRepository = accounts) + + vm.onOutlookResult(mockk(relaxed = true)) + advanceUntilIdle() + + assertEquals(SetupStatus.IDLE, vm.state.value.status) + assertEquals("IMAP verification failed", vm.state.value.error) + } + + @Test + fun `consumeError clears a surfaced error`() { + val vm = viewModel() + vm.onOutlookResult(null) + + vm.consumeError() + + assertNull(vm.state.value.error) + } + + @Test + fun `AccountSetupUiState value semantics`() { + val state = AccountSetupUiState(status = SetupStatus.DONE, error = "e", addedAccountId = "outlook:me") + + assertEquals(state, state.copy()) + assertEquals(state.hashCode(), state.copy().hashCode()) + assertTrue(state.toString().contains("outlook:me")) + assertNotEquals(state, state.copy(status = SetupStatus.IDLE)) + assertNotEquals(state, state.copy(error = null)) + assertNotEquals(state, state.copy(addedAccountId = null)) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelExtraTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelExtraTest.kt new file mode 100644 index 0000000..2676cc8 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelExtraTest.kt @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.lifecycle.SavedStateHandle +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.MailProvider +import org.libremail.domain.repository.AccountRepository +import org.libremail.ui.navigation.Routes +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Fills the gaps [AppPasswordViewModelTest] leaves: the disclosure/error helpers and the no-message failure. */ +@OptIn(ExperimentalCoroutinesApi::class) +class AppPasswordViewModelExtraTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel(repo: AccountRepository) = AppPasswordViewModel( + SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to MailProvider.GMAIL.key)), + repo, + ) + + @Test + fun `toggleAdvanced flips the disclosure and consumeError clears the message`() { + val vm = viewModel(mockk(relaxed = true)) + + vm.toggleAdvanced() + assertTrue(vm.form.value.advancedExpanded) + vm.toggleAdvanced() + assertFalse(vm.form.value.advancedExpanded) + + vm.testAndSave() // blank fields -> sets an error + assertNotEquals(null, vm.form.value.error) + vm.consumeError() + assertNull(vm.form.value.error) + } + + @Test + fun `a connection failure with no message falls back to a generic one`() = runTest(dispatcher) { + val repo = mockk() + coEvery { repo.addImapAccount(any(), any()) } returns Result.failure(RuntimeException()) + val vm = viewModel(repo) + + vm.onEmail("user@gmail.com") + vm.onAppPassword("app-pass") + vm.testAndSave() + + assertEquals("Could not connect to the server", vm.form.value.error) + assertEquals(SetupStatus.IDLE, vm.form.value.status) + } + + @Test + fun `AppPasswordForm carries value semantics`() { + val form = AppPasswordForm( + email = "user@gmail.com", + appPassword = "app-pass", + advancedExpanded = true, + status = SetupStatus.DONE, + error = "boom", + addedAccountId = "imap:user@gmail.com", + ) + assertEquals(form, form.copy()) + assertEquals(form.hashCode(), form.copy().hashCode()) + assertTrue(form.toString().contains("user@gmail.com")) + assertNotEquals(form, form.copy(status = SetupStatus.IDLE)) + assertNotEquals(form, form.copy(error = null)) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModelTest.kt new file mode 100644 index 0000000..8da34b6 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModelTest.kt @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.Account +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.repository.AccountRepository +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ManualSetupViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun filled(vm: ManualSetupViewModel) { + vm.onEmail(" user@example.org ") + vm.onPassword("secret") + vm.onImapHost(" imap.example.org ") + vm.onSmtpHost(" smtp.example.org ") + } + + @Test + fun `starts with the standard secure-port defaults`() { + val form = ManualSetupViewModel(mockk(relaxed = true)).form.value + + assertEquals("993", form.imapPort) + assertEquals("465", form.smtpPort) + assertEquals(MailSecurity.SSL_TLS, form.imapSecurity) + assertEquals(MailSecurity.SSL_TLS, form.smtpSecurity) + assertEquals(SetupStatus.IDLE, form.status) + assertFalse(form.advancedExpanded) + } + + @Test + fun `field setters update the form`() { + val vm = ManualSetupViewModel(mockk(relaxed = true)) + + vm.onEmail("a@b.org") + vm.onPassword("pw") + vm.onImapHost("imap.host") + vm.onSmtpHost("smtp.host") + vm.onImapSecurity(MailSecurity.STARTTLS) + vm.onSmtpSecurity(MailSecurity.NONE) + + val f = vm.form.value + assertEquals("a@b.org", f.email) + assertEquals("pw", f.password) + assertEquals("imap.host", f.imapHost) + assertEquals("smtp.host", f.smtpHost) + assertEquals(MailSecurity.STARTTLS, f.imapSecurity) + assertEquals(MailSecurity.NONE, f.smtpSecurity) + } + + @Test + fun `port inputs keep only digits and cap at five characters`() { + val vm = ManualSetupViewModel(mockk(relaxed = true)) + + vm.onImapPort("9a9b3xyz") + vm.onSmtpPort("1234567") + + assertEquals("993", vm.form.value.imapPort) + assertEquals("12345", vm.form.value.smtpPort) + } + + @Test + fun `toggleAdvanced flips the disclosure and consumeError clears the message`() { + val vm = ManualSetupViewModel(mockk(relaxed = true)) + + vm.toggleAdvanced() + assertTrue(vm.form.value.advancedExpanded) + vm.toggleAdvanced() + assertFalse(vm.form.value.advancedExpanded) + + vm.testAndSave() // invalid -> sets an error + assertNotEquals(null, vm.form.value.error) + vm.consumeError() + assertNull(vm.form.value.error) + } + + @Test + fun `testAndSave rejects an incomplete form without contacting the server`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + val vm = ManualSetupViewModel(repo) + + vm.onEmail("user@example.org") // missing password + hosts + vm.testAndSave() + + assertEquals("Enter email, password, and both servers", vm.form.value.error) + assertEquals(SetupStatus.IDLE, vm.form.value.status) + coVerify(exactly = 0) { repo.addImapAccount(any(), any()) } + } + + @Test + fun `testAndSave builds a trimmed account and marks it added on success`() = runTest(dispatcher) { + val repo = mockk() + val account = slot() + coEvery { repo.addImapAccount(capture(account), "secret") } returns Result.success(listOf("INBOX")) + val vm = ManualSetupViewModel(repo) + filled(vm) + vm.onImapPort("143") + vm.onImapSecurity(MailSecurity.STARTTLS) + + vm.testAndSave() + + assertEquals("imap:user@example.org", account.captured.id) + assertEquals("user@example.org", account.captured.email) + assertEquals("imap.example.org", account.captured.imap.host) + assertEquals(143, account.captured.imap.port) + assertEquals(MailSecurity.STARTTLS, account.captured.imap.security) + assertEquals("smtp.example.org", account.captured.smtp.host) + assertEquals(465, account.captured.smtp.port) + assertEquals(SetupStatus.DONE, vm.form.value.status) + assertEquals("imap:user@example.org", vm.form.value.addedAccountId) + } + + @Test + fun `testAndSave falls back to the default ports when the port fields are blank`() = runTest(dispatcher) { + val repo = mockk() + val account = slot() + coEvery { repo.addImapAccount(capture(account), any()) } returns Result.success(emptyList()) + val vm = ManualSetupViewModel(repo) + filled(vm) + vm.onImapPort("") + vm.onSmtpPort("") + + vm.testAndSave() + + assertEquals(993, account.captured.imap.port) + assertEquals(465, account.captured.smtp.port) + } + + @Test + fun `testAndSave surfaces a connection failure inline and leaves the account unadded`() = runTest(dispatcher) { + val repo = mockk() + coEvery { repo.addImapAccount(any(), any()) } returns Result.failure(RuntimeException("Login failed")) + val vm = ManualSetupViewModel(repo) + filled(vm) + + vm.testAndSave() + + assertEquals("Login failed", vm.form.value.error) + assertEquals(SetupStatus.IDLE, vm.form.value.status) + assertNull(vm.form.value.addedAccountId) + } + + @Test + fun `testAndSave uses a generic message when the failure has none`() = runTest(dispatcher) { + val repo = mockk() + coEvery { repo.addImapAccount(any(), any()) } returns Result.failure(RuntimeException()) + val vm = ManualSetupViewModel(repo) + filled(vm) + + vm.testAndSave() + + assertEquals("Could not connect to the server", vm.form.value.error) + } + + @Test + fun `ManualSetupForm isValid requires email, password, and both hosts`() { + val complete = ManualSetupForm( + email = "a@b.org", + password = "pw", + imapHost = "imap", + smtpHost = "smtp", + ) + assertTrue(complete.isValid) + assertFalse(complete.copy(email = "").isValid) + assertFalse(complete.copy(password = "").isValid) + assertFalse(complete.copy(imapHost = "").isValid) + assertFalse(complete.copy(smtpHost = "").isValid) + } + + @Test + fun `ManualSetupForm value semantics`() { + val form = ManualSetupForm( + email = "a@b.org", + password = "pw", + imapHost = "imap", + imapPort = "143", + imapSecurity = MailSecurity.STARTTLS, + smtpHost = "smtp", + smtpPort = "587", + smtpSecurity = MailSecurity.NONE, + advancedExpanded = true, + status = SetupStatus.DONE, + error = "boom", + addedAccountId = "imap:a@b.org", + ) + + assertEquals(form, form.copy()) + assertEquals(form.hashCode(), form.copy().hashCode()) + assertTrue(form.toString().contains("a@b.org")) + assertNotEquals(form, form.copy(status = SetupStatus.IDLE)) + assertNotEquals(form, form.copy(advancedExpanded = false)) + assertNotEquals(form, form.copy(addedAccountId = null)) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelExtraTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelExtraTest.kt new file mode 100644 index 0000000..e8ecaa0 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelExtraTest.kt @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose + +import androidx.lifecycle.SavedStateHandle +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.contacts.ContactSuggestion +import org.libremail.contacts.ContactsRepository +import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +import org.libremail.data.settings.SignatureRepository +import org.libremail.domain.model.Account +import org.libremail.domain.model.AccountSettings +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.OutgoingAttachment +import org.libremail.domain.model.ServerConfig +import org.libremail.domain.repository.AccountRepository +import org.libremail.domain.repository.MailRepository +import org.libremail.ui.navigation.Routes +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Complements [ComposeViewModelTest] with the surface it leaves uncovered: recipient autocomplete, + * inline-image tracking/pruning, the trySend guard branches, a send failure, and flushDraft. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ComposeViewModelExtraTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + private val alice = Account( + id = "imap:a", + email = "alice@example.org", + displayName = "Alice", + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), + ) + + private fun viewModel( + accounts: List = listOf(alice), + savedState: SavedStateHandle = SavedStateHandle(), + mailRepository: MailRepository = mockk(relaxed = true), + contactsRepository: ContactsRepository = mockk(relaxed = true), + ): ComposeViewModel { + val accountRepository = mockk() + every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts) + val accountSettingsRepository = mockk() + coEvery { accountSettingsRepository.get(any()) } answers { AccountSettings(firstArg()) } + val signatureRepository = mockk(relaxed = true) + coEvery { signatureRepository.getDefault(any()) } returns null + val settingsRepository = mockk() + every { settingsRepository.settings } returns flowOf(AppSettings()) + coEvery { settingsRepository.setLastFont(any(), any()) } just Runs + return ComposeViewModel( + savedStateHandle = savedState, + mailRepository = mailRepository, + accountRepository = accountRepository, + contactsRepository = contactsRepository, + accountSettingsRepository = accountSettingsRepository, + signatureRepository = signatureRepository, + settingsRepository = settingsRepository, + ) + } + + // --- recipient autocomplete ------------------------------------------------------------------ + + @Test + fun `typing a recipient with contacts allowed surfaces suggestions`() = runTest(dispatcher) { + val contacts = mockk() + coEvery { contacts.search("bo") } returns listOf(ContactSuggestion("Bob", "bob@example.org")) + val vm = viewModel(contactsRepository = contacts) + advanceUntilIdle() + + vm.onContactsPermission(granted = true) + vm.onToChange("bo") + advanceUntilIdle() + + assertEquals(listOf(ContactSuggestion("Bob", "bob@example.org")), vm.state.value.suggestions) + } + + @Test + fun `a too-short token clears any standing suggestions and never searches`() = runTest(dispatcher) { + val contacts = mockk(relaxed = true) + val vm = viewModel(contactsRepository = contacts) + advanceUntilIdle() + vm.onContactsPermission(granted = true) + + vm.onToChange("b") + advanceUntilIdle() + + assertTrue(vm.state.value.suggestions.isEmpty()) + coVerify(exactly = 0) { contacts.search(any()) } + } + + @Test + fun `no suggestions are searched while contacts permission is withheld`() = runTest(dispatcher) { + val contacts = mockk(relaxed = true) + val vm = viewModel(contactsRepository = contacts) + advanceUntilIdle() + + vm.onToChange("bob") + advanceUntilIdle() + + coVerify(exactly = 0) { contacts.search(any()) } + } + + @Test + fun `picking a suggestion replaces the trailing token and appends after earlier recipients`() = + runTest(dispatcher) { + val vm = viewModel() + advanceUntilIdle() + + vm.pickSuggestion(ContactSuggestion("Bob", "bob@example.org")) + assertEquals("bob@example.org", vm.state.value.to) + + vm.onToChange("bob@example.org, ca") + vm.pickSuggestion(ContactSuggestion("Carol", "carol@example.org")) + assertEquals("bob@example.org, carol@example.org", vm.state.value.to) + assertTrue(vm.state.value.suggestions.isEmpty()) + } + + // --- inline images --------------------------------------------------------------------------- + + @Test + fun `a picked inline image is tracked and handed to the editor, then kept while pending`() = runTest(dispatcher) { + val vm = viewModel() + advanceUntilIdle() + + vm.onImagePicked("content://img/1", "pic.png") + val inline = vm.state.value.attachments.single { it.isInline } + assertEquals(inline.contentId, vm.state.value.pendingInlineImage?.contentId) + + // A body edit that doesn't yet reference the cid keeps the image while it's still pending. + vm.onBodyChange("typing", null) + assertTrue(vm.state.value.attachments.any { it.contentId == inline.contentId }) + } + + @Test + fun `an inline image is pruned once inserted and no longer referenced by the body`() = runTest(dispatcher) { + val vm = viewModel() + advanceUntilIdle() + vm.onImagePicked("content://img/1", "pic.png") + val cid = vm.state.value.attachments.single { it.isInline }.contentId + + // The editor confirms insertion; a later body without the cid drops the image. + vm.onInlineImageInserted() + assertNull(vm.state.value.pendingInlineImage) + vm.onBodyChange("no image here", null) + + assertTrue(vm.state.value.attachments.none { it.contentId == cid }) + } + + @Test + fun `an inline image is kept while the body still references its cid`() = runTest(dispatcher) { + val vm = viewModel() + advanceUntilIdle() + vm.onImagePicked("content://img/1", "pic.png") + val cid = vm.state.value.attachments.single { it.isInline }.contentId + vm.onInlineImageInserted() + + vm.onBodyChange("look", "

") + + assertTrue(vm.state.value.attachments.any { it.contentId == cid }) + } + + // --- attachment/error helpers ---------------------------------------------------------------- + + @Test + fun `removeAttachment drops the matching uri and consumeError clears the error`() = runTest(dispatcher) { + val vm = viewModel() + advanceUntilIdle() + vm.addAttachments( + listOf( + OutgoingAttachment("content://a", "a.pdf"), + OutgoingAttachment("content://b", "b.pdf"), + ), + ) + + vm.removeAttachment("content://a") + assertEquals(listOf("content://b"), vm.state.value.attachments.map { it.uri }) + + vm.onCcChange("cc@example.org") + vm.onBccChange("bcc@example.org") + assertEquals("cc@example.org", vm.state.value.cc) + assertEquals("bcc@example.org", vm.state.value.bcc) + + vm.send() // no recipient -> sets an error + assertNotEquals(null, vm.state.value.error) + vm.consumeError() + assertNull(vm.state.value.error) + } + + // --- send guards & failure ------------------------------------------------------------------- + + @Test + fun `send refuses without an account`() = runTest(dispatcher) { + // Draft route so init doesn't wait forever for a (never-arriving) first account. + val mailRepository = mockk(relaxed = true) + coEvery { mailRepository.getDraft("d1") } returns null + val vm = viewModel( + accounts = emptyList(), + savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1")), + mailRepository = mailRepository, + ) + advanceUntilIdle() + + vm.onToChange("bob@example.org") + vm.send() + + assertEquals("Add an account first", vm.state.value.error) + coVerify(exactly = 0) { mailRepository.sendMessage(any()) } + } + + @Test + fun `send refuses without a recipient`() = runTest(dispatcher) { + val mailRepository = mockk(relaxed = true) + val vm = viewModel(mailRepository = mailRepository) + advanceUntilIdle() + + vm.send() + + assertEquals("Add a recipient", vm.state.value.error) + coVerify(exactly = 0) { mailRepository.sendMessage(any()) } + } + + @Test + fun `a send failure surfaces the error and clears the sending flag`() = runTest(dispatcher) { + val mailRepository = mockk(relaxed = true) + coEvery { mailRepository.sendMessage(any()) } returns Result.failure(RuntimeException("SMTP said no")) + val vm = viewModel(mailRepository = mailRepository) + advanceUntilIdle() + + vm.onToChange("bob@example.org") + vm.onBodyChange("Hello", null) + vm.send() + advanceUntilIdle() + + assertEquals("SMTP said no", vm.state.value.error) + assertFalse(vm.state.value.sending) + } + + @Test + fun `flushDraft persists the in-progress draft immediately`() = runTest(dispatcher) { + val mailRepository = mockk(relaxed = true) + val vm = viewModel(mailRepository = mailRepository) + advanceUntilIdle() + + vm.onToChange("bob@example.org") + vm.flushDraft() + advanceUntilIdle() + + coVerify(atLeast = 1) { mailRepository.saveDraft(any()) } + } + + @Test + fun `emptying a resumed draft deletes it on exit`() = runTest(dispatcher) { + val mailRepository = mockk(relaxed = true) + coEvery { mailRepository.getDraft("d1") } returns org.libremail.domain.model.Draft( + id = "d1", + accountId = "imap:a", + to = "x@example.org", + cc = "", + subject = "Hi", + body = "Body", + updatedAt = 0L, + ) + val vm = viewModel( + savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1")), + mailRepository = mailRepository, + ) + advanceUntilIdle() + + // Blank every field, then leave: the persisted row is removed rather than left as an empty orphan. + vm.onToChange("") + vm.onSubjectChange("") + vm.onBodyChange("", null) + vm.onExit() + advanceUntilIdle() + + coVerify { mailRepository.deleteDraft("d1") } + } + + @Test + fun `ComposeUiState and PendingInlineImage carry value semantics`() { + val state = ComposeUiState( + to = "a@b.org", + subject = "s", + body = "b", + bodyHtml = "

b

", + fromAccountId = "imap:a", + sending = true, + error = "e", + showAttachmentPrompt = true, + ) + assertEquals(state, state.copy()) + assertEquals(state.hashCode(), state.copy().hashCode()) + assertTrue(state.toString().contains("a@b.org")) + assertNotEquals(state, state.copy(sending = false)) + assertNotEquals(state, state.copy(error = null)) + + val pending = PendingInlineImage("cid-1", "pic.png") + val (contentId, name) = pending + assertEquals("cid-1", contentId) + assertEquals("pic.png", name) + assertEquals(pending, pending.copy()) + assertEquals(pending.hashCode(), pending.copy().hashCode()) + assertTrue(pending.toString().contains("cid-1")) + assertNotEquals(pending, pending.copy(name = "other.png")) + assertNotEquals(pending, pending.copy(contentId = "cid-2")) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/compose/IntentComposeParserTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/IntentComposeParserTest.kt new file mode 100644 index 0000000..06e6b06 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/compose/IntentComposeParserTest.kt @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose + +import android.content.Intent +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * [IntentComposeParser] turns inbound `mailto:` and share intents into a [ComposePrefill]. The + * `mailto:` parsing itself is [MailtoParser]'s job (covered in [MailtoParserTest]); these lock in the + * action routing, the URI-vs-extras precedence, and the `String[]`/`String` address-extra handling. + */ +class IntentComposeParserTest { + + private fun intent( + action: String?, + data: String? = null, + email: Array? = null, + emailSingle: String? = null, + cc: Array? = null, + bcc: Array? = null, + subject: String? = null, + text: CharSequence? = null, + ): Intent = mockk { + every { this@mockk.action } returns action + every { dataString } returns data + every { getStringArrayExtra(any()) } returns null + every { getStringExtra(any()) } returns null + every { getCharSequenceExtra(any()) } returns null + email?.let { every { getStringArrayExtra(Intent.EXTRA_EMAIL) } returns it } + emailSingle?.let { every { getStringExtra(Intent.EXTRA_EMAIL) } returns it } + cc?.let { every { getStringArrayExtra(Intent.EXTRA_CC) } returns it } + bcc?.let { every { getStringArrayExtra(Intent.EXTRA_BCC) } returns it } + subject?.let { every { getStringExtra(Intent.EXTRA_SUBJECT) } returns it } + text?.let { every { getCharSequenceExtra(Intent.EXTRA_TEXT) } returns it } + } + + @Test + fun `a null intent parses to nothing`() { + assertNull(IntentComposeParser.parse(null)) + } + + @Test + fun `an unrelated action parses to nothing`() { + assertNull(IntentComposeParser.parse(intent(Intent.ACTION_MAIN))) + } + + @Test + fun `a mailto VIEW intent is parsed and its fields win over the extras`() { + val prefill = IntentComposeParser.parse( + intent( + Intent.ACTION_VIEW, + data = "mailto:alice@example.org?subject=From%20URI", + subject = "From Extra", + ), + ) + + assertEquals("alice@example.org", prefill?.to) + // The URI's subject takes precedence; the extra only fills genuinely-blank fields. + assertEquals("From URI", prefill?.subject) + } + + @Test + fun `a mailto SENDTO intent fills blank fields from the email extras`() { + val prefill = IntentComposeParser.parse( + intent( + Intent.ACTION_SENDTO, + data = "mailto:alice@example.org", + subject = "Subject From Extra", + text = "Body from extra", + ), + ) + + assertEquals("alice@example.org", prefill?.to) + assertEquals("Subject From Extra", prefill?.subject) + assertEquals("Body from extra", prefill?.body) + } + + @Test + fun `a VIEW intent without a mailto URI is not a mail intent`() { + assertNull(IntentComposeParser.parse(intent(Intent.ACTION_VIEW, data = "https://example.org"))) + assertNull(IntentComposeParser.parse(intent(Intent.ACTION_VIEW, data = null))) + } + + @Test + fun `a SEND share reads the standard email extras, joining an address array`() { + val prefill = IntentComposeParser.parse( + intent( + Intent.ACTION_SEND, + email = arrayOf("a@example.org", " ", "b@example.org"), + cc = arrayOf("c@example.org"), + bcc = arrayOf("d@example.org"), + subject = "Shared", + text = "Shared body", + ), + ) + + // Blank entries in the address array are dropped; the rest are comma-joined. + assertEquals("a@example.org, b@example.org", prefill?.to) + assertEquals("c@example.org", prefill?.cc) + assertEquals("d@example.org", prefill?.bcc) + assertEquals("Shared", prefill?.subject) + assertEquals("Shared body", prefill?.body) + } + + @Test + fun `a SEND share tolerates a single-string email extra`() { + val prefill = IntentComposeParser.parse( + intent(Intent.ACTION_SEND, emailSingle = "solo@example.org", subject = "Hi"), + ) + + assertEquals("solo@example.org", prefill?.to) + assertEquals("Hi", prefill?.subject) + } + + @Test + fun `a SEND share that also carries a mailto URI honours the URI first`() { + val prefill = IntentComposeParser.parse( + intent( + Intent.ACTION_SEND, + data = "mailto:from-uri@example.org", + email = arrayOf("from-extra@example.org"), + text = "body", + ), + ) + + assertEquals("from-uri@example.org", prefill?.to) + assertEquals("body", prefill?.body) + } + + @Test + fun `a SEND_MULTIPLE with nothing to compose parses to nothing`() { + assertNull(IntentComposeParser.parse(intent(Intent.ACTION_SEND_MULTIPLE))) + } + + @Test + fun `ComposePrefill reports emptiness and carries value semantics`() { + assertTrue(ComposePrefill().isEmpty) + assertTrue(ComposePrefill(subject = " ").isEmpty) + val full = ComposePrefill(to = "a@b.org", cc = "c@b.org", bcc = "d@b.org", subject = "s", body = "b") + assertTrue(!full.isEmpty) + assertEquals(full, full.copy()) + assertEquals(full.hashCode(), full.copy().hashCode()) + assertTrue(full.toString().contains("a@b.org")) + assertNotEquals(full, full.copy(to = "x@b.org")) + assertNotEquals(full, full.copy(body = "")) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/drafts/DraftsViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/drafts/DraftsViewModelTest.kt new file mode 100644 index 0000000..2a8cce7 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/drafts/DraftsViewModelTest.kt @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.drafts + +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.Draft +import org.libremail.domain.repository.MailRepository +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class DraftsViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun draft(id: String) = Draft( + id = id, + accountId = "imap:a", + to = "to@example.org", + cc = "", + subject = "Subject", + body = "Body", + updatedAt = 1L, + ) + + @Test + fun `drafts mirrors the repository's draft stream once collected`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + every { repo.observeDrafts() } returns MutableStateFlow(listOf(draft("d1"), draft("d2"))) + val vm = DraftsViewModel(repo) + + backgroundScope.launch { vm.drafts.collect {} } + runCurrent() + + assertEquals(listOf("d1", "d2"), vm.drafts.value.map { it.id }) + } + + @Test + fun `drafts starts empty before the stream emits`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + every { repo.observeDrafts() } returns flowOf(emptyList()) + val vm = DraftsViewModel(repo) + + assertEquals(emptyList(), vm.drafts.value) + } + + @Test + fun `deleteDraft delegates to the repository`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + every { repo.observeDrafts() } returns flowOf(emptyList()) + val vm = DraftsViewModel(repo) + + vm.deleteDraft("d9") + + coVerify { repo.deleteDraft("d9") } + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/lock/AppLockUiStateTest.kt b/app/src/test/kotlin/org/libremail/ui/lock/AppLockUiStateTest.kt new file mode 100644 index 0000000..24d7907 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/lock/AppLockUiStateTest.kt @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.lock + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Value semantics for the [AppLockUiState] holder. The [AppLockViewModel] behaviour that produces + * these states is covered exhaustively in [AppLockViewModelTest]; this pins the sealed hierarchy's own + * equality/nonce contract (the nonce is what lets two identical-error locks still update the UI). + */ +class AppLockUiStateTest { + + @Test + fun `Locked carries value semantics including its distinguishing nonce`() { + val locked = AppLockUiState.Locked(error = "boom", nonce = 3) + + val (error, nonce) = locked + assertEquals("boom", error) + assertEquals(3, nonce) + assertEquals(locked, locked.copy()) + assertEquals(locked.hashCode(), locked.copy().hashCode()) + assertTrue(locked.toString().contains("boom")) + assertNotEquals(locked, locked.copy(error = null)) + // Two locks with the same error but a bumped nonce are distinct, so a retry still re-renders. + assertNotEquals(locked, locked.copy(nonce = 4)) + assertNotEquals(locked, AppLockUiState.Unlocked) + } + + @Test + fun `Locked defaults to no error and a zero nonce`() { + assertEquals(AppLockUiState.Locked(), AppLockUiState.Locked(error = null, nonce = 0)) + } + + @Test + fun `the transient states are singletons`() { + assertSame(AppLockUiState.Checking, AppLockUiState.Checking) + assertSame(AppLockUiState.Unlocked, AppLockUiState.Unlocked) + assertNotEquals(AppLockUiState.Checking, AppLockUiState.Unlocked) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelExtraTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelExtraTest.kt new file mode 100644 index 0000000..e59d22a --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelExtraTest.kt @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.mailbox + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.data.sync.Syncer +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ReplyMode +import org.libremail.domain.model.ServerConfig +import org.libremail.domain.repository.AccountRepository +import org.libremail.domain.repository.MailRepository +import org.libremail.ui.navigation.Routes +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Covers the parts of [MailboxViewModel] the behaviour-focused [MailboxViewModelTest] leaves out: + * pull-to-refresh, search open/close, the drawer-refresh hooks, the failure branches of the selection + * actions, and the account-removal fallback. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MailboxViewModelExtraTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + private val alice = account("imap:a", "alice@example.org") + private val bob = account("imap:b", "bob@example.org") + + private class Fixture( + val vm: MailboxViewModel, + val repo: MailRepository, + val syncer: Syncer, + val accountsFlow: MutableStateFlow>, + ) + + private fun fixture( + accounts: List = listOf(alice), + initialAccountId: String? = null, + repo: MailRepository = mockk(relaxed = true), + syncer: Syncer = mockk(relaxed = true), + ): Fixture { + every { repo.observeDrafts() } returns flowOf(emptyList()) + every { repo.observeOutbox() } returns flowOf(emptyList()) + every { repo.observeUnreadCounts() } returns flowOf(emptyList()) + every { repo.observeFolders(any()) } returns flowOf(emptyList()) + coEvery { syncer.syncAll() } returns Result.success(0) + coEvery { syncer.syncFolder(any(), any()) } returns Result.success(0) + val accountsFlow = MutableStateFlow(accounts) + val accountRepository = mockk(relaxed = true) + every { accountRepository.observeAccounts() } returns accountsFlow + val savedState = initialAccountId?.let { SavedStateHandle(mapOf(Routes.MAILBOX_ARG_ACCOUNT to it)) } + ?: SavedStateHandle() + return Fixture(MailboxViewModel(repo, accountRepository, syncer, savedState), repo, syncer, accountsFlow) + } + + @Test + fun `pull-to-refresh on the unified inbox syncs every account and toggles the spinner`() = runTest(dispatcher) { + val f = fixture() + val gate = CompletableDeferred>() + coEvery { f.syncer.syncAll() } coAnswers { gate.await() } // override the fixture's default + + f.vm.isRefreshing.test { + assertFalse(awaitItem()) + f.vm.refresh() + assertTrue(awaitItem()) // spinner up while the gated sync runs + gate.complete(Result.success(0)) + assertFalse(awaitItem()) + } + coVerify(exactly = 1) { f.syncer.syncAll() } + } + + @Test + fun `pull-to-refresh on a specific account syncs just that folder`() = runTest(dispatcher) { + val f = fixture() + f.vm.selectAccount("imap:a") + runCurrent() + + f.vm.refresh() + advanceUntilIdle() + + coVerify { f.syncer.syncFolder("imap:a", "INBOX") } + } + + @Test + fun `a refresh already in flight is not started again`() = runTest(dispatcher) { + val f = fixture() + val gate = CompletableDeferred>() + coEvery { f.syncer.syncAll() } coAnswers { gate.await() } + + f.vm.refresh() // in flight, suspended on the gate + f.vm.refresh() // guarded out + gate.complete(Result.success(0)) + advanceUntilIdle() + + coVerify(exactly = 1) { f.syncer.syncAll() } + } + + @Test + fun `a failed refresh surfaces the error, then consumeError clears it`() = runTest(dispatcher) { + val f = fixture() + coEvery { f.syncer.syncAll() } returns Result.failure(IllegalStateException("no network")) + + f.vm.refresh() + advanceUntilIdle() + assertEquals("no network", f.vm.error.value) + + f.vm.consumeError() + assertNull(f.vm.error.value) + } + + @Test + fun `opening search clears any selection and closing it wipes the query and server hits`() = runTest(dispatcher) { + val f = fixture() + f.vm.startSelection("imap:a:INBOX:1", "imap:a") + + f.vm.openSearch() + assertTrue(f.vm.searchActive.value) + assertTrue(f.vm.selectedIds.value.isEmpty()) + + f.vm.onSearchQuery("hello") + f.vm.closeSearch() + advanceUntilIdle() + + assertFalse(f.vm.searchActive.value) + assertEquals("", f.vm.searchQuery.value) + coVerify { f.repo.clearSearchResults() } + } + + @Test + fun `a debounced search query fetches server-side matches for the current folder`() = runTest(dispatcher) { + val f = fixture() + + f.vm.onSearchQuery("meeting") + advanceUntilIdle() // cross the search debounce window + + coVerify { f.repo.searchServer("meeting", null, "INBOX") } + } + + @Test + fun `opening the drawer refreshes the current drawer account's folders`() = runTest(dispatcher) { + val f = fixture() + backgroundScope.launch { f.vm.drawerAccount.collect {} } + runCurrent() + + f.vm.onDrawerOpened() + advanceUntilIdle() + + coVerify { f.repo.refreshFolders("imap:a") } + } + + @Test + fun `setDrawerAccount points the drawer elsewhere and refreshes those folders`() = runTest(dispatcher) { + val f = fixture(accounts = listOf(alice, bob)) + + f.vm.setDrawerAccount("imap:b") + advanceUntilIdle() + + coVerify { f.repo.refreshFolders("imap:b") } + } + + @Test + fun `a failed selection action surfaces the error and exits selection`() = runTest(dispatcher) { + val f = fixture() + coEvery { f.repo.archive(any()) } returns Result.failure(RuntimeException("IMAP move failed")) + f.vm.startSelection("imap:a:INBOX:1", "imap:a") + + f.vm.archiveSelected() + advanceUntilIdle() + + assertEquals("IMAP move failed", f.vm.error.value) + assertTrue(f.vm.selectedIds.value.isEmpty()) + } + + @Test + fun `a failed reply build surfaces the error`() = runTest(dispatcher) { + val f = fixture() + coEvery { f.repo.buildReplyDraft(any(), any()) } returns Result.failure(RuntimeException("draft failed")) + f.vm.startSelection("imap:a:INBOX:1", "imap:a") + + f.vm.reply(ReplyMode.FORWARD) + advanceUntilIdle() + + assertEquals("draft failed", f.vm.error.value) + assertFalse(f.vm.actionInProgress.value) + } + + @Test + fun `selection actions no-op without a selection`() = runTest(dispatcher) { + val f = fixture() + + f.vm.requestSpam() + f.vm.requestDelete() + f.vm.requestReplyAll() + f.vm.reply(ReplyMode.REPLY) + + assertNull(f.vm.pendingConfirm.value) + coVerify(exactly = 0) { f.repo.buildReplyDraft(any(), any()) } + } + + @Test + fun `removing the filtered account falls back to the unified inbox`() = runTest(dispatcher) { + val f = fixture(accounts = listOf(alice, bob), initialAccountId = "imap:b") + backgroundScope.launch { f.vm.accounts.collect {} } + runCurrent() + assertEquals("imap:b", f.vm.selectedAccountId.value) + + // Bob's account is deleted; the filter must fall back rather than point at a gone account. + f.accountsFlow.value = listOf(alice) + runCurrent() + + assertNull(f.vm.selectedAccountId.value) + assertEquals("INBOX", f.vm.selectedFolder.value) + } + + @Test + fun `draft and outbox counts follow their streams`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + every { repo.observeDrafts() } returns flowOf(listOf(mockk(), mockk())) + every { repo.observeOutbox() } returns flowOf(listOf(mockk())) + every { repo.observeUnreadCounts() } returns flowOf(emptyList()) + every { repo.observeFolders(any()) } returns flowOf(emptyList()) + val accountRepository = mockk(relaxed = true) + every { accountRepository.observeAccounts() } returns MutableStateFlow(listOf(alice)) + val vm = MailboxViewModel(repo, accountRepository, mockk(relaxed = true), SavedStateHandle()) + + backgroundScope.launch { vm.draftCount.collect {} } + backgroundScope.launch { vm.outboxCount.collect {} } + backgroundScope.launch { vm.hasAccounts.collect {} } + runCurrent() + + assertEquals(2, vm.draftCount.value) + assertEquals(1, vm.outboxCount.value) + assertTrue(vm.hasAccounts.value) + } + + @Test + fun `mailbox events and pending actions carry value semantics`() { + val open = MailboxEvent.OpenCompose("draft-1") + val (draftId) = open + assertEquals("draft-1", draftId) + assertEquals(open, open.copy()) + assertEquals(open.hashCode(), open.copy().hashCode()) + assertTrue(open.toString().contains("draft-1")) + assertNotEquals(open, MailboxEvent.OpenCompose("draft-2")) + + val delete = PendingAction.Delete(count = 3, permanent = true) + val (count, permanent) = delete + assertEquals(3, count) + assertTrue(permanent) + assertEquals(delete, delete.copy()) + assertEquals(delete.hashCode(), delete.copy().hashCode()) + assertNotEquals(delete, delete.copy(permanent = false)) + assertNotEquals(delete, delete.copy(count = 4)) + assertNotEquals(delete, PendingAction.Spam(3)) + + val spam = PendingAction.Spam(2) + assertEquals(2, spam.count) + assertEquals(spam, spam.copy()) + assertNotEquals(spam, spam.copy(count = 5)) + assertTrue(spam.toString().contains("2")) + + val replyAll = PendingAction.ReplyAll("m1") + assertEquals("m1", replyAll.messageId) + assertEquals(replyAll, replyAll.copy()) + assertNotEquals(replyAll, PendingAction.ReplyAll("m2")) + assertTrue(replyAll.toString().contains("m1")) + } + + private fun account(id: String, email: String) = Account( + id = id, + email = email, + displayName = email, + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), + ) +} diff --git a/app/src/test/kotlin/org/libremail/ui/outbox/OutboxViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/outbox/OutboxViewModelTest.kt new file mode 100644 index 0000000..940d9e9 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/outbox/OutboxViewModelTest.kt @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.outbox + +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.OutboxMessage +import org.libremail.domain.repository.MailRepository +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class OutboxViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun message(id: String) = OutboxMessage( + id = id, + to = "to@example.org", + subject = "Subject", + body = "Body", + createdAt = 1L, + lastError = null, + ) + + @Test + fun `messages mirrors the repository's outbox stream once collected`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + every { repo.observeOutbox() } returns MutableStateFlow(listOf(message("m1"), message("m2"))) + val vm = OutboxViewModel(repo) + + backgroundScope.launch { vm.messages.collect {} } + runCurrent() + + assertEquals(listOf("m1", "m2"), vm.messages.value.map { it.id }) + } + + @Test + fun `messages starts empty before the stream emits`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + every { repo.observeOutbox() } returns flowOf(emptyList()) + val vm = OutboxViewModel(repo) + + assertEquals(emptyList(), vm.messages.value) + } + + @Test + fun `cancel delegates to the repository`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + every { repo.observeOutbox() } returns flowOf(emptyList()) + val vm = OutboxViewModel(repo) + + vm.cancel("m9") + + coVerify { repo.cancelOutboxMessage("m9") } + } + + @Test + fun `retry drains the outbox through the repository`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + every { repo.observeOutbox() } returns flowOf(emptyList()) + val vm = OutboxViewModel(repo) + + vm.retry() + + coVerify { repo.retryOutbox() } + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/reader/ReaderViewModelActionsTest.kt b/app/src/test/kotlin/org/libremail/ui/reader/ReaderViewModelActionsTest.kt new file mode 100644 index 0000000..013c914 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reader/ReaderViewModelActionsTest.kt @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reader + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +import org.libremail.domain.model.Attachment +import org.libremail.domain.model.Message +import org.libremail.domain.repository.MailRepository +import org.libremail.ui.navigation.Routes +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ReaderViewModelActionsTest { + + private val dispatcher = StandardTestDispatcher() + private val messageId = "acct:INBOX:1" + + private val message = Message( + id = messageId, accountId = "acct", sender = "A", senderEmail = "a@example.org", + subject = "s", snippet = "", body = "Body", isHtml = false, timestampMillis = 1, + isRead = true, isStarred = false, + ) + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun attachment(partIndex: Int) = + Attachment(messageId, partIndex, "file$partIndex.bin", "application/octet-stream", 10L) + + private fun viewModel(repo: MailRepository, loadRemoteImages: Boolean = false): ReaderViewModel { + val settings = mockk() + every { settings.settings } returns flowOf(AppSettings(loadRemoteImages = loadRemoteImages)) + return ReaderViewModel(SavedStateHandle(mapOf(Routes.READER_ARG_ID to messageId)), repo, settings) + } + + private fun loadedRepo(): MailRepository { + val repo = mockk(relaxed = true) + coEvery { repo.openMessage(messageId) } returns Result.success(message) + every { repo.observeAttachments(messageId) } returns flowOf(listOf(attachment(0))) + coEvery { repo.downloadedAttachmentParts(messageId) } returns emptySet() + return repo + } + + @Test + fun `the global load-remote-images default is honoured on open`() = runTest(dispatcher) { + val vm = viewModel(loadedRepo(), loadRemoteImages = true) + advanceUntilIdle() + + assertTrue(vm.state.value.loadRemoteImages) + } + + @Test + fun `loadRemoteImages opts this message in on demand`() = runTest(dispatcher) { + val vm = viewModel(loadedRepo(), loadRemoteImages = false) + advanceUntilIdle() + assertFalse(vm.state.value.loadRemoteImages) + + vm.loadRemoteImages() + + assertTrue(vm.state.value.loadRemoteImages) + } + + @Test + fun `a failed open surfaces the error and stops loading`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.openMessage(messageId) } returns Result.failure(IllegalStateException("network down")) + every { repo.observeAttachments(messageId) } returns flowOf(emptyList()) + coEvery { repo.downloadedAttachmentParts(messageId) } returns emptySet() + + val vm = viewModel(repo) + advanceUntilIdle() + + assertEquals("network down", vm.state.value.error) + assertFalse(vm.state.value.loading) + assertNull(vm.state.value.message) + } + + @Test + fun `a successful download opens the file and clears the in-flight marker`() = runTest(dispatcher) { + val repo = loadedRepo() + val cached = File("cached.bin") + coEvery { repo.downloadAttachment(messageId, 0) } returns Result.success(cached) + val vm = viewModel(repo) + advanceUntilIdle() + + vm.events.test { + vm.downloadAttachment(attachment(0)) + advanceUntilIdle() + + val event = awaitItem() + assertTrue(event is ReaderEvent.OpenFile && event.file == cached) + assertTrue(0 in vm.state.value.downloaded) + assertFalse(0 in vm.state.value.downloading) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a failed download emits DownloadFailed and clears the in-flight marker`() = runTest(dispatcher) { + val repo = loadedRepo() + coEvery { repo.downloadAttachment(messageId, 0) } returns Result.failure(RuntimeException("boom")) + val vm = viewModel(repo) + advanceUntilIdle() + + vm.events.test { + vm.downloadAttachment(attachment(0)) + advanceUntilIdle() + + assertEquals(ReaderEvent.DownloadFailed("file0.bin"), awaitItem()) + assertFalse(0 in vm.state.value.downloading) + assertFalse(0 in vm.state.value.downloaded) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a second download of an in-flight attachment is ignored`() = runTest(dispatcher) { + val repo = loadedRepo() + coEvery { repo.downloadAttachment(messageId, 0) } returns Result.success(File("cached.bin")) + val vm = viewModel(repo) + advanceUntilIdle() + + // The first call marks the part in-flight synchronously; the second sees it and bails out. + vm.downloadAttachment(attachment(0)) + vm.downloadAttachment(attachment(0)) + advanceUntilIdle() + + coVerify(exactly = 1) { repo.downloadAttachment(messageId, 0) } + } + + @Test + fun `toggleStar flips the star optimistically and persists it`() = runTest(dispatcher) { + val repo = loadedRepo() + val vm = viewModel(repo) + advanceUntilIdle() + + vm.toggleStar() + advanceUntilIdle() + + assertTrue(vm.state.value.message!!.isStarred) + coVerify { repo.setStarred(messageId, true) } + } + + @Test + fun `toggleStar does nothing before the message has loaded`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.openMessage(messageId) } returns Result.failure(IllegalStateException("x")) + every { repo.observeAttachments(messageId) } returns flowOf(emptyList()) + coEvery { repo.downloadedAttachmentParts(messageId) } returns emptySet() + val vm = viewModel(repo) + advanceUntilIdle() + + vm.toggleStar() + advanceUntilIdle() + + coVerify(exactly = 0) { repo.setStarred(any(), any()) } + } + + @Test + fun `delete removes the message and marks the state deleted`() = runTest(dispatcher) { + val repo = loadedRepo() + val vm = viewModel(repo) + advanceUntilIdle() + + vm.delete() + advanceUntilIdle() + + assertTrue(vm.state.value.deleted) + coVerify { repo.deleteMessage(messageId) } + } + + @Test + fun `ReaderUiState and its events carry value semantics`() { + val state = ReaderUiState( + loading = false, + message = message, + attachments = listOf(attachment(0)), + downloading = setOf(1), + downloaded = setOf(0), + loadRemoteImages = true, + deleted = true, + error = "e", + ) + assertEquals(state, state.copy()) + assertEquals(state.hashCode(), state.copy().hashCode()) + assertTrue(state.toString().contains("error")) + assertNotEquals(state, state.copy(loading = true)) + assertNotEquals(state, state.copy(error = null)) + + val open = ReaderEvent.OpenFile(File("a"), "text/plain", "a.txt") + val (file, mimeType, name) = open + assertEquals(File("a"), file) + assertEquals("text/plain", mimeType) + assertEquals("a.txt", name) + assertEquals(open, open.copy()) + assertEquals(open.hashCode(), open.copy().hashCode()) + assertTrue(open.toString().contains("a.txt")) + assertNotEquals(open, open.copy(name = "b.txt")) + assertNotEquals(open, ReaderEvent.DownloadFailed("a.txt")) + + val failed = ReaderEvent.DownloadFailed("x") + assertEquals("x", failed.name) + assertEquals(failed, failed.copy()) + assertNotEquals(failed, failed.copy(name = "y")) + assertTrue(failed.toString().contains("x")) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/reporting/ProblemReportsViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/reporting/ProblemReportsViewModelTest.kt new file mode 100644 index 0000000..9356947 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reporting/ProblemReportsViewModelTest.kt @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import app.cash.turbine.test +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.reporting.DebugReport +import org.libremail.reporting.DiagnosticsCollector +import org.libremail.reporting.ReportKind +import org.libremail.reporting.ReportStore +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class ProblemReportsViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun report(id: String, kind: ReportKind = ReportKind.MANUAL, createdAt: Long = 1L) = DebugReport( + id = id, + createdAtMillis = createdAt, + kind = kind, + appVersionName = "0.1.0", + appVersionCode = 1, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel", + stackTrace = null, + settings = emptyMap(), + logs = emptyList(), + ) + + @Test + fun `reports projects each stored report to a summary row`() = runTest(dispatcher) { + val store = mockk(relaxed = true) + every { store.reports } returns + MutableStateFlow(listOf(report("a", ReportKind.CRASH, createdAt = 5L), report("b"))) + val vm = ProblemReportsViewModel(store, mockk(relaxed = true)) + + backgroundScope.launch { vm.reports.collect {} } + runCurrent() + + assertEquals( + listOf( + ReportSummary("a", ReportKind.CRASH, 5L), + ReportSummary("b", ReportKind.MANUAL, 1L), + ), + vm.reports.value, + ) + } + + @Test + fun `createManualReport collects, saves, and emits the new report id for immediate review`() = runTest(dispatcher) { + val store = mockk(relaxed = true) + every { store.reports } returns MutableStateFlow(emptyList()) + every { store.save(any()) } just Runs + val collector = mockk() + coEvery { collector.collectManual() } returns report("fresh") + val vm = ProblemReportsViewModel(store, collector) + + vm.created.test { + vm.createManualReport() + advanceUntilIdle() + + assertEquals("fresh", awaitItem()) + } + verify(exactly = 1) { store.save(match { it.id == "fresh" }) } + } + + @Test + fun `discard deletes the report from the store`() = runTest(dispatcher) { + val store = mockk(relaxed = true) + every { store.reports } returns MutableStateFlow(emptyList()) + every { store.delete(any()) } just Runs + val vm = ProblemReportsViewModel(store, mockk(relaxed = true)) + + vm.discard("gone") + advanceUntilIdle() + + verify(exactly = 1) { store.delete("gone") } + } + + @Test + fun `ReportSummary value semantics`() { + val summary = ReportSummary("id", ReportKind.CRASH, 7L) + + val (id, kind, createdAt) = summary + assertEquals("id", id) + assertEquals(ReportKind.CRASH, kind) + assertEquals(7L, createdAt) + assertEquals(summary, summary.copy()) + assertEquals(summary.hashCode(), summary.copy().hashCode()) + assertTrue(summary.toString().contains("id")) + assertNotEquals(summary, summary.copy(id = "other")) + assertNotEquals(summary, summary.copy(kind = ReportKind.MANUAL)) + assertNotEquals(summary, summary.copy(createdAtMillis = 8L)) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelStateTest.kt b/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelStateTest.kt new file mode 100644 index 0000000..2abcbb2 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reporting/ReportReviewViewModelStateTest.kt @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import androidx.lifecycle.SavedStateHandle +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.reporting.DebugReport +import org.libremail.reporting.ReportKind +import org.libremail.reporting.ReportStore +import org.libremail.reporting.ReportSubmitter +import org.libremail.reporting.SubmitStatus +import org.libremail.ui.navigation.Routes +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Drives [ReportReviewViewModel.state] (the combine flow) and the online-submit path — the parts the + * behaviour-focused [ReportReviewViewModelTest] leaves uncovered because it never collects `state` and + * only exercises the no-endpoint branch. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ReportReviewViewModelStateTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + private val report = DebugReport( + id = "rid", + createdAtMillis = 1L, + kind = ReportKind.CRASH, + appVersionName = "0.1.0", + appVersionCode = 1, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel", + stackTrace = "boom", + settings = emptyMap(), + logs = emptyList(), + userComment = "saved comment", + userEmail = "saved@example.com", + ) + + private val store = mockk(relaxed = true) + private val submitter = mockk(relaxed = true) + + private fun viewModel(reports: List = listOf(report)): ReportReviewViewModel { + every { store.reports } returns MutableStateFlow(reports) + every { store.find("rid") } returns reports.firstOrNull { it.id == "rid" } + return ReportReviewViewModel( + savedStateHandle = SavedStateHandle(mapOf(Routes.REPORT_REVIEW_ARG_ID to "rid")), + store = store, + submitter = submitter, + ) + } + + @Test + fun `state seeds from the stored report and folds edits into the preview`() = runTest(dispatcher) { + every { submitter.isEnabled } returns true + val vm = viewModel() + + backgroundScope.launch { vm.state.collect {} } + runCurrent() + val initial = vm.state.value + assertTrue(initial.loaded) + assertTrue(initial.exists) + assertEquals("saved comment", initial.comment) + assertEquals("saved@example.com", initial.email) + assertTrue(initial.canSubmitOnline) + assertTrue(initial.payload.contains("saved comment")) + + vm.updateComment("edited") + vm.updateEmail("edited@example.com") + runCurrent() + + val edited = vm.state.value + assertEquals("edited", edited.comment) + assertTrue(edited.payload.contains("edited@example.com")) + } + + @Test + fun `state marks a discarded report as no longer existing`() = runTest(dispatcher) { + val vm = viewModel(reports = emptyList()) + + backgroundScope.launch { vm.state.collect {} } + runCurrent() + + assertTrue(vm.state.value.loaded) + assertFalse(vm.state.value.exists) + assertEquals("", vm.state.value.payload) + } + + @Test + fun `an online submit tracks the worker through to success`() = runTest(dispatcher) { + every { submitter.isEnabled } returns true + every { store.save(any()) } just Runs + every { submitter.submit("rid") } just Runs + // Covers toUi for IDLE/SUBMITTING (both -> SUBMITTING) and SUCCEEDED. + every { submitter.status("rid") } returns + flowOf(SubmitStatus.IDLE, SubmitStatus.SUBMITTING, SubmitStatus.SUCCEEDED) + val vm = viewModel() + backgroundScope.launch { vm.state.collect {} } + vm.updateComment(VALID_COMMENT) + vm.updateEmail(VALID_EMAIL) + + vm.submit() + advanceUntilIdle() + + assertEquals(SubmitUiState.SUCCEEDED, vm.state.value.submit) + } + + @Test + fun `an online submit surfaces a worker failure`() = runTest(dispatcher) { + every { submitter.isEnabled } returns true + every { store.save(any()) } just Runs + every { submitter.submit("rid") } just Runs + every { submitter.status("rid") } returns flowOf(SubmitStatus.FAILED) + val vm = viewModel() + backgroundScope.launch { vm.state.collect {} } + vm.updateComment(VALID_COMMENT) + vm.updateEmail(VALID_EMAIL) + + vm.submit() + advanceUntilIdle() + + assertEquals(SubmitUiState.FAILED, vm.state.value.submit) + } + + @Test + fun `ReportReviewState carries value semantics`() { + val state = ReportReviewState( + loaded = true, + exists = true, + payload = "p", + comment = "c", + email = "e@x.org", + canSubmitOnline = true, + submit = SubmitUiState.SUBMITTING, + ) + assertEquals(state, state.copy()) + assertEquals(state.hashCode(), state.copy().hashCode()) + assertTrue(state.toString().contains("e@x.org")) + assertNotEquals(state, state.copy(loaded = false)) + assertNotEquals(state, state.copy(payload = "other")) + assertNotEquals(state, state.copy(submit = SubmitUiState.IDLE)) + assertEquals( + listOf("IDLE", "SUBMITTING", "SUCCEEDED", "FAILED", "UNAVAILABLE"), + SubmitUiState.entries.map { it.name }, + ) + } + + private companion object { + val VALID_COMMENT = "a".repeat(ReportSubmissionRules.MIN_COMMENT_LENGTH) + const val VALID_EMAIL = "reporter@example.com" + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/reporting/StartupReportViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/reporting/StartupReportViewModelTest.kt new file mode 100644 index 0000000..e10e73b --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/reporting/StartupReportViewModelTest.kt @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.reporting + +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.reporting.DebugReport +import org.libremail.reporting.ReportKind +import org.libremail.reporting.ReportStore +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@OptIn(ExperimentalCoroutinesApi::class) +class StartupReportViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun report(id: String, kind: ReportKind, createdAt: Long = 1L) = DebugReport( + id = id, + createdAtMillis = createdAt, + kind = kind, + appVersionName = "0.1.0", + appVersionCode = 1, + androidRelease = "14", + androidSdkInt = 34, + deviceManufacturer = "Google", + deviceModel = "Pixel", + stackTrace = null, + settings = emptyMap(), + logs = emptyList(), + ) + + @Test + fun `pendingCrash surfaces the first crash report`() = runTest(dispatcher) { + val store = mockk(relaxed = true) + every { store.reports } returns + MutableStateFlow(listOf(report("m", ReportKind.MANUAL), report("c", ReportKind.CRASH, createdAt = 9L))) + val vm = StartupReportViewModel(store) + + backgroundScope.launch { vm.pendingCrash.collect {} } + runCurrent() + + assertEquals(ReportSummary("c", ReportKind.CRASH, 9L), vm.pendingCrash.value) + } + + @Test + fun `pendingCrash is null when only manual reports exist`() = runTest(dispatcher) { + val store = mockk(relaxed = true) + every { store.reports } returns MutableStateFlow(listOf(report("m", ReportKind.MANUAL))) + val vm = StartupReportViewModel(store) + + backgroundScope.launch { vm.pendingCrash.collect {} } + runCurrent() + + assertNull(vm.pendingCrash.value) + } + + @Test + fun `dismiss hides the prompt for this launch without deleting the report`() = runTest(dispatcher) { + val store = mockk(relaxed = true) + every { store.reports } returns MutableStateFlow(listOf(report("c", ReportKind.CRASH))) + val vm = StartupReportViewModel(store) + + backgroundScope.launch { vm.pendingCrash.collect {} } + runCurrent() + vm.dismiss() + runCurrent() + + assertNull(vm.pendingCrash.value) + verify(exactly = 0) { store.delete(any()) } + } + + @Test + fun `discard hides the prompt and deletes the report`() = runTest(dispatcher) { + val store = mockk(relaxed = true) + every { store.reports } returns MutableStateFlow(listOf(report("c", ReportKind.CRASH))) + every { store.delete(any()) } just Runs + val vm = StartupReportViewModel(store) + + backgroundScope.launch { vm.pendingCrash.collect {} } + runCurrent() + vm.discard("c") + advanceUntilIdle() + + assertNull(vm.pendingCrash.value) + verify(exactly = 1) { store.delete("c") } + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/AccountSettingsViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/AccountSettingsViewModelTest.kt new file mode 100644 index 0000000..b71f509 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/AccountSettingsViewModelTest.kt @@ -0,0 +1,259 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import androidx.lifecycle.SavedStateHandle +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.SettingsRepository +import org.libremail.data.settings.SignatureRepository +import org.libremail.data.sync.SyncScheduler +import org.libremail.domain.model.Account +import org.libremail.domain.model.AccountSettings +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig +import org.libremail.domain.model.Signature +import org.libremail.domain.repository.AccountRepository +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class AccountSettingsViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private val account = Account( + id = ACCOUNT, + email = "me@example.org", + displayName = "Me", + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), + ) + + private fun signature(id: String, name: String, isDefault: Boolean) = + Signature(id = id, accountId = ACCOUNT, name = name, html = "

$name

", isDefault = isDefault) + + private fun viewModel( + accountRepository: AccountRepository = mockk(relaxed = true), + accountSettingsRepository: AccountSettingsRepository = mockk(relaxed = true), + signatureRepository: SignatureRepository = mockk(relaxed = true), + syncScheduler: SyncScheduler = mockk(relaxed = true), + settingsRepository: SettingsRepository = mockk(relaxed = true), + accounts: List = listOf(account), + settings: AccountSettings = AccountSettings(ACCOUNT), + appSettings: AppSettings = AppSettings(), + signatures: List = emptyList(), + ): AccountSettingsViewModel { + every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts) + every { accountSettingsRepository.observe(ACCOUNT) } returns MutableStateFlow(settings) + every { settingsRepository.settings } returns MutableStateFlow(appSettings) + every { signatureRepository.observeForAccount(ACCOUNT) } returns MutableStateFlow(signatures) + return AccountSettingsViewModel( + savedStateHandle = SavedStateHandle(mapOf("accountId" to ACCOUNT)), + accountRepository = accountRepository, + accountSettingsRepository = accountSettingsRepository, + signatureRepository = signatureRepository, + syncScheduler = syncScheduler, + settingsRepository = settingsRepository, + ) + } + + @Test + fun `account resolves the matching account from the list`() = runTest(dispatcher) { + val other = account.copy(id = "imap:other", email = "other@example.org") + val vm = viewModel(accounts = listOf(other, account)) + + backgroundScope.launch { vm.account.collect {} } + runCurrent() + + assertEquals(ACCOUNT, vm.account.value?.id) + } + + @Test + fun `settings and derived signature summary reflect the repositories`() = runTest(dispatcher) { + val vm = viewModel( + settings = AccountSettings(ACCOUNT, notificationsEnabled = false), + signatures = listOf( + signature("a", "First", isDefault = false), + signature("b", "Default", isDefault = true), + ), + ) + + backgroundScope.launch { vm.settings.collect {} } + backgroundScope.launch { vm.signatureCount.collect {} } + backgroundScope.launch { vm.defaultSignatureName.collect {} } + runCurrent() + + assertEquals(false, vm.settings.value.notificationsEnabled) + assertEquals(2, vm.signatureCount.value) + // The default signature wins the summary line. + assertEquals("Default", vm.defaultSignatureName.value) + } + + @Test + fun `defaultSignatureName falls back to the first signature when none is marked default`() = runTest(dispatcher) { + val vm = viewModel(signatures = listOf(signature("a", "OnlyOne", isDefault = false))) + + backgroundScope.launch { vm.defaultSignatureName.collect {} } + runCurrent() + + assertEquals("OnlyOne", vm.defaultSignatureName.value) + } + + @Test + fun `defaultSignatureName is blank when there are no signatures`() = runTest(dispatcher) { + val vm = viewModel(signatures = emptyList()) + + backgroundScope.launch { vm.defaultSignatureName.collect {} } + runCurrent() + + assertEquals("", vm.defaultSignatureName.value) + } + + @Test + fun `isDefaultAccount tracks whether this account holds the default preference`() = runTest(dispatcher) { + val vm = viewModel(appSettings = AppSettings(defaultAccountId = ACCOUNT)) + + backgroundScope.launch { vm.isDefaultAccount.collect {} } + runCurrent() + + assertEquals(true, vm.isDefaultAccount.value) + } + + @Test + fun `notificationChannelId is derived from the account id`() { + assertEquals("new_mail:$ACCOUNT", viewModel().notificationChannelId) + } + + @Test + fun `setSignatureEnabled and setNotificationsEnabled delegate to the settings repository`() = runTest(dispatcher) { + val settings = mockk(relaxed = true) + every { settings.observe(ACCOUNT) } returns MutableStateFlow(AccountSettings(ACCOUNT)) + val vm = viewModel(accountSettingsRepository = settings) + + vm.setSignatureEnabled(false) + vm.setNotificationsEnabled(false) + advanceUntilIdle() + + coVerify { settings.setSignatureEnabled(ACCOUNT, false) } + coVerify { settings.setNotificationsEnabled(ACCOUNT, false) } + } + + @Test + fun `setRetentionCount persists then prunes and resets backfill so the new cap takes effect`() = + runTest(dispatcher) { + val settings = mockk(relaxed = true) + every { settings.observe(ACCOUNT) } returns MutableStateFlow(AccountSettings(ACCOUNT)) + val accounts = mockk(relaxed = true) + every { accounts.observeAccounts() } returns MutableStateFlow(listOf(account)) + val syncScheduler = mockk(relaxed = true) + val vm = viewModel( + accountRepository = accounts, + accountSettingsRepository = settings, + syncScheduler = syncScheduler, + ) + + vm.setRetentionCount(500) + advanceUntilIdle() + + coVerifyOrder { + settings.setRetentionCount(ACCOUNT, 500) + syncScheduler.pruneNow() + accounts.resetBackfillProgress(ACCOUNT) + } + } + + @Test + fun `setRetentionMonths persists then prunes and resets backfill`() = runTest(dispatcher) { + val settings = mockk(relaxed = true) + every { settings.observe(ACCOUNT) } returns MutableStateFlow(AccountSettings(ACCOUNT)) + val accounts = mockk(relaxed = true) + every { accounts.observeAccounts() } returns MutableStateFlow(listOf(account)) + val syncScheduler = mockk(relaxed = true) + val vm = viewModel( + accountRepository = accounts, + accountSettingsRepository = settings, + syncScheduler = syncScheduler, + ) + + vm.setRetentionMonths(null) + advanceUntilIdle() + + coVerifyOrder { + settings.setRetentionMonths(ACCOUNT, null) + syncScheduler.pruneNow() + accounts.resetBackfillProgress(ACCOUNT) + } + } + + @Test + fun `setDefaultAccount persists this account as the default when turned on`() = runTest(dispatcher) { + val settingsRepository = mockk(relaxed = true) + every { settingsRepository.settings } returns MutableStateFlow(AppSettings()) + val vm = viewModel(settingsRepository = settingsRepository) + + vm.setDefaultAccount(true) + advanceUntilIdle() + + coVerify { settingsRepository.setDefaultAccountId(ACCOUNT) } + } + + @Test + fun `setDefaultAccount clears the default when turned off`() = runTest(dispatcher) { + val settingsRepository = mockk(relaxed = true) + every { settingsRepository.settings } returns MutableStateFlow(AppSettings()) + val vm = viewModel(settingsRepository = settingsRepository) + + vm.setDefaultAccount(false) + advanceUntilIdle() + + coVerify { settingsRepository.clearDefaultAccountId(ACCOUNT) } + } + + @Test + fun `removeAccount deletes the account, clears the stranded default, then notifies the caller`() = + runTest(dispatcher) { + val accounts = mockk(relaxed = true) + every { accounts.observeAccounts() } returns MutableStateFlow(listOf(account)) + val settingsRepository = mockk(relaxed = true) + every { settingsRepository.settings } returns MutableStateFlow(AppSettings()) + val vm = viewModel(accountRepository = accounts, settingsRepository = settingsRepository) + var removed = false + + vm.removeAccount { removed = true } + advanceUntilIdle() + + coVerifyOrder { + accounts.deleteAccount(ACCOUNT) + settingsRepository.clearDefaultAccountId(ACCOUNT) + } + assertEquals(true, removed) + } + + private companion object { + const val ACCOUNT = "imap:me@example.org" + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SettingsViewModelDelegationTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SettingsViewModelDelegationTest.kt new file mode 100644 index 0000000..c054493 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SettingsViewModelDelegationTest.kt @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import android.content.Intent +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.contacts.ContactsPermissionManager +import org.libremail.data.settings.AppSettings +import org.libremail.data.settings.FetchPolicy +import org.libremail.data.settings.SettingsRepository +import org.libremail.data.sync.SyncScheduler +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig +import org.libremail.domain.repository.AccountRepository +import org.libremail.push.BatteryOptimizationManager +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Covers the read-through state and the thin settings delegators of [SettingsViewModel]. The + * security-critical `setAppLock` branches have their own dedicated coverage in [SettingsViewModelTest]; + * these lock in everything else so the whole ViewModel clears the coverage bar. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelDelegationTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private val account = Account( + id = "imap:a", + email = "a@example.org", + displayName = "A", + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), + ) + + private fun viewModel( + accountRepository: AccountRepository = mockk(relaxed = true), + settingsRepository: SettingsRepository = mockk(relaxed = true), + batteryOptimizationManager: BatteryOptimizationManager = mockk(relaxed = true), + contactsPermissionManager: ContactsPermissionManager = mockk(relaxed = true), + syncScheduler: SyncScheduler = mockk(relaxed = true), + accounts: List = listOf(account), + settings: AppSettings = AppSettings(), + contactsRequested: Boolean = false, + ): SettingsViewModel { + every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts) + every { settingsRepository.settings } returns MutableStateFlow(settings) + every { settingsRepository.contactsPermissionRequested } returns MutableStateFlow(contactsRequested) + return SettingsViewModel( + accountRepository = accountRepository, + settingsRepository = settingsRepository, + appLockManager = mockk(relaxed = true), + databaseKeyStore = mockk(relaxed = true), + batteryOptimizationManager = batteryOptimizationManager, + contactsPermissionManager = contactsPermissionManager, + syncScheduler = syncScheduler, + ) + } + + @Test + fun `accounts, settings, and contacts-requested expose the repository streams`() = runTest(dispatcher) { + val vm = viewModel( + settings = AppSettings(dynamicColor = false, pushIdle = false), + contactsRequested = true, + ) + + backgroundScope.launch { vm.accounts.collect {} } + backgroundScope.launch { vm.settings.collect {} } + backgroundScope.launch { vm.contactsPermissionRequested.collect {} } + runCurrent() + + assertEquals(listOf("imap:a"), vm.accounts.value.map { it.id }) + assertFalse(vm.settings.value.dynamicColor) + assertTrue(vm.contactsPermissionRequested.value) + } + + @Test + fun `toggleAdvanced flips the advanced disclosure`() { + val vm = viewModel() + + assertFalse(vm.advancedExpanded.value) + vm.toggleAdvanced() + assertTrue(vm.advancedExpanded.value) + vm.toggleAdvanced() + assertFalse(vm.advancedExpanded.value) + } + + @Test + fun `battery-unrestricted state seeds from and refreshes via the manager`() { + val battery = mockk(relaxed = true) + every { battery.isIgnoringBatteryOptimizations() } returnsMany listOf(false, true) + val vm = viewModel(batteryOptimizationManager = battery) + + assertFalse(vm.batteryUnrestricted.value) + vm.refreshBatteryStatus() + assertTrue(vm.batteryUnrestricted.value) + } + + @Test + fun `intent and permission reads delegate straight to their managers`() { + val batteryIntent = mockk() + val contactsIntent = mockk() + val battery = mockk(relaxed = true) { + every { settingsIntent() } returns batteryIntent + } + val contacts = mockk(relaxed = true) { + every { settingsIntent() } returns contactsIntent + every { hasPermission() } returns true + } + val vm = viewModel(batteryOptimizationManager = battery, contactsPermissionManager = contacts) + + assertSame(batteryIntent, vm.batterySettingsIntent()) + assertSame(contactsIntent, vm.contactsSettingsIntent()) + assertTrue(vm.hasContactsPermission()) + } + + @Test + fun `simple toggles delegate to the settings repository`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + val vm = viewModel(settingsRepository = repo) + + vm.setDynamicColor(true) + vm.setNewMailNotifications(false) + vm.setPushIdle(true) + vm.setAllowStartTls(true) + vm.setLoadRemoteImages(true) + vm.setEncryptCache(true) + vm.setIncludeInBackup(false) + vm.setFetchPolicy(FetchPolicy.ON_DEMAND) + vm.markContactsPermissionRequested() + advanceUntilIdle() + + coVerify { repo.setDynamicColor(true) } + coVerify { repo.setNewMailNotifications(false) } + coVerify { repo.setPushIdle(true) } + coVerify { repo.setAllowStartTls(true) } + coVerify { repo.setLoadRemoteImages(true) } + coVerify { repo.setEncryptCache(true) } + coVerify { repo.setIncludeInBackup(false) } + coVerify { repo.setFetchPolicy(FetchPolicy.ON_DEMAND) } + coVerify { repo.setContactsPermissionRequested(true) } + } + + @Test + fun `clearAppLockMessage resets the transient message`() { + val vm = viewModel() + + vm.clearAppLockMessage() + + assertNull(vm.appLockMessage.value) + } + + @Test + fun `global retention changes persist then prune and reset backfill for all accounts`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + val accounts = mockk(relaxed = true) + every { accounts.observeAccounts() } returns MutableStateFlow(listOf(account)) + val syncScheduler = mockk(relaxed = true) + val vm = viewModel(accountRepository = accounts, settingsRepository = repo, syncScheduler = syncScheduler) + + vm.setRetentionCount(1000) + advanceUntilIdle() + coVerifyOrder { + repo.setRetentionCount(1000) + syncScheduler.pruneNow() + accounts.resetBackfillProgress(null) + } + + vm.setRetentionMonths(12) + advanceUntilIdle() + coVerifyOrder { + repo.setRetentionMonths(12) + syncScheduler.pruneNow() + accounts.resetBackfillProgress(null) + } + } + + @Test + fun `contactsPermissionRequested defaults to false before the stream emits`() { + val settingsRepository = mockk(relaxed = true) + every { settingsRepository.settings } returns flowOf(AppSettings()) + every { settingsRepository.contactsPermissionRequested } returns flowOf(true) + val vm = viewModel(settingsRepository = settingsRepository) + + // Before anyone collects, the StateFlow shows its seed value. + assertFalse(vm.contactsPermissionRequested.value) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SignatureEditViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SignatureEditViewModelTest.kt new file mode 100644 index 0000000..ccc6683 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SignatureEditViewModelTest.kt @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import androidx.lifecycle.SavedStateHandle +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.data.settings.SignatureRepository +import org.libremail.domain.model.Signature +import org.libremail.ui.navigation.Routes +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class SignatureEditViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel( + signatureRepository: SignatureRepository, + signatureId: String? = null, + ): SignatureEditViewModel { + val args = mutableMapOf(Routes.SIGNATURE_EDIT_ARG_ACCOUNT to ACCOUNT) + if (signatureId != null) args[Routes.SIGNATURE_EDIT_ARG_ID] = signatureId + return SignatureEditViewModel(SavedStateHandle(args), signatureRepository) + } + + @Test + fun `a new signature is immediately loaded and blank`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + val vm = viewModel(repo) + + assertTrue(vm.isNew) + assertTrue(vm.state.value.loaded) + assertEquals("", vm.state.value.name) + assertEquals("", vm.state.value.body) + assertNull(vm.state.value.bodyHtml) + } + + @Test + fun `a blank signature id argument is treated as a new signature`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + val vm = viewModel(repo, signatureId = " ") + + assertTrue(vm.isNew) + assertTrue(vm.state.value.loaded) + coVerify(exactly = 0) { repo.get(any()) } + } + + @Test + fun `an existing signature is loaded into the form`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.get("sig-1") } returns + Signature(id = "sig-1", accountId = ACCOUNT, name = "Work", html = "

Regards

") + val vm = viewModel(repo, signatureId = "sig-1") + advanceUntilIdle() + + assertFalse(vm.isNew) + assertTrue(vm.state.value.loaded) + assertEquals("Work", vm.state.value.name) + assertEquals("Regards", vm.state.value.body) + assertEquals("

Regards

", vm.state.value.bodyHtml) + } + + @Test + fun `an existing signature with blank html keeps bodyHtml null`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.get("sig-1") } returns Signature(id = "sig-1", accountId = ACCOUNT, name = "Empty", html = "") + val vm = viewModel(repo, signatureId = "sig-1") + advanceUntilIdle() + + assertNull(vm.state.value.bodyHtml) + assertTrue(vm.state.value.loaded) + } + + @Test + fun `a missing existing signature leaves the form unloaded`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.get("gone") } returns null + val vm = viewModel(repo, signatureId = "gone") + advanceUntilIdle() + + assertFalse(vm.isNew) + assertFalse(vm.state.value.loaded) + } + + @Test + fun `onNameChange and onBodyChange update the form state`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + val vm = viewModel(repo) + + vm.onNameChange("Personal") + vm.onBodyChange("hi there", "hi there") + + assertEquals("Personal", vm.state.value.name) + assertEquals("hi there", vm.state.value.body) + assertEquals("hi there", vm.state.value.bodyHtml) + } + + @Test + fun `saving a new signature with a blank name falls back to the default name`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.create(any(), any(), any()) } returns "new-id" + val vm = viewModel(repo) + var saved = false + + vm.onBodyChange("plain body", null) + vm.save { saved = true } + advanceUntilIdle() + + // Blank name -> "Signature"; html is derived from the plaintext when the form has no rich HTML. + coVerify { repo.create(ACCOUNT, "Signature", match { it.contains("plain body") }) } + assertTrue(saved) + } + + @Test + fun `saving a new signature persists its rich html verbatim when present`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.create(any(), any(), any()) } returns "new-id" + val vm = viewModel(repo) + + vm.onNameChange(" Trimmed ") + vm.onBodyChange("body", "body") + vm.save {} + advanceUntilIdle() + + coVerify { repo.create(ACCOUNT, "Trimmed", "body") } + } + + @Test + fun `saving an existing signature updates it and invokes the callback`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + coEvery { repo.get("sig-1") } returns + Signature(id = "sig-1", accountId = ACCOUNT, name = "Work", html = "

Regards

") + coEvery { repo.update(any(), any(), any()) } just Runs + val vm = viewModel(repo, signatureId = "sig-1") + advanceUntilIdle() + var saved = false + + vm.onNameChange("Work v2") + vm.save { saved = true } + advanceUntilIdle() + + coVerify { repo.update("sig-1", "Work v2", "

Regards

") } + coVerify(exactly = 0) { repo.create(any(), any(), any()) } + assertTrue(saved) + } + + @Test + fun `SignatureEditUiState value semantics`() { + val state = SignatureEditUiState(name = "n", body = "b", bodyHtml = "b", loaded = true) + + assertEquals(state, state.copy()) + assertEquals(state.hashCode(), state.copy().hashCode()) + assertTrue(state.toString().contains("n")) + assertNotEquals(state, state.copy(name = "other")) + assertNotEquals(state, state.copy(body = "other")) + assertNotEquals(state, state.copy(bodyHtml = null)) + assertNotEquals(state, state.copy(loaded = false)) + } + + private companion object { + const val ACCOUNT = "imap:a" + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/settings/SignaturesViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/settings/SignaturesViewModelTest.kt new file mode 100644 index 0000000..b76c437 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/settings/SignaturesViewModelTest.kt @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import androidx.lifecycle.SavedStateHandle +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.data.settings.SignatureRepository +import org.libremail.domain.model.Signature +import org.libremail.ui.navigation.Routes +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class SignaturesViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun signature(id: String, isDefault: Boolean = false) = + Signature(id = id, accountId = ACCOUNT, name = "Sig $id", html = "

hi

", isDefault = isDefault) + + private fun viewModel(signatureRepository: SignatureRepository) = SignaturesViewModel( + SavedStateHandle(mapOf(Routes.SIGNATURES_ARG_ACCOUNT to ACCOUNT)), + signatureRepository, + ) + + @Test + fun `resolves the account id from the nav argument`() { + val repo = mockk(relaxed = true) + every { repo.observeForAccount(ACCOUNT) } returns flowOf(emptyList()) + + assertEquals(ACCOUNT, viewModel(repo).accountId) + } + + @Test + fun `signatures mirrors the repository stream for this account`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + every { repo.observeForAccount(ACCOUNT) } returns + MutableStateFlow(listOf(signature("a", isDefault = true), signature("b"))) + val vm = viewModel(repo) + + backgroundScope.launch { vm.signatures.collect {} } + runCurrent() + + assertEquals(listOf("a", "b"), vm.signatures.value.map { it.id }) + } + + @Test + fun `setDefault delegates to the repository scoped to this account`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + every { repo.observeForAccount(ACCOUNT) } returns flowOf(emptyList()) + val vm = viewModel(repo) + + vm.setDefault("sig-1") + + coVerify { repo.setDefault(ACCOUNT, "sig-1") } + } + + @Test + fun `delete delegates to the repository`() = runTest(dispatcher) { + val repo = mockk(relaxed = true) + every { repo.observeForAccount(ACCOUNT) } returns flowOf(emptyList()) + val vm = viewModel(repo) + + vm.delete("sig-2") + + coVerify { repo.delete("sig-2") } + } + + private companion object { + const val ACCOUNT = "imap:a" + } +}