Merge main into feat-74-75-compose-color-highlight
This commit is contained in:
@@ -30,6 +30,7 @@ import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.DraftEntity
|
||||
import org.libremail.data.local.entity.FolderEntity
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.MessageRouting
|
||||
import org.libremail.data.local.entity.MessageSummary
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
@@ -176,6 +177,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `openMessage fetches the body from the message's own folder, not the inbox`() = runTest {
|
||||
val id = "acct:Archive:5"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "Archive")
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "Archive")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -194,6 +196,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `openMessage derives a readable plain-text snippet from an HTML body`() = runTest {
|
||||
val id = "acct:INBOX:20"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -224,6 +227,7 @@ class MailRepositoryImplTest {
|
||||
// (CompletableDeferred.complete), and JUnit4 requires @Test methods to return void/Unit.
|
||||
runBlocking {
|
||||
val id = "acct:INBOX:40"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX", bodyFetched = true)
|
||||
coEvery { messageDao.getById(id) } returns
|
||||
messageEntity(id, "INBOX", bodyFetched = true) // isRead = false
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
@@ -252,6 +256,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `a failed SEEN-flag push is retried in the background`() = runBlocking {
|
||||
val id = "acct:INBOX:41"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX", bodyFetched = true)
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX", bodyFetched = true)
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -267,12 +272,35 @@ class MailRepositoryImplTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openMessage does no credential, network, or second full read for a cached read message`() = runTest {
|
||||
val id = "acct:INBOX:42"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX", bodyFetched = true, isRead = true)
|
||||
coEvery { messageDao.getById(id) } returns
|
||||
messageEntity(id, "INBOX", bodyFetched = true, isRead = true, body = "Cached body")
|
||||
|
||||
val result = repository.openMessage(id)
|
||||
|
||||
// The reader still gets the stored body back...
|
||||
assertEquals("Cached body", result.getOrThrow().body)
|
||||
// ...but a cached, already-read open resolves no account/credentials, touches no network, and
|
||||
// never re-marks the row read (issue #186 — the Keystore decrypt + DataStore read are skipped).
|
||||
coVerify(exactly = 0) { accountDao.getById(any()) }
|
||||
coVerify(exactly = 0) { connectionFactory.imapParamsFor(any()) }
|
||||
coVerify(exactly = 0) { imapClient.fetchBodyMarkingSeen(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { imapClient.setFlag(any(), any(), any(), any(), any()) }
|
||||
coVerify(exactly = 0) { messageDao.setRead(any(), any()) }
|
||||
// Routing used the body-less projection; the only full-body read is the single returned value.
|
||||
coVerify(exactly = 1) { messageDao.getRouting(id) }
|
||||
coVerify(exactly = 1) { messageDao.getById(id) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prefetchMessage leaves a plain-text body's literal angle brackets in the snippet`() = runTest {
|
||||
val cache = Files.createTempDirectory("attach").toFile()
|
||||
every { context.cacheDir } returns cache
|
||||
val id = "acct:INBOX:21"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { imapClient.fetchBodyPeek(any(), "INBOX", "21") } returns
|
||||
@@ -290,7 +318,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `archive moves messages to the account's archive folder and drops the local rows`() = runTest {
|
||||
val id = "acct:INBOX:5"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
|
||||
coEvery { messageDao.deleteByIds(listOf(id)) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -306,7 +334,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `trash falls back to a permanent delete when the account has no trash folder`() = runTest {
|
||||
val id = "acct:INBOX:7"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -324,7 +352,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `archive fails when the account has no archive folder`() = runTest {
|
||||
val id = "acct:INBOX:9"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -338,7 +366,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `expunge permanently deletes each message from its own folder`() = runTest {
|
||||
val id = "acct:Trash:3"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "Trash")
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "Trash"))
|
||||
coEvery { messageDao.deleteByIds(listOf(id)) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -351,7 +379,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `moveToFolder moves messages to the chosen destination`() = runTest {
|
||||
val id = "acct:INBOX:11"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
|
||||
coEvery { messageDao.deleteByIds(listOf(id)) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -364,7 +392,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `buildReplyDraft fetches the original and saves a prefilled draft`() = runTest {
|
||||
val id = "acct:INBOX:2"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
|
||||
coEvery { signatureRepository.getDefault(any()) } returns null
|
||||
@@ -393,7 +421,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `buildReplyDraft bakes the account default signature above the quote`() = runTest {
|
||||
val id = "acct:INBOX:3"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct")
|
||||
coEvery { signatureRepository.getDefault("acct") } returns org.libremail.domain.model.Signature(
|
||||
@@ -429,7 +457,7 @@ class MailRepositoryImplTest {
|
||||
val cache = Files.createTempDirectory("attach").toFile()
|
||||
every { context.cacheDir } returns cache
|
||||
val id = "acct:INBOX:4"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "report.pdf"))
|
||||
val cached = File(cache, "attachments/acct_INBOX_4/0/report.pdf").apply {
|
||||
parentFile?.mkdirs()
|
||||
@@ -447,7 +475,7 @@ class MailRepositoryImplTest {
|
||||
val cache = Files.createTempDirectory("attach").toFile()
|
||||
every { context.cacheDir } returns cache
|
||||
val id = "acct:INBOX:6"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "a.txt"))
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -482,7 +510,7 @@ class MailRepositoryImplTest {
|
||||
val cache = Files.createTempDirectory("attach").toFile()
|
||||
every { context.cacheDir } returns cache
|
||||
val id = "acct:INBOX:30"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { attachmentDao.getForMessage(id) } returns listOf(
|
||||
AttachmentEntity(id, 0, "logo.png", "image/png", 4, contentId = "logo1"),
|
||||
AttachmentEntity(id, 1, "invoice.pdf", "application/pdf", 10, contentId = null),
|
||||
@@ -508,7 +536,7 @@ class MailRepositoryImplTest {
|
||||
val cache = Files.createTempDirectory("attach").toFile()
|
||||
every { context.cacheDir } returns cache
|
||||
val id = "acct:INBOX:10"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") // bodyFetched = false
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX") // bodyFetched = false
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { imapClient.fetchBodyPeek(any(), "INBOX", "10") } returns
|
||||
@@ -533,8 +561,10 @@ class MailRepositoryImplTest {
|
||||
fun `archive resolves a separate destination folder for each account`() = runTest {
|
||||
val id1 = "acct:INBOX:1"
|
||||
val id2 = "acct2:INBOX:1"
|
||||
coEvery { messageDao.getById(id1) } returns messageEntity(id1, "INBOX", accountId = "acct")
|
||||
coEvery { messageDao.getById(id2) } returns messageEntity(id2, "INBOX", accountId = "acct2")
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id1, id2)) } returns listOf(
|
||||
messageRouting(id1, "INBOX", accountId = "acct"),
|
||||
messageRouting(id2, "INBOX", accountId = "acct2"),
|
||||
)
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity(id = "acct")
|
||||
coEvery { accountDao.getById("acct2") } returns accountEntity(id = "acct2", email = "bob@example.org")
|
||||
@@ -554,7 +584,7 @@ class MailRepositoryImplTest {
|
||||
val cache = Files.createTempDirectory("attach").toFile()
|
||||
every { context.cacheDir } returns cache
|
||||
val id = "acct:INBOX:12"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX", bodyFetched = true)
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX", bodyFetched = true)
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { attachmentDao.getForMessage(id) } returns listOf(attachmentEntity(id, 0, "f.bin"))
|
||||
@@ -570,7 +600,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `archive refreshes folders once and retries when the archive folder is initially missing`() = runTest {
|
||||
val id = "acct:INBOX:14"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -590,7 +620,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `reportSpam prefers the special-use spam folder when the user folder is listed first`() = runTest {
|
||||
val id = "acct:INBOX:16"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -610,7 +640,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `reportSpam prefers the special-use spam folder when it is listed first`() = runTest {
|
||||
val id = "acct:INBOX:18"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -629,7 +659,7 @@ class MailRepositoryImplTest {
|
||||
@Test
|
||||
fun `reportSpam keeps the first listed folder when no special-use folder holds the role`() = runTest {
|
||||
val id = "acct:INBOX:20"
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX")
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
|
||||
coEvery { messageDao.deleteByIds(any()) } just Runs
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
@@ -658,21 +688,44 @@ class MailRepositoryImplTest {
|
||||
private fun attachmentEntity(messageId: String, partIndex: Int, filename: String) =
|
||||
AttachmentEntity(messageId, partIndex, filename, "application/octet-stream", 10L)
|
||||
|
||||
private fun messageEntity(id: String, folder: String, accountId: String = "acct", bodyFetched: Boolean = false) =
|
||||
MessageEntity(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "snippet",
|
||||
body = "",
|
||||
timestampMillis = 1_000L,
|
||||
isRead = false,
|
||||
isStarred = false,
|
||||
folder = folder,
|
||||
bodyFetched = bodyFetched,
|
||||
)
|
||||
private fun messageEntity(
|
||||
id: String,
|
||||
folder: String,
|
||||
accountId: String = "acct",
|
||||
bodyFetched: Boolean = false,
|
||||
isRead: Boolean = false,
|
||||
body: String = "",
|
||||
) = MessageEntity(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "snippet",
|
||||
body = body,
|
||||
timestampMillis = 1_000L,
|
||||
isRead = isRead,
|
||||
isStarred = false,
|
||||
folder = folder,
|
||||
bodyFetched = bodyFetched,
|
||||
)
|
||||
|
||||
private fun messageRouting(
|
||||
id: String,
|
||||
folder: String,
|
||||
accountId: String = "acct",
|
||||
bodyFetched: Boolean = false,
|
||||
isRead: Boolean = false,
|
||||
) = MessageRouting(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
folder = folder,
|
||||
uid = id.substringAfterLast(':').toLongOrNull() ?: 0L,
|
||||
isRead = isRead,
|
||||
isStarred = false,
|
||||
bodyFetched = bodyFetched,
|
||||
isHtml = false,
|
||||
)
|
||||
|
||||
private fun messageSummary(id: String, folder: String, accountId: String = "acct", bodyFetched: Boolean = false) =
|
||||
MessageSummary(
|
||||
|
||||
@@ -96,7 +96,9 @@ class MailProviderTest {
|
||||
// generic Apple ID sign-in page (issue #153).
|
||||
assertEquals("https://support.apple.com/en-us/102660", MailProvider.ICLOUD.twoFactorHelpUrl)
|
||||
|
||||
// Yahoo gates nothing on two-factor, so it must not grow the extra link.
|
||||
// Yahoo gates nothing on two-factor — reconfirmed directly against Yahoo's own help docs,
|
||||
// which never list two-step verification as a prerequisite for generating an app password
|
||||
// (issue #155) — so it must not grow the extra link.
|
||||
assertNull(MailProvider.YAHOO.twoFactorHelpUrl)
|
||||
}
|
||||
|
||||
@@ -105,6 +107,14 @@ class MailProviderTest {
|
||||
assertEquals("https://support.apple.com/en-us/102654", MailProvider.ICLOUD.appPasswordHelpUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yahoo app-password help points at Yahoo's step-by-step instructions, not the generic sign-in page`() {
|
||||
assertEquals(
|
||||
"https://my.help.yahoo.com/kb/mail/generate-app-specific-password-sln15241.html",
|
||||
MailProvider.YAHOO.appPasswordHelpUrl,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `createAccount trims the email and derives a stable id and display name`() {
|
||||
val account = MailProvider.GMAIL.createAccount(" User@Gmail.com ")
|
||||
|
||||
@@ -19,6 +19,7 @@ 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.InlineImage
|
||||
import org.libremail.domain.model.Message
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.ui.navigation.Routes
|
||||
@@ -66,6 +67,28 @@ class ReaderViewModelTest {
|
||||
assertEquals(setOf(0), vm.state.value.downloaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inline images resolve into the same state update as the body so the reader renders once`() =
|
||||
runTest(dispatcher) {
|
||||
val htmlMessage = message.copy(isHtml = true, body = "<img src=\"cid:logo\">")
|
||||
val image = InlineImage("logo", "image/png", byteArrayOf(1, 2, 3))
|
||||
val repo = mockk<MailRepository>(relaxed = true)
|
||||
coEvery { repo.openMessage(messageId) } returns Result.success(htmlMessage)
|
||||
coEvery { repo.inlineImages(messageId) } returns listOf(image)
|
||||
every { repo.observeAttachments(messageId) } returns flowOf(emptyList())
|
||||
coEvery { repo.downloadedAttachmentParts(messageId) } returns emptySet()
|
||||
|
||||
val vm = viewModel(repo)
|
||||
advanceUntilIdle()
|
||||
|
||||
val state = vm.state.value
|
||||
// The body and its inline cid: image land together (loading already false), so HtmlBody
|
||||
// first composes with the image in place and loads the WebView exactly once (issue #186).
|
||||
assertEquals(false, state.loading)
|
||||
assertEquals(htmlMessage, state.message)
|
||||
assertEquals(setOf("logo"), state.inlineImages.keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloading an attachment adds its part to the downloaded set`() = runTest(dispatcher) {
|
||||
val repo = mockk<MailRepository>(relaxed = true)
|
||||
|
||||
Reference in New Issue
Block a user