Merge main into feat-164-reorder-accounts
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user