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

This commit is contained in:
Jason Ross
2026-07-03 15:47:48 -05:00
committed by GitHub
19 changed files with 1670 additions and 74 deletions
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reporting
import androidx.activity.ComponentActivity
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.reporting.DebugReport
import org.libremail.reporting.ReportKind
import org.libremail.reporting.ReportStore
import org.libremail.ui.StartupCrashPrompt
import org.libremail.ui.theme.LibreMailTheme
import java.io.File
/**
* E2E for the #255 startup-crash-prompt gating, driving the real [StartupCrashPrompt] composable over a
* real file-backed [ReportStore]: a legitimate recent crash pops the dialog exactly once (and never
* again after a simulated relaunch reads the persisted `surfaced` flag), while a stale (> 24h) crash
* never pops it. A fixed clock keeps the age gate independent of the device wall clock.
*/
@RunWith(AndroidJUnit4::class)
class StartupCrashPromptTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val now = 1_000_000_000_000L
private val dayMs = 24L * 60 * 60 * 1000
private lateinit var dir: File
@Before
fun setUp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
dir = File(context.cacheDir, "startup_crash_prompt_test_${System.nanoTime()}")
dir.deleteRecursively()
dir.mkdirs()
}
@After
fun tearDown() {
dir.deleteRecursively()
}
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun store() = ReportStore(dir)
private fun crash(id: String, createdAt: Long) = DebugReport(
id = id,
createdAtMillis = createdAt,
kind = ReportKind.CRASH,
appVersionName = "0.1.0",
appVersionCode = 1,
androidRelease = "14",
androidSdkInt = 34,
deviceManufacturer = "Google",
deviceModel = "Pixel",
stackTrace = null,
settings = emptyMap(),
logs = emptyList(),
)
private fun viewModel(store: ReportStore) = StartupReportViewModel(store, now = { now })
/** Renders the prompt against [vmState]; swapping its value simulates a fresh process on relaunch. */
private fun render(vmState: MutableState<StartupReportViewModel>) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
StartupCrashPrompt(viewModel = vmState.value, onReview = {})
}
}
}
private fun awaitDialogShown() = composeTestRule.waitUntil(WAIT_MS) {
composeTestRule.onAllNodesWithText(string(R.string.crash_prompt_title)).fetchSemanticsNodes().isNotEmpty()
}
private fun awaitDialogGone() = composeTestRule.waitUntil(WAIT_MS) {
composeTestRule.onAllNodesWithText(string(R.string.crash_prompt_title)).fetchSemanticsNodes().isEmpty()
}
@Test
fun recentCrash_popsDialogOnce_andNotAgainOnRelaunch() {
val store = store()
store.save(crash("c", createdAt = now - 60_000L))
val vmState = mutableStateOf(viewModel(store))
render(vmState)
// First re-open after the crash: the prompt is offered.
awaitDialogShown()
composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertIsDisplayed()
// "Not now" hides it and persistently marks it surfaced (the report itself stays saved).
composeTestRule.onNodeWithText(string(R.string.crash_prompt_later)).performClick()
awaitDialogGone()
assertNotNull(store().find("c"))
// Relaunch: a fresh store + VM over the same dir reads the persisted flag → no re-nag.
composeTestRule.runOnUiThread { vmState.value = viewModel(store()) }
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertDoesNotExist()
}
@Test
fun staleCrash_doesNotPopDialog() {
val store = store()
store.save(crash("old", createdAt = now - dayMs - 60_000L))
render(mutableStateOf(viewModel(store)))
composeTestRule.waitForIdle()
composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertDoesNotExist()
}
@Test
fun discard_deletesTheReport() {
val store = store()
store.save(crash("c", createdAt = now - 60_000L))
render(mutableStateOf(viewModel(store)))
awaitDialogShown()
composeTestRule.onNodeWithText(string(R.string.crash_prompt_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.crash_prompt_discard)).performClick()
awaitDialogGone()
assertNull(store().find("c"))
}
private companion object {
const val WAIT_MS = 5_000L
}
}
@@ -31,12 +31,19 @@ data class DebugReport(
val userComment: String = "",
/** Reply-to address the user supplied when submitting (see #159); required for online submit. */
val userEmail: String = "",
/**
* Whether the startup crash prompt has already auto-offered this report (see #255). Internal
* bookkeeping only: persisted with the report but deliberately kept out of [toSubmissionPayload]
* so it never leaks into what the user reviews or submits. A missing flag (older stored reports)
* reads as `false` — not yet surfaced.
*/
val surfaced: Boolean = false,
) {
/** The exact text shown for review, copied, saved to a file, and POSTed on submit. */
fun toSubmissionPayload(): String = toJson().toString(JSON_INDENT)
/** Compact form used for on-disk persistence. */
fun toStorageJson(): String = toJson().toString()
/** Compact form used for on-disk persistence; adds the internal [surfaced] bookkeeping flag. */
fun toStorageJson(): String = toJson().put("surfaced", surfaced).toString()
private fun toJson(): JSONObject {
val app = JSONObject()
@@ -92,6 +99,7 @@ data class DebugReport(
logs = logs,
userComment = json.optString("userComment", ""),
userEmail = json.optString("userEmail", ""),
surfaced = json.optBoolean("surfaced", false),
)
}
}
@@ -27,6 +27,20 @@ class ReportStore(private val directory: File) {
fun find(id: String): DebugReport? = _reports.value.firstOrNull { it.id == id }
/**
* Persistently marks a report as auto-surfaced so the startup crash prompt offers it at most once
* across launches (see #255). The report itself stays in the store (still listed under Problem
* Reports); only [delete] removes it. No-op if the report is missing or already surfaced.
*/
fun markSurfaced(id: String) {
synchronized(lock) {
val report = _reports.value.firstOrNull { it.id == id } ?: return
if (report.surfaced) return
File(directory, fileName(id)).writeText(report.copy(surfaced = true).toStorageJson())
_reports.value = scan()
}
}
fun delete(id: String) {
synchronized(lock) {
File(directory, fileName(id)).delete()
@@ -69,7 +69,6 @@ fun LibreMailApp(
val start = startDestination ?: return
val licenseAlreadyAccepted = licenseAccepted ?: return
val navController = rememberNavController()
val pendingCrash by startupViewModel.pendingCrash.collectAsStateWithLifecycle()
// A mailto:/share intent opens compose on top of the mailbox, pre-filled. Keyed on the request so
// it fires once per intent (and again for a new intent delivered while the app is alive).
@@ -256,14 +255,28 @@ fun LibreMailApp(
}
// On launch, offer any saved crash report for review — never sent without the user's action.
StartupCrashPrompt(
viewModel = startupViewModel,
onReview = { reportId -> navController.navigate(Routes.reportReview(reportId)) },
)
}
/**
* Offers any pending crash report for review on launch (see #255). [StartupReportViewModel] gates this
* to a legitimate crash from the last 24h, shown at most once; this only renders its decision. "Review"
* and "Not now" both mark the report surfaced so it never re-nags; only "Discard" deletes it.
*/
@Composable
internal fun StartupCrashPrompt(viewModel: StartupReportViewModel, onReview: (String) -> Unit) {
val pendingCrash by viewModel.pendingCrash.collectAsStateWithLifecycle()
pendingCrash?.let { crash ->
CrashReportDialog(
onReview = {
startupViewModel.dismiss()
navController.navigate(Routes.reportReview(crash.id))
viewModel.dismiss(crash.id)
onReview(crash.id)
},
onLater = startupViewModel::dismiss,
onDiscard = { startupViewModel.discard(crash.id) },
onLater = { viewModel.dismiss(crash.id) },
onDiscard = { viewModel.discard(crash.id) },
)
}
}
@@ -4,43 +4,58 @@ package org.libremail.ui.reporting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.libremail.reporting.ReportKind
import org.libremail.reporting.ReportStore
import javax.inject.Inject
/** Surfaces a pending crash report (if any) so the app can offer it for review on launch. */
/**
* Surfaces a pending crash report (if any) so the app can offer it for review on launch. The prompt is
* gated (see #255) so it fires at most once, only for a legitimate recent crash:
*
* - **First re-open only:** a report is auto-offered once, then persistently marked surfaced; it never
* re-nags on later launches. It stays in the store (still listed under Problem Reports for manual
* review); only [discard] deletes it.
* - **< 24h only:** older crashes are never auto-surfaced (they may still be reviewed manually).
* - **Legitimate crash only:** only `CrashReporter`'s uncaught-exception handler ever creates a
* [ReportKind.CRASH] report, so an app update, a user-initiated close, or a force-stop create no
* report and therefore never pop this prompt.
*
* @param now clock provider, injected so the age gate is unit-testable.
*/
@HiltViewModel
class StartupReportViewModel @Inject constructor(private val store: ReportStore) : ViewModel() {
class StartupReportViewModel(private val store: ReportStore, private val now: () -> Long) : ViewModel() {
private val dismissed = MutableStateFlow(false)
@Inject
constructor(store: ReportStore) : this(store, { System.currentTimeMillis() })
val pendingCrash: StateFlow<ReportSummary?> =
combine(store.reports, dismissed) { reports, isDismissed ->
if (isDismissed) {
null
} else {
reports.firstOrNull { it.kind == ReportKind.CRASH }
?.let { ReportSummary(it.id, it.kind, it.createdAtMillis) }
}
store.reports.map { reports ->
reports.firstOrNull { report ->
report.kind == ReportKind.CRASH &&
!report.surfaced &&
report.createdAtMillis >= now() - CRASH_MAX_AGE_MS
}?.let { ReportSummary(it.id, it.kind, it.createdAtMillis) }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_MS), null)
/** Hides the prompt for this launch; the report stays saved and is offered again next launch. */
fun dismiss() {
dismissed.value = true
/**
* "Not now" / "Review": persistently marks the crash surfaced so it is auto-offered at most once
* across launches. The report stays saved (still shown in Problem Reports); only [discard] deletes.
*/
fun dismiss(id: String) {
viewModelScope.launch { store.markSurfaced(id) }
}
fun discard(id: String) {
dismissed.value = true
viewModelScope.launch { store.delete(id) }
}
private companion object {
const val SUBSCRIBE_MS = 5_000L
const val CRASH_MAX_AGE_MS = 24L * 60 * 60 * 1000
}
}
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.auth
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
/** Value-semantics cover for the two OAuth result carriers. */
class OAuthModelsTest {
@Test
fun `OAuthResult exposes its fields and value semantics`() {
val result = OAuthResult(email = "me@example.com", accessToken = "at", authStateJson = "{json}")
assertEquals("me@example.com", result.email)
assertEquals("at", result.accessToken)
assertEquals("{json}", result.authStateJson)
assertEquals(result, result.copy())
assertNotEquals(result, result.copy(accessToken = "other"))
}
@Test
fun `FreshToken defaults its expiry to null and supports copy`() {
val fresh = FreshToken(accessToken = "at", authStateJson = "{json}")
assertNull(fresh.accessTokenExpiry)
assertEquals(4_200L, fresh.copy(accessTokenExpiry = 4_200L).accessTokenExpiry)
assertEquals("at", fresh.accessToken)
assertEquals(fresh, FreshToken("at", "{json}"))
}
}
@@ -0,0 +1,286 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.auth
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.text.TextUtils
import android.util.Base64
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkAll
import kotlinx.coroutines.test.runTest
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationException
import net.openid.appauth.AuthorizationRequest
import net.openid.appauth.AuthorizationResponse
import net.openid.appauth.AuthorizationService
import net.openid.appauth.AuthorizationServiceConfiguration
import net.openid.appauth.ResponseTypeValues
import net.openid.appauth.TokenRequest
import net.openid.appauth.TokenResponse
import org.json.JSONObject
import org.junit.After
import org.junit.Test
import org.libremail.BuildConfig
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
/**
* Drives the Outlook/Microsoft OAuth wrapper in a pure JVM test. The AppAuth types it builds
* (requests, responses, token responses) carry data in final fields, so those are constructed for
* real; only the boundaries are faked — the Android statics AppAuth reaches for ([Uri], [Base64],
* [TextUtils], [Intent]), the browser-less [PackageManager] that lets [AuthorizationService]
* construct, and the token-endpoint call itself (its constructor is mocked, the callback invoked with
* a canned [TokenResponse]). This pins the two-resource token exchange (Exchange token minted from the
* auth-code grant), the email extraction from the id_token (with its preferred_username fallback), the
* refresh paths, and every failure branch — none of which had unit cover before.
*/
class OutlookAuthManagerTest {
@After
fun tearDown() = unmockkAll()
@Test
fun `isConfigured reflects whether a client id ships with the build`() {
installStatics()
assertEquals(BuildConfig.OUTLOOK_OAUTH_CLIENT_ID.isNotBlank(), authManager().isConfigured)
}
@Test
fun `createAuthIntent builds and returns the AppAuth sign-in intent`() {
installStatics()
mockkConstructor(AuthorizationService::class)
every { anyConstructed<AuthorizationService>().dispose() } just runs
val intent = mockk<Intent>()
every { anyConstructed<AuthorizationService>().getAuthorizationRequestIntent(any()) } returns intent
assertEquals(intent, authManager().createAuthIntent())
}
@Test
fun `exchangeToken mints an Exchange token and reads the account email from the id_token`() = runTest {
installStatics()
stubResponse(authResponseWithCode("auth-code"))
stubTokenRequests(
tokenResponse(access = "code-access", idToken = jwt("email" to "me@example.com"), refresh = "rt"),
tokenResponse(access = "outlook-access", idToken = null, refresh = "rt"),
)
val result = authManager().exchangeToken(mockk<Intent>())
assertEquals("me@example.com", result.email)
assertEquals("outlook-access", result.accessToken) // the Exchange-scoped token, not the code one
}
@Test
fun `exchangeToken falls back to preferred_username when the email claim is blank`() = runTest {
installStatics()
stubResponse(authResponseWithCode("auth-code"))
stubTokenRequests(
tokenResponse("code-access", jwt("email" to "", "preferred_username" to "alt@example.com"), "rt"),
tokenResponse("outlook-access", null, "rt"),
)
assertEquals("alt@example.com", authManager().exchangeToken(mockk<Intent>()).email)
}
@Test
fun `exchangeToken throws when the authorization was cancelled`() = runTest {
installStatics()
mockkStatic(AuthorizationResponse::class)
every { AuthorizationResponse.fromIntent(any()) } returns null
mockkStatic(AuthorizationException::class)
every { AuthorizationException.fromIntent(any()) } returns null
assertFailsWith<IllegalStateException> { authManager().exchangeToken(mockk<Intent>()) }
}
@Test
fun `exchangeToken throws when no authorization code was returned`() = runTest {
installStatics()
stubResponse(authResponseWithCode(null)) // a response with no code
mockkConstructor(AuthorizationService::class)
every { anyConstructed<AuthorizationService>().dispose() } just runs
assertFailsWith<IllegalStateException> { authManager().exchangeToken(mockk<Intent>()) }
}
@Test
fun `exchangeToken throws when the id_token carries no usable email`() = runTest {
installStatics()
stubResponse(authResponseWithCode("auth-code"))
stubTokenRequests(tokenResponse("code-access", jwt(), "rt")) // empty claims -> no email
assertFailsWith<IllegalStateException> { authManager().exchangeToken(mockk<Intent>()) }
}
@Test
fun `exchangeToken throws on an unparseable id_token`() = runTest {
installStatics()
stubResponse(authResponseWithCode("auth-code"))
stubTokenRequests(tokenResponse("code-access", idToken = "not-a-jwt", refresh = "rt"))
assertFailsWith<IllegalStateException> { authManager().exchangeToken(mockk<Intent>()) }
}
@Test
fun `freshOutlookToken refreshes and returns the Exchange access token`() = runTest {
installStatics()
stubDeserializedAuthState(refreshToken = "rt")
stubTokenRequests(tokenResponse("exchange-at", idToken = null, refresh = "rt"))
val fresh = authManager().freshOutlookToken("{stored}")
assertEquals("exchange-at", fresh.accessToken)
assertEquals("{serialized}", fresh.authStateJson)
}
@Test
fun `freshGraphToken refreshes and returns the Graph access token`() = runTest {
installStatics()
stubDeserializedAuthState(refreshToken = "rt")
stubTokenRequests(tokenResponse("graph-at", idToken = null, refresh = "rt"))
assertEquals("graph-at", authManager().freshGraphToken("{stored}").accessToken)
}
@Test
fun `a refresh without a stored refresh token asks the user to sign in again`() = runTest {
installStatics()
stubDeserializedAuthState(refreshToken = null)
mockkConstructor(AuthorizationService::class)
every { anyConstructed<AuthorizationService>().dispose() } just runs
assertFailsWith<IllegalStateException> { authManager().freshGraphToken("{stored}") }
}
@Test
fun `exchangeToken surfaces a token endpoint failure`() = runTest {
installStatics()
stubResponse(authResponseWithCode("auth-code"))
stubFailingTokenRequest()
assertFailsWith<Exception> { authManager().exchangeToken(mockk<Intent>()) }
}
@Test
fun `a refresh surfaces a token endpoint failure`() = runTest {
installStatics()
stubDeserializedAuthState(refreshToken = "rt")
stubFailingTokenRequest()
assertFailsWith<Exception> { authManager().freshGraphToken("{stored}") }
}
// --- test fixtures -----------------------------------------------------------------------------
private fun authManager() = OutlookAuthManager(androidContext())
/** Installs the Android statics AppAuth touches so its real objects can be built off-device. */
private fun installStatics() {
mockkStatic(TextUtils::class)
every { TextUtils.isEmpty(any()) } answers { (firstArg<CharSequence?>()?.length ?: 0) == 0 }
every { TextUtils.join(any(), any<Iterable<*>>()) } answers {
secondArg<Iterable<*>>().joinToString(firstArg<CharSequence>().toString())
}
mockkStatic(Uri::class)
val uri = mockk<Uri>(relaxed = true)
every { uri.scheme } returns "org.libremail"
every { Uri.parse(any()) } returns uri
every { Uri.fromParts(any(), any(), any()) } returns uri
mockkStatic(Base64::class)
every { Base64.encodeToString(any(), any()) } answers {
java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(firstArg())
}
every { Base64.decode(any<String>(), any()) } answers {
java.util.Base64.getUrlDecoder().decode(firstArg<String>())
}
// BrowserSelector's static init builds a probe Intent; keep its fluent chain from touching stubs.
mockkConstructor(Intent::class)
every { anyConstructed<Intent>().setAction(any()) } answers { self as Intent }
every { anyConstructed<Intent>().addCategory(any()) } answers { self as Intent }
every { anyConstructed<Intent>().setData(any()) } answers { self as Intent }
}
/** A context whose PackageManager reports no browsers, so AuthorizationService constructs cleanly. */
private fun androidContext(): Context {
val pm = mockk<PackageManager>(relaxed = true)
every { pm.resolveActivity(any(), any<Int>()) } returns null
every { pm.queryIntentActivities(any(), any<Int>()) } returns emptyList()
return mockk<Context>(relaxed = true).also {
every { it.packageManager } returns pm
every { it.applicationContext } returns it
}
}
private val config get() = AuthorizationServiceConfiguration(Uri.parse("authorize"), Uri.parse("token"))
private fun stubResponse(response: AuthorizationResponse) {
mockkStatic(AuthorizationResponse::class)
every { AuthorizationResponse.fromIntent(any()) } returns response
mockkStatic(AuthorizationException::class)
every { AuthorizationException.fromIntent(any()) } returns null
}
private fun authResponseWithCode(code: String?): AuthorizationResponse {
val request = AuthorizationRequest.Builder(config, "client", ResponseTypeValues.CODE, Uri.parse("redirect"))
.setScope("openid")
.build()
return AuthorizationResponse.Builder(request).apply { code?.let { setAuthorizationCode(it) } }.build()
}
/** Mocks the token-endpoint call, handing back [responses] in order to each performTokenRequest. */
private fun stubTokenRequests(vararg responses: TokenResponse) {
mockkConstructor(AuthorizationService::class)
every { anyConstructed<AuthorizationService>().dispose() } just runs
val queue = ArrayDeque(responses.toList())
every { anyConstructed<AuthorizationService>().performTokenRequest(any(), any()) } answers {
secondArg<AuthorizationService.TokenResponseCallback>().onTokenRequestCompleted(queue.removeFirst(), null)
}
}
/** Mocks the token endpoint to report failure (null response, null exception) to its callback. */
private fun stubFailingTokenRequest() {
mockkConstructor(AuthorizationService::class)
every { anyConstructed<AuthorizationService>().dispose() } just runs
every { anyConstructed<AuthorizationService>().performTokenRequest(any(), any()) } answers {
secondArg<AuthorizationService.TokenResponseCallback>().onTokenRequestCompleted(null, null)
}
}
/** Makes AuthState.jsonDeserialize hand back a mock carrying [refreshToken]. */
private fun stubDeserializedAuthState(refreshToken: String?) {
val authState = mockk<AuthState>(relaxed = true)
every { authState.refreshToken } returns refreshToken
every { authState.update(any<TokenResponse>(), any()) } just runs
every { authState.jsonSerializeString() } returns "{serialized}"
mockkStatic(AuthState::class)
every { AuthState.jsonDeserialize(any<String>()) } returns authState
}
private fun tokenResponse(access: String, idToken: String?, refresh: String?): TokenResponse {
val request = TokenRequest.Builder(config, "client")
.setGrantType("authorization_code")
.setAuthorizationCode("code")
.setRedirectUri(Uri.parse("redirect"))
.build()
return TokenResponse.Builder(request)
.setAccessToken(access)
.setIdToken(idToken)
.setRefreshToken(refresh)
.setAccessTokenExpirationTime(System.currentTimeMillis() + 3_600_000L)
.build()
}
private fun jwt(vararg claims: Pair<String, String>): String {
val payload = java.util.Base64.getUrlEncoder().withoutPadding()
.encodeToString(JSONObject(mapOf(*claims)).toString().toByteArray())
return "header.$payload.signature"
}
}
@@ -332,6 +332,27 @@ class MailBackfillerTest {
coVerify(exactly = 0) { imapClient.fetchOlderThan(any(), any(), match { it <= 1L }, any()) }
}
/**
* A backfilled page whose ids already exist (e.g. former search-only rows) must be *refreshed*
* (markSynced + header update), not just IGNORE-inserted — this covers persistBatch's
* pre-existing-row branch, which the all-brand-new happy paths above never hit.
*/
@Test
fun `re-inserting a pre-existing header refreshes it rather than only inserting`() = runTest {
appendMessages(60)
seedForegroundWindow()
val backfiller = backfiller(AccountSettings("acct"))
// Report every offered id as already present, so persistBatch takes the refresh branch.
coEvery { lastMessageDao!!.existingIds(any()) } answers { firstArg() }
backfiller.runBackfill()
coVerify(atLeast = 1) { lastMessageDao!!.markSynced(any()) }
coVerify(atLeast = 1) {
lastMessageDao!!.updateHeaderContent(any(), any(), any(), any(), any(), any())
}
}
private fun fetchedMessage(uid: String) = FetchedMessage(
uid = uid,
sender = "Sender",
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.libremail.auth.FreshToken
import org.libremail.auth.OutlookAuthManager
import org.libremail.data.security.CredentialStore
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
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 kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [MailConnectionFactory] turns a stored credential into connection params, refreshing (and caching)
* OAuth access tokens on demand. These tests pin the password path, the OAuth refresh-and-cache
* behaviour (a still-valid token is reused; an expired or unknown-expiry one is refreshed), the
* persist-only-when-changed rule, and the missing-credential errors — all without a real network.
*/
class MailConnectionFactoryTest {
private val credentialStore = mockk<CredentialStore>(relaxed = true)
private val outlookAuthManager = mockk<OutlookAuthManager>()
private val settingsRepository = mockk<SettingsRepository> {
every { settings } returns flowOf(AppSettings())
}
private fun factory() = MailConnectionFactory(credentialStore, outlookAuthManager, settingsRepository)
private val passwordAccount = Account(
id = "acct",
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 val outlookAccount = Account.outlook("me@example.com")
private fun token(access: String, json: String, expiry: Long?) =
FreshToken(accessToken = access, authStateJson = json, accessTokenExpiry = expiry)
private val future get() = System.currentTimeMillis() + 3_600_000L
@Test
fun `password imapParams resolve the stored secret and never use XOAUTH2`() = runTest {
coEvery { credentialStore.loadSecret("acct") } returns "app-password"
val params = factory().imapParamsFor(passwordAccount)
assertEquals("app-password", params.secret)
assertEquals("a@example.org", params.username)
assertFalse(params.useXoauth2)
assertTrue(params.strictStartTls) // allowStartTls defaults false -> strict
}
@Test
fun `password smtpParams resolve the stored secret`() = runTest {
coEvery { credentialStore.loadSecret("acct") } returns "app-password"
val params = factory().smtpParamsFor(passwordAccount)
assertEquals("app-password", params.secret)
assertFalse(params.useXoauth2)
}
@Test
fun `a missing password credential is a hard error`() = runTest {
coEvery { credentialStore.loadSecret("acct") } returns null
assertFailsWith<IllegalStateException> { factory().imapParamsFor(passwordAccount) }
}
@Test
fun `allowing STARTTLS relaxes the strict flag`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(allowStartTls = true))
coEvery { credentialStore.loadSecret("acct") } returns "pw"
assertFalse(factory().imapParamsFor(passwordAccount).strictStartTls)
}
@Test
fun `outlook imapParams mint an Exchange token and mark XOAUTH2`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "refreshed", future)
val params = factory().imapParamsFor(outlookAccount)
assertEquals("outlook-at", params.secret)
assertTrue(params.useXoauth2)
// The AuthState changed, so the refreshed one is persisted for next time.
coVerify { credentialStore.saveSecret(outlookAccount.id, "refreshed") }
}
@Test
fun `an unchanged AuthState is not re-persisted`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "stored", future)
factory().imapParamsFor(outlookAccount)
coVerify(exactly = 0) { credentialStore.saveSecret(any(), any()) }
}
@Test
fun `a still-valid cached token is reused without a second refresh`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "stored", future)
val factory = factory()
factory.imapParamsFor(outlookAccount)
val second = factory.imapParamsFor(outlookAccount)
assertEquals("outlook-at", second.secret)
coVerify(exactly = 1) { outlookAuthManager.freshOutlookToken(any()) }
}
@Test
fun `an expired cached token forces a refresh`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
val past = System.currentTimeMillis() - 1_000L
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "stored", past)
val factory = factory()
factory.imapParamsFor(outlookAccount)
factory.imapParamsFor(outlookAccount)
coVerify(exactly = 2) { outlookAuthManager.freshOutlookToken(any()) }
}
@Test
fun `an unknown expiry is never trusted from cache`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "stored", null)
val factory = factory()
factory.imapParamsFor(outlookAccount)
factory.imapParamsFor(outlookAccount)
coVerify(exactly = 2) { outlookAuthManager.freshOutlookToken(any()) }
}
@Test
fun `graphToken mints a Graph-scoped token cached separately from the Exchange one`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns "stored"
coEvery { outlookAuthManager.freshGraphToken("stored") } returns token("graph-at", "stored", future)
coEvery { outlookAuthManager.freshOutlookToken("stored") } returns token("outlook-at", "stored", future)
val factory = factory()
assertEquals("graph-at", factory.graphTokenFor(outlookAccount))
// The Exchange scope has its own cache slot, so it still refreshes independently.
assertEquals("outlook-at", factory.imapParamsFor(outlookAccount).secret)
coVerify(exactly = 1) { outlookAuthManager.freshGraphToken(any()) }
coVerify(exactly = 1) { outlookAuthManager.freshOutlookToken(any()) }
}
@Test
fun `a missing OAuth credential is a hard error`() = runTest {
coEvery { credentialStore.loadSecret(outlookAccount.id) } returns null
assertFailsWith<IllegalStateException> { factory().graphTokenFor(outlookAccount) }
}
}
@@ -28,7 +28,9 @@ import org.libremail.mail.ImapClient
import org.libremail.notifications.MailNotifier
import org.libremail.power.BatteryStatus
import org.libremail.power.BatteryStatusProvider
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MailSyncerTest {
@@ -286,6 +288,88 @@ class MailSyncerTest {
)
}
@Test
fun `syncAll succeeds with a zero total when there are no accounts`() = runTest {
val syncer = syncAllSyncer(accounts = emptyList())
val result = syncer.syncAll()
assertEquals(0, result.getOrNull())
}
@Test
fun `syncAll syncs every account inbox and sums the fetched counts`() = runTest {
val syncer = syncAllSyncer(accounts = listOf(accountEntity("one"), accountEntity("two")))
val result = syncer.syncAll()
assertTrue(result.isSuccess)
assertEquals(2, result.getOrNull()) // one message fetched per account
}
@Test
fun `syncAll still succeeds when one account fails but another syncs`() = runTest {
val syncer = syncAllSyncer(
accounts = listOf(accountEntity("ok"), accountEntity("bad")),
failingIds = setOf("bad"),
)
val result = syncer.syncAll()
assertTrue(result.isSuccess, "at least one account synced")
assertEquals(1, result.getOrNull())
}
@Test
fun `syncAll fails only when every account fails`() = runTest {
val syncer = syncAllSyncer(
accounts = listOf(accountEntity("bad1"), accountEntity("bad2")),
failingIds = setOf("bad1", "bad2"),
)
assertTrue(syncer.syncAll().isFailure)
}
private fun accountEntity(id: String) = account.copy(id = id, email = "$id@example.org")
/**
* A syncer for the [MailSyncer.syncAll] path: [accountDao.getAll] returns [accounts], each inbox
* fetch yields one message (so a successful account contributes 1 to the total), and any account
* in [failingIds] fails its connection so its per-account sync errors out.
*/
private fun syncAllSyncer(accounts: List<AccountEntity>, failingIds: Set<String> = emptySet()): MailSyncer {
val accountDao = mockk<AccountDao>()
coEvery { accountDao.getAll() } returns accounts
val messageDao = mockk<MessageDao>(relaxed = true)
coEvery { messageDao.getSyncedIds(any(), any()) } returns emptyList()
coEvery { messageDao.getUnfetchedIds(any(), any()) } returns emptyList()
val imapClient = mockk<ImapClient>()
coEvery { imapClient.fetchRecent(any(), any(), any()) } returns listOf(
FetchedMessage("1", "Ada", "ada@example.org", "Hi", 1_000L, isRead = true, isFlagged = false),
)
val connectionFactory = mockk<MailConnectionFactory>()
coEvery { connectionFactory.imapParamsFor(match { it.id in failingIds }) } throws IOException("no network")
coEvery { connectionFactory.imapParamsFor(match { it.id !in failingIds }) } returns mockk()
val settingsRepository = mockk<SettingsRepository>()
coEvery { settingsRepository.fetchPolicy() } returns FetchPolicy.ON_DEMAND
coEvery { settingsRepository.isNewMailNotificationsEnabled() } returns false
every { settingsRepository.settings } returns flowOf(AppSettings())
val accountSettingsRepository = mockk<AccountSettingsRepository>()
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
return MailSyncer(
context = mockk(relaxed = true),
accountDao = accountDao,
messageDao = messageDao,
imapClient = imapClient,
connectionFactory = connectionFactory,
settingsRepository = settingsRepository,
accountSettingsRepository = accountSettingsRepository,
batteryStatusProvider = batteryProvider(BatteryStatus(percent = 100, isCharging = false)),
notifier = mockk(relaxed = true),
mailRepository = mockk(relaxed = true),
)
}
/** A context whose active network reports the given metered state via ConnectivityManager. */
private fun networkContext(unmetered: Boolean): Context {
val capabilities = mockk<NetworkCapabilities>()
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import org.junit.After
import org.junit.Test
/**
* [SendScheduler] is a thin wrapper over WorkManager; this pins the behaviour that carries meaning —
* the outbox drain is enqueued as a single unique job with REPLACE, so a newly-queued message (or a
* manual retry) starts a fresh drain rather than waiting behind a pending backoff.
*/
class SendSchedulerTest {
@After
fun tearDown() = unmockkAll()
@Test
fun `sendNow enqueues a unique send-outbox job with REPLACE`() {
mockkObject(WorkManager.Companion)
val workManager = mockk<WorkManager>(relaxed = true)
every { WorkManager.getInstance(any()) } returns workManager
SendScheduler(mockk(relaxed = true)).sendNow()
verify {
workManager.enqueueUniqueWork(
"libremail_send_outbox",
ExistingWorkPolicy.REPLACE,
any<OneTimeWorkRequest>(),
)
}
}
}
@@ -1,52 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.sync
import android.content.Context
import android.util.Log
import androidx.work.ListenableWorker.Result
import dagger.Lazy
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
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.Before
import org.junit.Test
import org.libremail.data.attachment.AttachmentUriGrants
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.data.local.toOutgoingAttachmentsJson
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.SmtpParams
import org.libremail.mail.GraphSendException
import org.libremail.mail.GraphSender
import org.libremail.mail.SendableAttachment
import org.libremail.mail.SmtpSender
import java.io.File
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [SendWorker] already gates on [EncryptedCacheGuard]; this locks that invariant in — while the cache
* is locked it must defer without resolving any of its (`Lazy`) DB-backed dependencies (resolving any
* of them opens the Room DB, which blocks on the passphrase await).
* [SendWorker] first gates on [EncryptedCacheGuard] (regression cover for the pre-auth-DB class of
* bug — while locked it must defer without resolving any of its `Lazy` DB-backed deps), then drains
* the outbox: sending each queued message over SMTP or Microsoft Graph, deleting it on success,
* flagging failures for a retry, and — crucially for the "may have sent" case — never auto-retrying
* a Graph request that might already have delivered.
*/
class SendWorkerTest {
private val outboxDao = mockk<OutboxDao>()
private val outboxDao = mockk<OutboxDao>(relaxed = true)
private val accountDao = mockk<AccountDao>()
private val connectionFactory = mockk<MailConnectionFactory>()
private val attachmentUriGrants = mockk<AttachmentUriGrants>()
private val smtpSender = mockk<SmtpSender>(relaxed = true)
private val graphSender = mockk<GraphSender>(relaxed = true)
private val connectionFactory = mockk<MailConnectionFactory>(relaxed = true)
private val attachmentUriGrants = mockk<AttachmentUriGrants>(relaxed = true)
private val lazyOutbox = mockk<Lazy<OutboxDao>> { every { get() } returns outboxDao }
private val lazyAccount = mockk<Lazy<AccountDao>> { every { get() } returns accountDao }
private val lazyConnection = mockk<Lazy<MailConnectionFactory>> { every { get() } returns connectionFactory }
private val lazyGrants = mockk<Lazy<AttachmentUriGrants>> { every { get() } returns attachmentUriGrants }
private val cacheGuard = mockk<EncryptedCacheGuard>()
private lateinit var cacheDir: File
private lateinit var appContext: Context
@Before
fun setUp() {
cacheDir = java.nio.file.Files.createTempDirectory("sendworker").toFile()
appContext = mockk(relaxed = true)
every { appContext.cacheDir } returns cacheDir
coEvery { cacheGuard.isCacheLocked() } returns false
}
@After
fun tearDown() {
cacheDir.deleteRecursively()
unmockkAll()
}
private fun worker() = SendWorker(
mockk(relaxed = true),
appContext,
mockk(relaxed = true),
lazyOutbox,
lazyAccount,
mockk<SmtpSender>(),
mockk<GraphSender>(),
smtpSender,
graphSender,
lazyConnection,
cacheGuard,
lazyGrants,
)
private fun account(id: String, authType: String) = AccountEntity(
id = id,
email = "$id@example.org",
displayName = id,
authType = authType,
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
)
private fun entity(id: String = "m1", accountId: String = "acct", attachments: String = "") = OutboxEntity(
id = id,
accountId = accountId,
toAddresses = "bob@example.org",
ccAddresses = "",
subject = "Hi",
body = "Body",
createdAt = 0L,
attachments = attachments,
)
@Test
fun `retries without resolving any DB dependency when the cache is locked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns true
@@ -60,12 +119,154 @@ class SendWorkerTest {
}
@Test
fun `drains the outbox when the cache is unlocked`() = runTest {
coEvery { cacheGuard.isCacheLocked() } returns false
fun `an empty outbox succeeds without sending`() = runTest {
coEvery { outboxDao.getAll() } returns emptyList()
assertEquals(Result.success(), worker().doWork())
coVerify(exactly = 1) { outboxDao.getAll() }
coVerify(exactly = 0) { smtpSender.send(any(), any(), any(), any()) }
}
@Test
fun `drops a queued message whose account was removed`() = runTest {
val row = entity()
coEvery { outboxDao.getAll() } returns listOf(row)
coEvery { accountDao.getById("acct") } returns null
assertEquals(Result.success(), worker().doWork())
coVerify { outboxDao.delete("m1") }
coVerify { attachmentUriGrants.releaseUnreferenced(any()) }
coVerify(exactly = 0) { smtpSender.send(any(), any(), any(), any()) }
}
@Test
fun `sends a password account message over SMTP then clears it`() = runTest {
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP")
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
assertEquals(Result.success(), worker().doWork())
coVerify { smtpSender.send(any(), "acct@example.org", any(), any()) }
coVerify { outboxDao.delete("m1") }
coVerify { attachmentUriGrants.releaseUnreferenced(any()) }
}
@Test
fun `an SMTP failure flags the row and retries`() = runTest {
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP")
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
coEvery { smtpSender.send(any(), any(), any(), any()) } throws RuntimeException("smtp down")
assertEquals(Result.retry(), worker().doWork())
coVerify { outboxDao.setError("m1", "smtp down") }
coVerify(exactly = 0) { outboxDao.delete(any()) }
}
@Test
fun `sends an Outlook message over Graph`() = runTest {
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "OAUTH_OUTLOOK")
coEvery { connectionFactory.graphTokenFor(any()) } returns "graph-token"
assertEquals(Result.success(), worker().doWork())
coVerify { graphSender.send("graph-token", any(), any()) }
coVerify { outboxDao.delete("m1") }
coVerify(exactly = 0) { smtpSender.send(any(), any(), any(), any()) }
}
@Test
fun `a Graph send that may have sent is left queued and not retried`() = runTest {
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "OAUTH_OUTLOOK")
coEvery { connectionFactory.graphTokenFor(any()) } returns "graph-token"
coEvery { graphSender.send(any(), any(), any()) } throws
GraphSendException("maybe sent", mayHaveSent = true)
// Not a failure: WorkManager must NOT auto-retry, or the message could be duplicated.
assertEquals(Result.success(), worker().doWork())
coVerify { outboxDao.setError("m1", match { it.contains("check your Sent folder") }) }
coVerify(exactly = 0) { outboxDao.delete(any()) }
coVerify(exactly = 0) { smtpSender.send(any(), any(), any(), any()) } // must not fall back
}
@Test
fun `a Graph rejection falls back to SMTP`() = runTest {
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "OAUTH_OUTLOOK")
coEvery { connectionFactory.graphTokenFor(any()) } returns "graph-token"
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
coEvery { graphSender.send(any(), any(), any()) } throws
GraphSendException("rejected", mayHaveSent = false)
assertEquals(Result.success(), worker().doWork())
coVerify { smtpSender.send(any(), "acct@example.org", any(), any()) }
coVerify { outboxDao.delete("m1") }
}
@Test
fun `a Graph transport error falls back to SMTP`() = runTest {
mockkStatic(Log::class)
every { Log.w(any(), any<String>(), any<Throwable>()) } returns 0
coEvery { outboxDao.getAll() } returns listOf(entity())
coEvery { accountDao.getById("acct") } returns account("acct", "OAUTH_OUTLOOK")
coEvery { connectionFactory.graphTokenFor(any()) } throws RuntimeException("token refresh failed")
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
assertEquals(Result.success(), worker().doWork())
coVerify { smtpSender.send(any(), "acct@example.org", any(), any()) }
coVerify { outboxDao.delete("m1") }
}
@Test
fun `stages attachments pairing an inline image with its file and content id`() = runTest {
val attachmentsJson = listOf(
OutgoingAttachment(uri = "content://1", name = "logo.png", contentId = "logo@x", isInline = true),
OutgoingAttachment(uri = "content://2", name = "doc.pdf"),
).toOutgoingAttachmentsJson()
stageFile("m1", index = 0, name = "logo.png", bytes = byteArrayOf(1, 2))
stageFile("m1", index = 1, name = "doc.pdf", bytes = byteArrayOf(3, 4))
coEvery { outboxDao.getAll() } returns listOf(entity(attachments = attachmentsJson))
coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP")
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
val sent = slot<List<SendableAttachment>>()
coEvery { smtpSender.send(any(), any(), any(), capture(sent)) } returns Unit
worker().doWork()
assertEquals(2, sent.captured.size)
val inline = sent.captured.single { it.isInline }
assertEquals("logo@x", inline.contentId)
assertTrue(sent.captured.any { !it.isInline })
}
@Test
fun `falls back to positional staged files when there is no attachment metadata`() = runTest {
stageFile("m1", index = 0, name = "a.txt", bytes = byteArrayOf(1))
stageFile("m1", index = 1, name = "b.txt", bytes = byteArrayOf(2))
coEvery { outboxDao.getAll() } returns listOf(entity(attachments = ""))
coEvery { accountDao.getById("acct") } returns account("acct", "PASSWORD_IMAP")
coEvery { connectionFactory.smtpParamsFor(any()) } returns mockk<SmtpParams>()
val sent = slot<List<SendableAttachment>>()
coEvery { smtpSender.send(any(), any(), any(), capture(sent)) } returns Unit
worker().doWork()
assertEquals(2, sent.captured.size)
assertFalse(sent.captured.any { it.isInline }) // positional restore is always plain attachments
}
/** Stages a file the way the compose pipeline does: cacheDir/outbox/<id>/<index>/<name>. */
private fun stageFile(messageId: String, index: Int, name: String, bytes: ByteArray) {
File(cacheDir, "outbox/$messageId/$index").apply { mkdirs() }
.resolve(name).writeBytes(bytes)
}
}
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Test
import org.libremail.domain.model.OutgoingMessage
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLConnection
import java.net.URLStreamHandler
import java.net.URLStreamHandlerFactory
import java.util.concurrent.atomic.AtomicReference
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Exercises the [GraphSender.send] transport path (its JSON payload builder is unit-tested separately
* in [GraphSenderTest]). The Graph endpoint is a fixed https URL the sender news up itself, so the
* test routes https through a process-wide [URLStreamHandlerFactory] to a per-test fake connection —
* no network, no production seam. The behaviour pinned: a 2xx succeeds; a non-2xx is a safe-to-retry
* rejection; a lost response is flagged [GraphSendException.mayHaveSent] (must NOT retry/fall back);
* a transmit failure is not; and the connection is always disconnected.
*/
class GraphSenderSendTest {
@After
fun tearDown() = armed.set(null)
private val message =
OutgoingMessage(accountId = "outlook:me@x.com", to = "bob@example.org", subject = "Hi", body = "Body")
private fun arm(
status: Int = 202,
body: String = "",
failOutput: Boolean = false,
failResponse: Boolean = false,
): AtomicReference<FakeGraphConnection?> {
val last = AtomicReference<FakeGraphConnection?>(null)
armed.set { u -> FakeGraphConnection(u, status, body, failOutput, failResponse).also { last.set(it) } }
return last
}
@Test
fun `a 2xx response completes the send`() = runTest {
val last = arm(status = 202)
GraphSender().send("token", message)
assertTrue(last.get()!!.disconnected, "the connection must be disconnected when done")
}
@Test
fun `a non-2xx response is a safe-to-retry rejection`() = runTest {
arm(status = 400, body = "{\"error\":\"bad request\"}")
val ex = assertFailsWith<GraphSendException> { GraphSender().send("token", message) }
assertFalse(ex.mayHaveSent, "an explicit rejection means Graph did not send")
assertTrue(ex.message!!.contains("HTTP 400"), ex.message!!)
}
@Test
fun `a lost response is flagged as maybe-sent`() = runTest {
arm(failResponse = true)
val ex = assertFailsWith<GraphSendException> { GraphSender().send("token", message) }
assertTrue(ex.mayHaveSent, "the request was fully sent, so it may already have delivered")
}
@Test
fun `a transmit failure is not maybe-sent`() = runTest {
arm(failOutput = true)
val ex = assertFailsWith<GraphSendException> { GraphSender().send("token", message) }
assertFalse(ex.mayHaveSent, "the request never reached Graph, so a retry is safe")
}
@Test
fun `GraphSendException carries its message, flag and cause`() {
val cause = IOException("boom")
val ex = GraphSendException("failed", mayHaveSent = true, cause = cause)
assertEquals("failed", ex.message)
assertTrue(ex.mayHaveSent)
assertEquals(cause, ex.cause)
}
private class FakeGraphConnection(
url: URL,
private val status: Int,
private val body: String,
private val failOutput: Boolean,
private val failResponse: Boolean,
) : HttpURLConnection(url) {
var disconnected = false
override fun connect() = Unit
override fun disconnect() {
disconnected = true
}
override fun usingProxy() = false
override fun getOutputStream(): OutputStream =
if (failOutput) throw IOException("cannot transmit") else ByteArrayOutputStream()
override fun getResponseCode(): Int = if (failResponse) throw IOException("no response") else status
override fun getInputStream(): InputStream = body.byteInputStream()
override fun getErrorStream(): InputStream? = if (body.isEmpty()) null else body.byteInputStream()
}
companion object {
private val armed = AtomicReference<((URL) -> HttpURLConnection)?>(null)
// Set once per JVM: route https opens to whatever the running test armed. Only this test opens
// https in the unit-test JVM (ReportUploadWorker's endpoint is blank and never opened), so a
// permanent https handler is safe here.
init {
URL.setURLStreamHandlerFactory(
URLStreamHandlerFactory { protocol ->
if (protocol == "https") {
object : URLStreamHandler() {
override fun openConnection(u: URL): URLConnection =
armed.get()?.invoke(u) ?: throw IOException("no fake connection armed")
}
} else {
null
}
},
)
}
}
}
@@ -1,10 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import android.util.Log
import com.icegreen.greenmail.util.GreenMail
import com.icegreen.greenmail.util.GreenMailUtil
import com.icegreen.greenmail.util.ServerSetupTest
import io.mockk.every
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import jakarta.activation.DataHandler
import jakarta.mail.Flags
import jakarta.mail.Folder
import jakarta.mail.Message
import jakarta.mail.Part
@@ -14,13 +19,20 @@ import jakarta.mail.internet.MimeBodyPart
import jakarta.mail.internet.MimeMessage
import jakarta.mail.internet.MimeMultipart
import jakarta.mail.util.ByteArrayDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import java.util.Properties
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
@@ -41,6 +53,7 @@ class ImapClientTest {
@After
fun tearDown() {
greenMail.stop()
unmockkAll()
}
private fun params(secret: String = "secret") = ImapConnectionParams(
@@ -187,6 +200,142 @@ class ImapClientTest {
assertEquals(setOf("Inbox subject"), inbox.map { it.subject }.toSet())
}
@Test
fun `fetchAttachment downloads a part's bytes by its index`() = runTest {
appendInlineImageDigest() // part 0 = inline logo.png, part 1 = invoice.pdf
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
val inline = client.fetchAttachment(params(), "INBOX", uid, 0)
val attachment = client.fetchAttachment(params(), "INBOX", uid, 1)
assertEquals("logo.png", inline.filename)
assertContentEquals(byteArrayOf(1, 2, 3, 4), inline.bytes)
assertEquals("invoice.pdf", attachment.filename)
assertContentEquals(byteArrayOf(5, 6, 7), attachment.bytes)
assertTrue(attachment.mimeType.contains("pdf", ignoreCase = true), attachment.mimeType)
}
@Test
fun `fetchAttachment fails for an out-of-range part index`() = runTest {
appendInlineImageDigest()
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
assertFailsWith<Exception> { client.fetchAttachment(params(), "INBOX", uid, 99) }
}
@Test
fun `fetchAttachment fails when the message is not found`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Solo", "Body")
greenMail.waitForIncomingEmail(1)
assertFailsWith<Exception> { client.fetchAttachment(params(), "INBOX", "999999", 0) }
}
@Test
fun `setFlag marks a message flagged on the server`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Star me", "Body")
greenMail.waitForIncomingEmail(1)
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first().uid
client.setFlag(params(), "INBOX", uid, Flags.Flag.FLAGGED, value = true)
assertTrue(client.fetchRecent(params(), "INBOX", limit = 50).first().isFlagged, "should be flagged")
}
@Test
fun `setFlag on an unknown uid is a no-op`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Present", "Body")
greenMail.waitForIncomingEmail(1)
// No message has this uid, so getMessageByUID returns null and setFlag simply does nothing.
client.setFlag(params(), "INBOX", "999999", Flags.Flag.FLAGGED, value = true)
assertFalse(client.fetchRecent(params(), "INBOX", limit = 50).first().isFlagged)
}
@Test
fun `deleteMessage expunges the message from the folder`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Delete me", "Body")
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Keep me", "Body")
greenMail.waitForIncomingEmail(2)
val uid = client.fetchRecent(params(), "INBOX", limit = 50).first { it.subject == "Delete me" }.uid
client.deleteMessage(params(), "INBOX", uid)
val remaining = client.fetchRecent(params(), "INBOX", limit = 50).map { it.subject }
assertFalse(remaining.contains("Delete me"), "remaining=$remaining")
assertTrue(remaining.contains("Keep me"), "remaining=$remaining")
}
@Test
fun `fetchRecent returns empty for an empty folder`() = runTest {
createFolder("Empty")
assertTrue(client.fetchRecent(params(), "Empty", limit = 50).isEmpty())
}
@Test
fun `body and reply fetches fail for an unknown uid`() = runTest {
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Present", "Body")
greenMail.waitForIncomingEmail(1)
assertFailsWith<Exception> { client.fetchBodyMarkingSeen(params(), "INBOX", "999999") }
assertFailsWith<Exception> { client.fetchBodyPeek(params(), "INBOX", "999999") }
assertFailsWith<Exception> { client.fetchForReply(params(), "INBOX", "999999") }
}
@Test
fun `STARTTLS and XOAUTH2 params drive the corresponding connection properties`() = runTest {
// GreenMail here is plaintext with no XOAUTH2, so the connect fails — but buildProps has already
// run, which is the STARTTLS + XOAUTH2 property wiring this exercises.
val params = ImapConnectionParams(
host = "127.0.0.1",
port = greenMail.imap.port,
security = MailSecurity.STARTTLS,
username = "alice@example.org",
secret = "secret",
useXoauth2 = true,
strictStartTls = true,
)
assertFailsWith<Exception> { client.listFolders(params) }
}
@Test
fun `idle syncs once on connect, again on newly delivered mail, and stops on cancel`() = runBlocking {
mockkStatic(Log::class) // idle() logs connect/push at debug level
every { Log.d(any(), any()) } returns 0
val activity = Channel<Unit>(Channel.UNLIMITED)
val job = launch(Dispatchers.IO) { client.idle(params()) { activity.send(Unit) } }
try {
// A sync fires immediately on connect to catch anything already waiting...
withTimeout(IDLE_TIMEOUT_MS) { activity.receive() }
// ...then an IMAP IDLE push fires another when new mail arrives.
GreenMailUtil.sendTextEmailTest("alice@example.org", "bob@example.org", "Pushed", "Body")
withTimeout(IDLE_TIMEOUT_MS) { activity.receive() }
} finally {
job.cancelAndJoin() // cancelling closes the connection and unblocks idle()
}
assertTrue(job.isCompleted, "the idle loop must terminate on cancellation")
}
/** Creates [folderName] (holding messages) if it does not already exist. */
private fun createFolder(folderName: String) {
val props = Properties().apply {
put("mail.store.protocol", "imap")
put("mail.imap.host", "127.0.0.1")
put("mail.imap.port", greenMail.imap.port.toString())
}
val store = Session.getInstance(props).getStore("imap")
store.connect("127.0.0.1", greenMail.imap.port, "alice@example.org", "secret")
try {
val folder = store.getFolder(folderName)
if (!folder.exists()) folder.create(Folder.HOLDS_MESSAGES)
} finally {
store.close()
}
}
/**
* Appends a rich digest to the INBOX: a `multipart/mixed` of a `multipart/related` (HTML body
* referencing an inline image via `cid:logo1`) plus a genuine PDF attachment — the shape that
@@ -275,4 +424,9 @@ class ImapClientTest {
store.close()
}
}
private companion object {
/** Generous ceiling for the in-process IDLE round trip so the assertion never races the server. */
const val IDLE_TIMEOUT_MS = 15_000L
}
}
@@ -0,0 +1,140 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import io.mockk.mockk
import io.mockk.verify
import jakarta.mail.Folder
import jakarta.mail.FolderClosedException
import jakarta.mail.MessagingException
import jakarta.mail.Store
import jakarta.mail.StoreClosedException
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
/**
* The connection-reuse cache (issue #125 spike): one authenticated [Store] per account behind a
* mutex, established lazily and kept open. These tests pin the reuse guarantee and the lazy
* catch-and-retry-once stale handling — a dropped connection is rebuilt and the op retried, a second
* failure clears the slot, and a genuine protocol error is propagated without ever reconnecting.
*/
class ImapConnectionCacheTest {
private val params = ImapConnectionParams(
host = "127.0.0.1",
port = 143,
security = MailSecurity.NONE,
username = "alice@example.org",
secret = "secret",
useXoauth2 = false,
)
private var connects = 0
/** A cache whose connect step counts calls and returns [supply] (a fresh relaxed [Store] by default). */
private fun cache(supply: () -> Store = { mockk(relaxed = true) }) = ImapConnectionCache {
connects++
supply()
}
@Test
fun `establishes one connection and reuses it across calls`() = runTest {
val cache = cache()
assertEquals("a", cache.withStore(params) { "a" })
assertEquals("b", cache.withStore(params) { "b" })
assertEquals(1, connects, "the second op reuses the first connection")
}
@Test
fun `rebuilds the socket once and retries when the connection drops`() = runTest {
val opened = mutableListOf<Store>()
val cache = cache {
mockk<Store>(relaxed = true).also { opened += it }
}
var attempts = 0
val result = cache.withStore(params) {
attempts++
if (attempts == 1) throw IOException("dropped") else "recovered"
}
assertEquals("recovered", result)
assertEquals(2, connects, "a dropped connection is rebuilt exactly once")
verify { opened[0].close() } // the stale socket is torn down before reconnecting
}
@Test
fun `a second failure after reconnect clears the slot so the next call reconnects`() = runTest {
val cache = cache()
assertFailsWith<IOException> { cache.withStore(params) { throw IOException("still down") } }
assertEquals(2, connects, "initial connect plus one rebuild")
cache.withStore(params) { "ok" }
assertEquals(3, connects, "the cleared slot forces a fresh connect")
}
@Test
fun `a genuine protocol error is propagated without reconnecting`() = runTest {
val cache = cache()
assertFailsWith<IllegalStateException> {
cache.withStore(params) { throw IllegalStateException("bad login") }
}
assertEquals(1, connects, "a non-drop error must not trigger a reconnect")
}
@Test
fun `folder-closed, store-closed, IO and IO-caused messaging errors all count as drops`() = runTest {
retriesOn(FolderClosedException(mockk<Folder>(relaxed = true)))
retriesOn(StoreClosedException(mockk<Store>(relaxed = true)))
retriesOn(IOException("socket"))
retriesOn(MessagingException("wrapped", IOException("socket")))
}
@Test
fun `a messaging error without an IO cause is not a drop`() = runTest {
val cache = cache()
assertFailsWith<MessagingException> {
cache.withStore(params) { throw MessagingException("server said no") }
}
assertEquals(1, connects)
}
@Test
fun `closeAll tears down and forgets every cached connection`() = runTest {
val store = mockk<Store>(relaxed = true)
val cache = cache { store }
cache.withStore(params) { "a" }
cache.closeAll()
verify { store.close() }
cache.withStore(params) { "b" }
assertEquals(2, connects, "after closeAll the next op reconnects")
}
/** Asserts [error] is treated as a dropped connection: rebuilt once, the retry succeeds. */
private suspend fun retriesOn(error: Throwable) {
connects = 0
val cache = cache()
var attempts = 0
val result = cache.withStore(params) {
attempts++
if (attempts == 1) throw error else "ok"
}
assertEquals("ok", result)
assertEquals(2, connects, "${error.javaClass.simpleName} should have been retried on a fresh socket")
}
}
@@ -11,6 +11,7 @@ import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.libremail.data.settings.SettingsRepository
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Covers [CrashReporter.install]: the installed handler must persist the crash locally AND still chain
@@ -63,4 +64,26 @@ class CrashReporterInstallTest {
// ...and the OS's original handler still ran, so the system crash still surfaces.
verify { previous.uncaughtException(thread, crash) }
}
@Test
fun `only a genuine uncaught exception creates a crash report - update, force-stop, swipe-away do not`() {
Thread.setDefaultUncaughtExceptionHandler(mockk(relaxed = true))
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())
// An app update (killDueToPackageUpdate), a force-stop, and a user swipe-away/task-removal all
// end the process WITHOUT delivering an uncaught throwable to this handler, so none of them
// creates a report and the startup prompt stays silent (#255 criterion 3). Reports come solely
// from a genuine uncaught crash routing through the installed handler.
assertTrue(store.reports.value.isEmpty())
installed.uncaughtException(Thread.currentThread(), IllegalStateException("real crash"))
assertEquals(1, store.reports.value.size)
assertEquals(ReportKind.CRASH, store.reports.value.single().kind)
}
}
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.reporting
import org.json.JSONObject
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -93,4 +95,30 @@ class DebugReportTest {
assertTrue(payload.contains("reporter@example.com"))
}
@Test
fun `surfaced flag round-trips through storage json`() {
val original = sample().copy(surfaced = true)
val restored = DebugReport.fromStorageJson(original.toStorageJson())
assertTrue(restored.surfaced)
assertEquals(original, restored)
}
@Test
fun `a legacy stored report without the surfaced flag reads as not surfaced`() {
val legacy = JSONObject(sample().toStorageJson()).apply { remove("surfaced") }.toString()
val restored = DebugReport.fromStorageJson(legacy)
assertFalse(restored.surfaced)
}
@Test
fun `the internal surfaced flag never appears in the submission payload`() {
val payload = sample().copy(surfaced = true).toSubmissionPayload()
assertFalse(payload.contains("surfaced"))
}
}
@@ -69,6 +69,27 @@ class ReportStoreTest {
assertEquals("persisted", reopened.find("persisted")?.id)
}
@Test
fun `markSurfaced flags the report and persists across a fresh instance`() {
val store = ReportStore(tempFolder.root)
store.save(report("a"))
store.markSurfaced("a")
assertTrue(store.find("a")!!.surfaced)
// Survives a fresh instance over the same directory (the next launch reads it as surfaced).
assertTrue(ReportStore(tempFolder.root).find("a")!!.surfaced)
}
@Test
fun `markSurfaced is a no-op for a missing report`() {
val store = ReportStore(tempFolder.root)
store.markSurfaced("missing")
assertTrue(store.reports.value.isEmpty())
}
@Test
fun `ignores unparseable files`() {
File(tempFolder.root, "garbage.json").writeText("not json at all")
@@ -1,15 +1,10 @@
// 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.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
@@ -18,25 +13,41 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.libremail.reporting.DebugReport
import org.libremail.reporting.ReportKind
import org.libremail.reporting.ReportStore
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Verifies the #255 gating: the startup crash prompt surfaces a [ReportKind.CRASH] report only when it
* is fresh (< 24h), unseen (first re-open only, persisted across relaunches), and a real crash. Uses a
* real file-backed [ReportStore] over a temp dir so the persisted `surfaced` flag round-trips exactly
* as it would across a process restart, and a fixed clock so the age gate is deterministic.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class StartupReportViewModelTest {
@get:Rule
val tempFolder = TemporaryFolder()
private val dispatcher = UnconfinedTestDispatcher()
private val now = 1_000_000_000_000L
private val dayMs = 24L * 60 * 60 * 1000
@Before
fun setUp() = Dispatchers.setMain(dispatcher)
@After
fun tearDown() = Dispatchers.resetMain()
private fun report(id: String, kind: ReportKind, createdAt: Long = 1L) = DebugReport(
private fun report(id: String, kind: ReportKind, createdAt: Long) = DebugReport(
id = id,
createdAtMillis = createdAt,
kind = kind,
@@ -51,59 +62,105 @@ class StartupReportViewModelTest {
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)
private fun crash(id: String, createdAt: Long) = report(id, ReportKind.CRASH, createdAt)
private fun store() = ReportStore(tempFolder.root)
private fun viewModel(store: ReportStore) = StartupReportViewModel(store, now = { now })
/** Subscribes to [StartupReportViewModel.pendingCrash] so the `WhileSubscribed` flow starts. */
private fun TestScope.subscribe(vm: StartupReportViewModel) {
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)
fun `surfaces a fresh unseen crash`() = runTest(dispatcher) {
val store = store()
store.save(crash("c", createdAt = now - 1_000L))
val vm = viewModel(store)
subscribe(vm)
backgroundScope.launch { vm.pendingCrash.collect {} }
runCurrent()
assertEquals(ReportSummary("c", ReportKind.CRASH, now - 1_000L), vm.pendingCrash.value)
}
@Test
fun `does not surface a crash older than 24h`() = runTest(dispatcher) {
val store = store()
store.save(crash("old", createdAt = now - dayMs - 1))
val vm = viewModel(store)
subscribe(vm)
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)
fun `surfaces a crash exactly at the 24h boundary`() = runTest(dispatcher) {
val store = store()
store.save(crash("edge", createdAt = now - dayMs))
val vm = viewModel(store)
subscribe(vm)
backgroundScope.launch { vm.pendingCrash.collect {} }
runCurrent()
vm.dismiss()
runCurrent()
assertNull(vm.pendingCrash.value)
verify(exactly = 0) { store.delete(any()) }
assertEquals("edge", vm.pendingCrash.value?.id)
}
@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)
fun `ignores non-crash reports`() = runTest(dispatcher) {
val store = store()
store.save(report("m", ReportKind.MANUAL, createdAt = now))
val vm = viewModel(store)
subscribe(vm)
assertNull(vm.pendingCrash.value)
}
@Test
fun `surfaces the newest eligible crash, skipping surfaced and stale ones`() = runTest(dispatcher) {
val store = store()
store.save(crash("stale", createdAt = now - dayMs - 1))
store.save(crash("fresh", createdAt = now - 2_000L))
store.save(crash("seen", createdAt = now - 1_000L))
store.markSurfaced("seen")
val vm = viewModel(store)
subscribe(vm)
assertEquals("fresh", vm.pendingCrash.value?.id)
}
@Test
fun `dismiss marks the crash surfaced so it does not reappear on relaunch`() = runTest(dispatcher) {
val store = store()
store.save(crash("c", createdAt = now))
val vm = viewModel(store)
subscribe(vm)
assertNotNull(vm.pendingCrash.value)
vm.dismiss("c")
advanceUntilIdle()
// Hidden this launch, still stored (not deleted), and now flagged surfaced.
assertNull(vm.pendingCrash.value)
assertNotNull(store.find("c"))
assertTrue(store.find("c")!!.surfaced)
// A fresh store + VM (a new process) reads the persisted flag → never re-nags.
val relaunchStore = store()
val relaunchVm = viewModel(relaunchStore)
subscribe(relaunchVm)
assertNull(relaunchVm.pendingCrash.value)
}
@Test
fun `discard deletes the report`() = runTest(dispatcher) {
val store = store()
store.save(crash("c", createdAt = now))
val vm = viewModel(store)
subscribe(vm)
backgroundScope.launch { vm.pendingCrash.collect {} }
runCurrent()
vm.discard("c")
advanceUntilIdle()
assertNull(vm.pendingCrash.value)
verify(exactly = 1) { store.delete("c") }
assertNull(store.find("c"))
}
}