Merge main into fix-193-age-retention-sync-window

This commit is contained in:
Jason Ross
2026-07-03 14:48:15 -05:00
committed by GitHub
28 changed files with 3671 additions and 0 deletions
@@ -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<Context>(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) }
}
}
@@ -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<ContentResolver>()
private val context = mockk<Context> { every { contentResolver } returns resolver }
private val repository = ContactsRepository(context)
private fun cursorOf(addresses: List<String?>, names: List<String?>): Cursor {
val cursor = mockk<Cursor>(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"))
}
}
@@ -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<Context>(relaxed = true)
private val provider = AndroidBatteryStatusProvider(context)
@After
fun tearDown() = unmockkAll()
private fun batteryManager(percent: Int, charging: Boolean) = mockk<BatteryManager> {
every { getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) } returns percent
every { isCharging } returns charging
}
private fun batteryIntent(level: Int, scale: Int, status: Int) = mockk<Intent> {
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<BroadcastReceiver>()
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()
}
}
}
@@ -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<Context>(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<PowerManager> {
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<Intent>().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<Intent>().resolveActivity(any()) } returns null
assertNotNull(manager.settingsIntent())
}
}
@@ -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<Context>(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()) }
}
}
@@ -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<String>(), any<String>()) } 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) }
}
}
@@ -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<PackageInfo>(relaxed = true)
info.versionName = "1.2.3"
every { info.longVersionCode } returns 42L
val pm = mockk<PackageManager> { 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<PackageInfo>(relaxed = true)
info.versionName = null
every { info.longVersionCode } returns 7L
val pm = mockk<PackageManager> { every { getPackageInfo(any<String>(), any<Int>()) } 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<PackageManager> {
every { getPackageInfo(any<String>(), any<Int>()) } throws PackageManager.NameNotFoundException()
}
val provider = AppVersionProvider(context(pm))
assertEquals("unknown", provider.versionName)
assertEquals(0L, provider.versionCode)
}
}
@@ -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<AppVersionProvider> {
every { versionName } returns "0.1.0"
every { versionCode } returns 1L
}
private val settingsRepository = mockk<SettingsRepository>()
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<Thread.UncaughtExceptionHandler>(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) }
}
}
@@ -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))
}
}
@@ -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<ReportUploadScheduler>(relaxed = true)
private val submitter = ReportSubmitter(scheduler)
private fun workInfo(state: WorkInfo.State) = mockk<WorkInfo> { 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"))
}
}
@@ -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<ReportStore>(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())
}
}
@@ -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<OutlookAuthManager>(relaxed = true)
every { manager.isConfigured } returns true
assertTrue(viewModel(outlookAuthManager = manager).isOutlookConfigured)
}
@Test
fun `outlookAuthIntent wraps a successfully built intent`() {
val manager = mockk<OutlookAuthManager>(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<OutlookAuthManager>(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<ActivityNotFoundException>(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<OutlookAuthManager>(relaxed = true)
coEvery { manager.exchangeToken(any()) } returns
OAuthResult(email = "me@outlook.com", accessToken = "tok", authStateJson = "{}")
val accounts = mockk<AccountRepository>(relaxed = true)
coEvery { accounts.addOutlookAccount("me@outlook.com", "tok", "{}") } returns Result.success(listOf("INBOX"))
val vm = viewModel(outlookAuthManager = manager, accountRepository = accounts)
vm.onOutlookResult(mockk<Intent>(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<OutlookAuthManager>(relaxed = true)
coEvery { manager.exchangeToken(any()) } throws IllegalStateException("Token exchange failed")
val vm = viewModel(outlookAuthManager = manager)
vm.onOutlookResult(mockk<Intent>(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<OutlookAuthManager>(relaxed = true)
coEvery { manager.exchangeToken(any()) } returns
OAuthResult(email = "me@outlook.com", accessToken = "tok", authStateJson = "{}")
val accounts = mockk<AccountRepository>(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<Intent>(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))
}
}
@@ -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<AccountRepository>()
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))
}
}
@@ -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<AccountRepository>(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<AccountRepository>()
val account = slot<Account>()
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<AccountRepository>()
val account = slot<Account>()
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<AccountRepository>()
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<AccountRepository>()
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))
}
}
@@ -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<Account> = listOf(alice),
savedState: SavedStateHandle = SavedStateHandle(),
mailRepository: MailRepository = mockk(relaxed = true),
contactsRepository: ContactsRepository = mockk(relaxed = true),
): ComposeViewModel {
val accountRepository = mockk<AccountRepository>()
every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts)
val accountSettingsRepository = mockk<AccountSettingsRepository>()
coEvery { accountSettingsRepository.get(any()) } answers { AccountSettings(firstArg()) }
val signatureRepository = mockk<SignatureRepository>(relaxed = true)
coEvery { signatureRepository.getDefault(any()) } returns null
val settingsRepository = mockk<SettingsRepository>()
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<ContactsRepository>()
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<ContactsRepository>(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<ContactsRepository>(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", "<p><img src=\"cid:$cid\"></p>")
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<MailRepository>(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<MailRepository>(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<MailRepository>(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<MailRepository>(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<MailRepository>(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 = "<p>b</p>",
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"))
}
}
@@ -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<String>? = null,
emailSingle: String? = null,
cc: Array<String>? = null,
bcc: Array<String>? = 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 = ""))
}
}
@@ -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<MailRepository>(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<MailRepository>(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<MailRepository>(relaxed = true)
every { repo.observeDrafts() } returns flowOf(emptyList())
val vm = DraftsViewModel(repo)
vm.deleteDraft("d9")
coVerify { repo.deleteDraft("d9") }
}
}
@@ -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<AppLockUiState>(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>(AppLockUiState.Checking, AppLockUiState.Unlocked)
}
}
@@ -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<List<Account>>,
)
private fun fixture(
accounts: List<Account> = 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<AccountRepository>(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<Result<Int>>()
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<Result<Int>>()
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<MailRepository>(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<AccountRepository>(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<PendingAction>(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<PendingAction>(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),
)
}
@@ -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<MailRepository>(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<MailRepository>(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<MailRepository>(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<MailRepository>(relaxed = true)
every { repo.observeOutbox() } returns flowOf(emptyList())
val vm = OutboxViewModel(repo)
vm.retry()
coVerify { repo.retryOutbox() }
}
}
@@ -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<SettingsRepository>()
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<MailRepository>(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<MailRepository>(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<MailRepository>(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<ReaderEvent>(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"))
}
}
@@ -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<ReportStore>(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<ReportStore>(relaxed = true)
every { store.reports } returns MutableStateFlow(emptyList())
every { store.save(any()) } just Runs
val collector = mockk<DiagnosticsCollector>()
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<ReportStore>(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))
}
}
@@ -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<ReportStore>(relaxed = true)
private val submitter = mockk<ReportSubmitter>(relaxed = true)
private fun viewModel(reports: List<DebugReport> = 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"
}
}
@@ -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<ReportStore>(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<ReportStore>(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<ReportStore>(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<ReportStore>(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") }
}
}
@@ -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 = "<p>$name</p>", 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<Account> = listOf(account),
settings: AccountSettings = AccountSettings(ACCOUNT),
appSettings: AppSettings = AppSettings(),
signatures: List<Signature> = 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<AccountSettingsRepository>(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<AccountSettingsRepository>(relaxed = true)
every { settings.observe(ACCOUNT) } returns MutableStateFlow(AccountSettings(ACCOUNT))
val accounts = mockk<AccountRepository>(relaxed = true)
every { accounts.observeAccounts() } returns MutableStateFlow(listOf(account))
val syncScheduler = mockk<SyncScheduler>(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<AccountSettingsRepository>(relaxed = true)
every { settings.observe(ACCOUNT) } returns MutableStateFlow(AccountSettings(ACCOUNT))
val accounts = mockk<AccountRepository>(relaxed = true)
every { accounts.observeAccounts() } returns MutableStateFlow(listOf(account))
val syncScheduler = mockk<SyncScheduler>(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<SettingsRepository>(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<SettingsRepository>(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<AccountRepository>(relaxed = true)
every { accounts.observeAccounts() } returns MutableStateFlow(listOf(account))
val settingsRepository = mockk<SettingsRepository>(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"
}
}
@@ -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<Account> = 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<BatteryOptimizationManager>(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<Intent>()
val contactsIntent = mockk<Intent>()
val battery = mockk<BatteryOptimizationManager>(relaxed = true) {
every { settingsIntent() } returns batteryIntent
}
val contacts = mockk<ContactsPermissionManager>(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<SettingsRepository>(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<SettingsRepository>(relaxed = true)
val accounts = mockk<AccountRepository>(relaxed = true)
every { accounts.observeAccounts() } returns MutableStateFlow(listOf(account))
val syncScheduler = mockk<SyncScheduler>(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<SettingsRepository>(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)
}
}
@@ -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<String, Any?>(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<SignatureRepository>(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<SignatureRepository>(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<SignatureRepository>(relaxed = true)
coEvery { repo.get("sig-1") } returns
Signature(id = "sig-1", accountId = ACCOUNT, name = "Work", html = "<p>Regards</p>")
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("<p>Regards</p>", vm.state.value.bodyHtml)
}
@Test
fun `an existing signature with blank html keeps bodyHtml null`() = runTest(dispatcher) {
val repo = mockk<SignatureRepository>(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<SignatureRepository>(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<SignatureRepository>(relaxed = true)
val vm = viewModel(repo)
vm.onNameChange("Personal")
vm.onBodyChange("hi there", "<b>hi there</b>")
assertEquals("Personal", vm.state.value.name)
assertEquals("hi there", vm.state.value.body)
assertEquals("<b>hi there</b>", 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<SignatureRepository>(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<SignatureRepository>(relaxed = true)
coEvery { repo.create(any(), any(), any()) } returns "new-id"
val vm = viewModel(repo)
vm.onNameChange(" Trimmed ")
vm.onBodyChange("body", "<i>body</i>")
vm.save {}
advanceUntilIdle()
coVerify { repo.create(ACCOUNT, "Trimmed", "<i>body</i>") }
}
@Test
fun `saving an existing signature updates it and invokes the callback`() = runTest(dispatcher) {
val repo = mockk<SignatureRepository>(relaxed = true)
coEvery { repo.get("sig-1") } returns
Signature(id = "sig-1", accountId = ACCOUNT, name = "Work", html = "<p>Regards</p>")
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", "<p>Regards</p>") }
coVerify(exactly = 0) { repo.create(any(), any(), any()) }
assertTrue(saved)
}
@Test
fun `SignatureEditUiState value semantics`() {
val state = SignatureEditUiState(name = "n", body = "b", bodyHtml = "<b>b</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"
}
}
@@ -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 = "<p>hi</p>", 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<SignatureRepository>(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<SignatureRepository>(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<SignatureRepository>(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<SignatureRepository>(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"
}
}