Merge branch 'main' into test-249-coverage-viewmodels-nonui
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import org.junit.Test
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AccountSettingsEntity
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.FolderUnreadCount
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.MessageSummary
|
||||
import org.libremail.data.local.entity.OutboxEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.mail.AttachmentPart
|
||||
import org.libremail.mail.FetchedMessage
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Round-trips the entity<->domain mappers in `Mappers.kt` that the higher-level repository tests don't
|
||||
* already exercise field-by-field: the account/settings/message/attachment/outbox mappers plus the
|
||||
* connection-param builders. Pins the persisted-enum fallbacks (an unknown `authType`/`security` name
|
||||
* must degrade to a safe default rather than throw) and the composite-id / uid-parse rules that
|
||||
* [FetchedMessage.toEntity] encodes.
|
||||
*/
|
||||
class MappersTest {
|
||||
|
||||
@Test
|
||||
fun `Account round-trips through the entity, preserving auth type and both server configs`() {
|
||||
val account = Account(
|
||||
id = "outlook:me@outlook.com",
|
||||
email = "me@outlook.com",
|
||||
displayName = "Me",
|
||||
authType = AuthType.OAUTH_OUTLOOK,
|
||||
imap = ServerConfig("outlook.office365.com", 993, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig("smtp.office365.com", 587, MailSecurity.STARTTLS),
|
||||
)
|
||||
|
||||
assertEquals(account, account.toEntity().toDomain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AccountEntity toDomain falls back to safe defaults for unknown persisted enum names`() {
|
||||
val entity = AccountEntity(
|
||||
id = "acct",
|
||||
email = "ada@example.org",
|
||||
displayName = "Ada",
|
||||
authType = "SOME_FUTURE_AUTH",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "MYSTERY_SECURITY"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
)
|
||||
|
||||
val account = entity.toDomain()
|
||||
|
||||
// An unrecognized authType degrades to PASSWORD_IMAP; an unrecognized security to SSL_TLS.
|
||||
assertEquals(AuthType.PASSWORD_IMAP, account.authType)
|
||||
assertEquals(MailSecurity.SSL_TLS, account.imap.security)
|
||||
assertEquals(MailSecurity.SSL_TLS, account.smtp.security)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Account toImapParams carries the imap config and auth options with strict STARTTLS by default`() {
|
||||
val params = account().toImapParams(secret = "s3cret", useXoauth2 = true)
|
||||
|
||||
assertEquals("imap.example.org", params.host)
|
||||
assertEquals(993, params.port)
|
||||
assertEquals(MailSecurity.SSL_TLS, params.security)
|
||||
assertEquals("ada@example.org", params.username)
|
||||
assertEquals("s3cret", params.secret)
|
||||
assertTrue(params.useXoauth2)
|
||||
assertTrue(params.strictStartTls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Account toImapParams honors an explicit non-strict STARTTLS flag`() {
|
||||
assertFalse(account().toImapParams(secret = "s", useXoauth2 = false, strictStartTls = false).strictStartTls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Account toSmtpParams carries the smtp config and auth options with strict STARTTLS by default`() {
|
||||
val params = account().toSmtpParams(secret = "token", useXoauth2 = true)
|
||||
|
||||
assertEquals("smtp.example.org", params.host)
|
||||
assertEquals(587, params.port)
|
||||
assertEquals(MailSecurity.STARTTLS, params.security)
|
||||
assertEquals("ada@example.org", params.username)
|
||||
assertEquals("token", params.secret)
|
||||
assertTrue(params.useXoauth2)
|
||||
assertTrue(params.strictStartTls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Account toSmtpParams honors an explicit non-strict STARTTLS flag`() {
|
||||
assertFalse(account().toSmtpParams(secret = "s", useXoauth2 = false, strictStartTls = false).strictStartTls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AccountSettings round-trips through the entity including retention overrides`() {
|
||||
val settings = AccountSettings(
|
||||
accountId = "acct",
|
||||
signature = "Regards, Ada",
|
||||
signatureEnabled = false,
|
||||
notificationsEnabled = false,
|
||||
retentionCount = 500,
|
||||
retentionMonths = 12,
|
||||
)
|
||||
|
||||
assertEquals(settings, settings.toEntity().toDomain())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AccountSettingsEntity toDomain keeps null retention overrides and default toggles`() {
|
||||
val domain = AccountSettingsEntity("acct").toDomain()
|
||||
|
||||
assertEquals("acct", domain.accountId)
|
||||
assertEquals("", domain.signature)
|
||||
assertTrue(domain.signatureEnabled)
|
||||
assertTrue(domain.notificationsEnabled)
|
||||
assertNull(domain.retentionCount)
|
||||
assertNull(domain.retentionMonths)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MessageEntity toDomain carries every field including the full body`() {
|
||||
val entity = MessageEntity(
|
||||
id = "acct:INBOX:1",
|
||||
accountId = "acct",
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "snip",
|
||||
body = "Full body",
|
||||
isHtml = true,
|
||||
timestampMillis = 5L,
|
||||
isRead = true,
|
||||
isStarred = true,
|
||||
folder = "INBOX",
|
||||
inInbox = true,
|
||||
bodyFetched = true,
|
||||
uid = 1L,
|
||||
)
|
||||
|
||||
val message = entity.toDomain()
|
||||
|
||||
assertEquals("acct:INBOX:1", message.id)
|
||||
assertEquals("Full body", message.body)
|
||||
assertTrue(message.isHtml)
|
||||
assertTrue(message.isRead)
|
||||
assertTrue(message.isStarred)
|
||||
assertTrue(message.bodyFetched)
|
||||
assertEquals("snip", message.snippet)
|
||||
assertEquals(5L, message.timestampMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MessageSummary toDomain leaves the body empty because the list never renders it`() {
|
||||
val summary = MessageSummary(
|
||||
id = "acct:INBOX:2",
|
||||
accountId = "acct",
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "snip",
|
||||
timestampMillis = 5L,
|
||||
isRead = false,
|
||||
isStarred = false,
|
||||
folder = "INBOX",
|
||||
inInbox = true,
|
||||
bodyFetched = false,
|
||||
)
|
||||
|
||||
val message = summary.toDomain()
|
||||
|
||||
assertEquals("", message.body)
|
||||
assertFalse(message.isHtml)
|
||||
assertEquals("acct:INBOX:2", message.id)
|
||||
assertEquals("snip", message.snippet)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FetchedMessage toEntity builds a composite id and defaults the cached-body fields`() {
|
||||
val fetched = FetchedMessage(
|
||||
uid = "42",
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
timestampMillis = 1_000L,
|
||||
isRead = true,
|
||||
isFlagged = true,
|
||||
)
|
||||
|
||||
val entity = fetched.toEntity(accountId = "acct", folder = "INBOX")
|
||||
|
||||
assertEquals("acct:INBOX:42", entity.id)
|
||||
assertEquals(42L, entity.uid)
|
||||
assertEquals("acct", entity.accountId)
|
||||
assertEquals("INBOX", entity.folder)
|
||||
assertTrue(entity.isRead)
|
||||
// The server \Flagged flag maps onto the local starred column.
|
||||
assertTrue(entity.isStarred)
|
||||
// A freshly-listed header defaults to in-inbox with no cached body yet.
|
||||
assertTrue(entity.inInbox)
|
||||
assertFalse(entity.bodyFetched)
|
||||
assertEquals("", entity.snippet)
|
||||
assertEquals("", entity.body)
|
||||
assertFalse(entity.isHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FetchedMessage toEntity marks a search hit not-in-inbox and zeroes an unparseable uid`() {
|
||||
val fetched = FetchedMessage(
|
||||
uid = "not-a-number",
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
timestampMillis = 0L,
|
||||
isRead = false,
|
||||
isFlagged = false,
|
||||
)
|
||||
|
||||
val entity = fetched.toEntity(accountId = "acct", folder = "INBOX", inInbox = false)
|
||||
|
||||
assertEquals("acct:INBOX:not-a-number", entity.id)
|
||||
// A non-numeric uid can't be materialized, so it stores 0 (refreshed on the next sync).
|
||||
assertEquals(0L, entity.uid)
|
||||
assertFalse(entity.inInbox)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `FolderUnreadCount toDomain maps the aggregate row to the domain unread count`() {
|
||||
val domain = FolderUnreadCount(accountId = "acct", folder = "INBOX", unreadCount = 7).toDomain()
|
||||
|
||||
assertEquals("acct", domain.accountId)
|
||||
assertEquals("INBOX", domain.folder)
|
||||
assertEquals(7, domain.count)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AttachmentEntity toDomain carries the content id for an inline part`() {
|
||||
val domain = AttachmentEntity("m", 2, "logo.png", "image/png", 42L, contentId = "logo@cid").toDomain()
|
||||
|
||||
assertEquals("m", domain.messageId)
|
||||
assertEquals(2, domain.partIndex)
|
||||
assertEquals("logo.png", domain.filename)
|
||||
assertEquals("image/png", domain.mimeType)
|
||||
assertEquals(42L, domain.sizeBytes)
|
||||
assertEquals("logo@cid", domain.contentId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AttachmentEntity toDomain leaves an ordinary attachment content id null`() {
|
||||
assertNull(AttachmentEntity("m", 0, "a.pdf", "application/pdf", 1L).toDomain().contentId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AttachmentPart toEntity attaches the part metadata to its message`() {
|
||||
val entity = AttachmentPart(3, "cat.png", "image/png", 9L, contentId = "cat@cid").toEntity("msg-1")
|
||||
|
||||
assertEquals("msg-1", entity.messageId)
|
||||
assertEquals(3, entity.partIndex)
|
||||
assertEquals("cat.png", entity.filename)
|
||||
assertEquals("image/png", entity.mimeType)
|
||||
assertEquals(9L, entity.sizeBytes)
|
||||
assertEquals("cat@cid", entity.contentId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OutboxEntity toDomain exposes the queue fields, last error, and html body`() {
|
||||
val entity = OutboxEntity(
|
||||
id = "o1",
|
||||
accountId = "acct",
|
||||
toAddresses = "bob@example.org",
|
||||
ccAddresses = "",
|
||||
subject = "Hi",
|
||||
body = "hello",
|
||||
createdAt = 3L,
|
||||
lastError = "smtp 550",
|
||||
bodyHtml = "<p>hello</p>",
|
||||
)
|
||||
|
||||
val domain = entity.toDomain()
|
||||
|
||||
assertEquals("o1", domain.id)
|
||||
assertEquals("bob@example.org", domain.to)
|
||||
assertEquals("Hi", domain.subject)
|
||||
assertEquals("hello", domain.body)
|
||||
assertEquals(3L, domain.createdAt)
|
||||
assertEquals("smtp 550", domain.lastError)
|
||||
assertEquals("<p>hello</p>", domain.bodyHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed or blank attachment json decodes to an empty list`() {
|
||||
// Blank short-circuits; unparseable JSON is caught and defaulted rather than thrown.
|
||||
assertEquals(emptyList(), " ".toOutgoingAttachments())
|
||||
assertEquals(emptyList(), "not valid json".toOutgoingAttachments())
|
||||
}
|
||||
|
||||
private fun account() = Account(
|
||||
id = "imap:ada@example.org",
|
||||
email = "ada@example.org",
|
||||
displayName = "Ada",
|
||||
authType = AuthType.PASSWORD_IMAP,
|
||||
imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig("smtp.example.org", 587, MailSecurity.STARTTLS),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.repository
|
||||
|
||||
import app.cash.turbine.test
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.slot
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.BackfillProgressDao
|
||||
import org.libremail.data.local.dao.FolderDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.security.CredentialStore
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AuthType
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.ServerConfig
|
||||
import org.libremail.mail.FetchedFolder
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.notifications.MailNotifier
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Covers [AccountRepositoryImpl]'s add/remove/observe surface: the happy paths persist the account,
|
||||
* its credential, its notification channel and kick sync + backfill; the failure paths (a rejected
|
||||
* initial folder LIST) must persist nothing. Every collaborator is mocked, so the assertions pin the
|
||||
* repository's own orchestration, not the DAOs or the IMAP client.
|
||||
*/
|
||||
class AccountRepositoryImplTest {
|
||||
|
||||
private val accountDao = mockk<AccountDao>()
|
||||
private val messageDao = mockk<MessageDao>(relaxed = true)
|
||||
private val folderDao = mockk<FolderDao>(relaxed = true)
|
||||
private val backfillProgressDao = mockk<BackfillProgressDao>(relaxed = true)
|
||||
private val credentialStore = mockk<CredentialStore>(relaxed = true)
|
||||
private val imapClient = mockk<ImapClient>()
|
||||
private val syncScheduler = mockk<SyncScheduler>(relaxed = true)
|
||||
private val accountSettingsRepository = mockk<AccountSettingsRepository>(relaxed = true)
|
||||
private val mailNotifier = mockk<MailNotifier>(relaxed = true)
|
||||
|
||||
private val repository = AccountRepositoryImpl(
|
||||
accountDao = accountDao,
|
||||
messageDao = messageDao,
|
||||
folderDao = folderDao,
|
||||
backfillProgressDao = backfillProgressDao,
|
||||
credentialStore = credentialStore,
|
||||
imapClient = imapClient,
|
||||
syncScheduler = syncScheduler,
|
||||
accountSettingsRepository = accountSettingsRepository,
|
||||
mailNotifier = mailNotifier,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `observeAccounts maps the stored account rows to domain models`() = runTest {
|
||||
every { accountDao.observeAll() } returns flowOf(listOf(accountEntity()))
|
||||
|
||||
repository.observeAccounts().test {
|
||||
val accounts = awaitItem()
|
||||
assertEquals(1, accounts.size)
|
||||
assertEquals("acct", accounts.first().id)
|
||||
assertEquals("ada@example.org", accounts.first().email)
|
||||
assertEquals(AuthType.PASSWORD_IMAP, accounts.first().authType)
|
||||
assertEquals(MailSecurity.SSL_TLS, accounts.first().imap.security)
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testConnection returns the server's folder names on success`() = runTest {
|
||||
coEvery { imapClient.listFolders(params) } returns listOf(
|
||||
FetchedFolder("INBOX", "INBOX", emptyList(), selectable = true),
|
||||
FetchedFolder("[Gmail]/Sent Mail", "Sent Mail", emptyList(), selectable = true),
|
||||
)
|
||||
|
||||
val result = repository.testConnection(params)
|
||||
|
||||
assertEquals(listOf("INBOX", "[Gmail]/Sent Mail"), result.getOrThrow())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `testConnection wraps a connection failure in a failed result`() = runTest {
|
||||
coEvery { imapClient.listFolders(params) } throws RuntimeException("no route to host")
|
||||
|
||||
assertTrue(repository.testConnection(params).isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addImapAccount lists folders, persists the account, and starts syncing`() = runTest {
|
||||
val account = account()
|
||||
val used = slot<ImapConnectionParams>()
|
||||
coEvery { imapClient.listFolders(capture(used)) } returns listOf(
|
||||
FetchedFolder("INBOX", "INBOX", emptyList(), selectable = true),
|
||||
)
|
||||
coEvery { accountDao.upsert(any()) } just Runs
|
||||
|
||||
val result = repository.addImapAccount(account, "app-password")
|
||||
|
||||
assertEquals(listOf("INBOX"), result.getOrThrow())
|
||||
// A password account authenticates the LIST with the app password, not XOAUTH2.
|
||||
assertFalse(used.captured.useXoauth2)
|
||||
assertEquals("app-password", used.captured.secret)
|
||||
assertEquals("ada@example.org", used.captured.username)
|
||||
coVerify { accountDao.upsert(any()) }
|
||||
coVerify { accountSettingsRepository.ensureDefaults(account.id) }
|
||||
coVerify { credentialStore.saveSecret(account.id, "app-password") }
|
||||
verify { mailNotifier.ensureAccountChannel(account) }
|
||||
verify { syncScheduler.syncNow() }
|
||||
verify { syncScheduler.backfillNow() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addImapAccount persists nothing when the initial folder list fails`() = runTest {
|
||||
coEvery { imapClient.listFolders(any()) } throws RuntimeException("bad credentials")
|
||||
|
||||
val result = repository.addImapAccount(account(), "wrong")
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
coVerify(exactly = 0) { accountDao.upsert(any()) }
|
||||
coVerify(exactly = 0) { credentialStore.saveSecret(any(), any()) }
|
||||
verify(exactly = 0) { syncScheduler.syncNow() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addOutlookAccount builds an Outlook account and stores its durable auth state via XOAUTH2`() = runTest {
|
||||
val used = slot<ImapConnectionParams>()
|
||||
coEvery { imapClient.listFolders(capture(used)) } returns listOf(
|
||||
FetchedFolder("INBOX", "INBOX", emptyList(), selectable = true),
|
||||
)
|
||||
val saved = slot<AccountEntity>()
|
||||
coEvery { accountDao.upsert(capture(saved)) } just Runs
|
||||
|
||||
val result = repository.addOutlookAccount("me@outlook.com", "access-token", "{authstate}")
|
||||
|
||||
assertEquals(listOf("INBOX"), result.getOrThrow())
|
||||
// The short-lived access token authenticates the initial LIST over XOAUTH2...
|
||||
assertTrue(used.captured.useXoauth2)
|
||||
assertEquals("access-token", used.captured.secret)
|
||||
// ...but the persisted secret is the durable AuthState JSON the refresh flow later reads.
|
||||
coVerify { credentialStore.saveSecret("outlook:me@outlook.com", "{authstate}") }
|
||||
assertEquals("outlook:me@outlook.com", saved.captured.id)
|
||||
assertEquals(AuthType.OAUTH_OUTLOOK.name, saved.captured.authType)
|
||||
verify { syncScheduler.syncNow() }
|
||||
verify { syncScheduler.backfillNow() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addOutlookAccount fails and persists nothing when the token is rejected`() = runTest {
|
||||
coEvery { imapClient.listFolders(any()) } throws RuntimeException("401 unauthorized")
|
||||
|
||||
val result = repository.addOutlookAccount("me@outlook.com", "expired", "{authstate}")
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
coVerify(exactly = 0) { accountDao.upsert(any()) }
|
||||
coVerify(exactly = 0) { credentialStore.saveSecret(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteAccount removes the account, its credential, channel, and all cached data`() = runTest {
|
||||
coEvery { accountDao.deleteById("acct") } just Runs
|
||||
|
||||
repository.deleteAccount("acct")
|
||||
|
||||
coVerify { accountDao.deleteById("acct") }
|
||||
coVerify { credentialStore.delete("acct") }
|
||||
verify { mailNotifier.deleteAccountChannel("acct") }
|
||||
coVerify { messageDao.deleteByAccount("acct") }
|
||||
coVerify { folderDao.deleteForAccount("acct") }
|
||||
coVerify { backfillProgressDao.deleteForAccount("acct") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetBackfillProgress for one account clears only that account and re-kicks backfill`() = runTest {
|
||||
repository.resetBackfillProgress("acct")
|
||||
|
||||
coVerify { backfillProgressDao.deleteForAccount("acct") }
|
||||
coVerify(exactly = 0) { backfillProgressDao.deleteAll() }
|
||||
verify { syncScheduler.backfillNow() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetBackfillProgress with no account clears all progress and re-kicks backfill`() = runTest {
|
||||
repository.resetBackfillProgress(null)
|
||||
|
||||
coVerify { backfillProgressDao.deleteAll() }
|
||||
coVerify(exactly = 0) { backfillProgressDao.deleteForAccount(any()) }
|
||||
verify { syncScheduler.backfillNow() }
|
||||
}
|
||||
|
||||
private fun account(id: String = "imap:ada@example.org") = Account(
|
||||
id = id,
|
||||
email = "ada@example.org",
|
||||
displayName = "Ada",
|
||||
authType = AuthType.PASSWORD_IMAP,
|
||||
imap = ServerConfig("imap.example.org", 993, MailSecurity.SSL_TLS),
|
||||
smtp = ServerConfig("smtp.example.org", 587, MailSecurity.STARTTLS),
|
||||
)
|
||||
|
||||
private fun accountEntity(id: String = "acct", email: String = "ada@example.org") = AccountEntity(
|
||||
id = id,
|
||||
email = email,
|
||||
displayName = "Ada",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
)
|
||||
|
||||
private val params = ImapConnectionParams(
|
||||
host = "imap.example.org",
|
||||
port = 993,
|
||||
security = MailSecurity.SSL_TLS,
|
||||
username = "ada@example.org",
|
||||
secret = "secret",
|
||||
useXoauth2 = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.repository
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import app.cash.turbine.test
|
||||
import io.mockk.Runs
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.just
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import jakarta.mail.Flags
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Test
|
||||
import org.libremail.data.attachment.AttachmentUriGrants
|
||||
import org.libremail.data.local.dao.AccountDao
|
||||
import org.libremail.data.local.dao.AttachmentDao
|
||||
import org.libremail.data.local.dao.DraftDao
|
||||
import org.libremail.data.local.dao.FolderDao
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.dao.OutboxDao
|
||||
import org.libremail.data.local.entity.AccountEntity
|
||||
import org.libremail.data.local.entity.AttachmentEntity
|
||||
import org.libremail.data.local.entity.DraftEntity
|
||||
import org.libremail.data.local.entity.FolderUnreadCount
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.entity.MessageRouting
|
||||
import org.libremail.data.local.entity.OutboxEntity
|
||||
import org.libremail.data.local.entity.ServerConfigEmbedded
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.SignatureRepository
|
||||
import org.libremail.data.sync.MailConnectionFactory
|
||||
import org.libremail.data.sync.SendScheduler
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.AccountSettings
|
||||
import org.libremail.domain.model.Draft
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.model.OutgoingAttachment
|
||||
import org.libremail.domain.model.OutgoingMessage
|
||||
import org.libremail.domain.model.ReplyMode
|
||||
import org.libremail.mail.DownloadedAttachment
|
||||
import org.libremail.mail.FetchedMessage
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.mail.ReplyContext
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Extends [MailRepositoryImplTest]'s coverage to the methods and edge/error branches it doesn't reach:
|
||||
* the observe-* flows, single-message reads, flag/delete/send/search paths, and the "account or row
|
||||
* gone mid-flight" fall-throughs. Kept as a separate, purely-additive file so the existing suite (and
|
||||
* the in-flight search PR that touches it) is left untouched.
|
||||
*/
|
||||
class MailRepositoryImplCoverageTest {
|
||||
|
||||
private val messageDao = mockk<MessageDao>(relaxed = true)
|
||||
private val accountDao = mockk<AccountDao>(relaxed = true)
|
||||
private val folderDao = mockk<FolderDao>(relaxed = true)
|
||||
private val attachmentDao = mockk<AttachmentDao>(relaxed = true)
|
||||
private val draftDao = mockk<DraftDao>(relaxed = true)
|
||||
private val outboxDao = mockk<OutboxDao>(relaxed = true)
|
||||
private val imapClient = mockk<ImapClient>(relaxed = true)
|
||||
private val connectionFactory = mockk<MailConnectionFactory>()
|
||||
private val context = mockk<Context>(relaxed = true)
|
||||
private val sendScheduler = mockk<SendScheduler>(relaxed = true)
|
||||
private val accountSettingsRepository = mockk<AccountSettingsRepository>(relaxed = true)
|
||||
private val signatureRepository = mockk<SignatureRepository>(relaxed = true)
|
||||
|
||||
private val repository = MailRepositoryImpl(
|
||||
context = context,
|
||||
messageDao = messageDao,
|
||||
accountDao = accountDao,
|
||||
attachmentDao = attachmentDao,
|
||||
outboxDao = outboxDao,
|
||||
draftDao = draftDao,
|
||||
folderDao = folderDao,
|
||||
imapClient = imapClient,
|
||||
connectionFactory = connectionFactory,
|
||||
sendScheduler = sendScheduler,
|
||||
accountSettingsRepository = accountSettingsRepository,
|
||||
signatureRepository = signatureRepository,
|
||||
attachmentUriGrants = mockk<AttachmentUriGrants>(relaxed = true),
|
||||
)
|
||||
|
||||
@After
|
||||
fun tearDown() = unmockkAll()
|
||||
|
||||
// --- single-message reads -------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getMessage returns the mapped domain message when it is cached`() = runTest {
|
||||
coEvery { messageDao.getById("acct:INBOX:1") } returns messageEntity("acct:INBOX:1", body = "Hi", isHtml = true)
|
||||
|
||||
val message = repository.getMessage("acct:INBOX:1")
|
||||
|
||||
assertEquals("Hi", message?.body)
|
||||
assertTrue(message?.isHtml == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getMessage returns null when the message is not cached`() = runTest {
|
||||
coEvery { messageDao.getById("missing") } returns null
|
||||
|
||||
assertNull(repository.getMessage("missing"))
|
||||
}
|
||||
|
||||
// --- observe-* flows ------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `observeAttachments maps attachment rows to the domain, carrying inline content ids`() = runTest {
|
||||
every { attachmentDao.observeForMessage("m") } returns flowOf(
|
||||
listOf(
|
||||
AttachmentEntity("m", 0, "a.pdf", "application/pdf", 10L),
|
||||
AttachmentEntity("m", 1, "logo.png", "image/png", 4L, contentId = "logo@cid"),
|
||||
),
|
||||
)
|
||||
|
||||
repository.observeAttachments("m").test {
|
||||
val list = awaitItem()
|
||||
assertEquals(2, list.size)
|
||||
assertNull(list[0].contentId)
|
||||
assertEquals("logo@cid", list[1].contentId)
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `observeUnreadCounts maps the aggregate rows to domain unread counts`() = runTest {
|
||||
every { messageDao.observeUnreadCounts() } returns flowOf(
|
||||
listOf(FolderUnreadCount("acct", "INBOX", 3), FolderUnreadCount("acct", "Work", 1)),
|
||||
)
|
||||
|
||||
repository.observeUnreadCounts().test {
|
||||
val counts = awaitItem()
|
||||
assertEquals(listOf(3, 1), counts.map { it.count })
|
||||
assertEquals("INBOX", counts.first().folder)
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `observeDrafts maps draft rows to the domain, decoding their attachments`() = runTest {
|
||||
every { draftDao.observeAll() } returns flowOf(
|
||||
listOf(draftEntity("d1", """[{"uri":"content://f","name":"a.txt"}]""")),
|
||||
)
|
||||
|
||||
repository.observeDrafts().test {
|
||||
val drafts = awaitItem()
|
||||
assertEquals("content://f", drafts.single().attachments.single().uri)
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `observeOutbox maps outbox rows to the domain`() = runTest {
|
||||
every { outboxDao.observeAll() } returns flowOf(listOf(outboxEntity("o1")))
|
||||
|
||||
repository.observeOutbox().test {
|
||||
val list = awaitItem()
|
||||
assertEquals("o1", list.single().id)
|
||||
assertEquals("bob@example.org", list.single().to)
|
||||
awaitComplete()
|
||||
}
|
||||
}
|
||||
|
||||
// --- drafts ---------------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `getDraft returns the mapped draft when present`() = runTest {
|
||||
coEvery { draftDao.getById("d1") } returns draftEntity("d1", "")
|
||||
|
||||
assertEquals("d1", repository.getDraft("d1")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getDraft returns null when the draft is absent`() = runTest {
|
||||
coEvery { draftDao.getById("nope") } returns null
|
||||
|
||||
assertNull(repository.getDraft("nope"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveDraft upserts the mapped draft entity`() = runTest {
|
||||
val saved = slot<DraftEntity>()
|
||||
coEvery { draftDao.upsert(capture(saved)) } just Runs
|
||||
|
||||
repository.saveDraft(
|
||||
Draft(
|
||||
id = "d9",
|
||||
accountId = "acct",
|
||||
to = "bob@example.org",
|
||||
cc = "",
|
||||
subject = "Hi",
|
||||
body = "Yo",
|
||||
updatedAt = 7L,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("d9", saved.captured.id)
|
||||
assertEquals("bob@example.org", saved.captured.toAddresses)
|
||||
}
|
||||
|
||||
// --- setStarred / deleteMessage (server-reachable vs. local-only) ---------------------------
|
||||
|
||||
@Test
|
||||
fun `setStarred writes the local flag then pushes the FLAGGED change to the server`() = runTest {
|
||||
val id = "acct:INBOX:5"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
|
||||
val result = repository.setStarred(id, true)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { messageDao.setStarred(id, true) }
|
||||
coVerify { imapClient.setFlag(any(), "INBOX", "5", Flags.Flag.FLAGGED, true) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setStarred still succeeds locally when the message has no cached routing`() = runTest {
|
||||
val id = "acct:INBOX:6"
|
||||
coEvery { messageDao.getRouting(id) } returns null
|
||||
|
||||
val result = repository.setStarred(id, false)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { messageDao.setStarred(id, false) }
|
||||
coVerify(exactly = 0) { imapClient.setFlag(any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteMessage removes the local row and deletes it on the server`() = runTest {
|
||||
val id = "acct:INBOX:7"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
|
||||
val result = repository.deleteMessage(id)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { messageDao.deleteById(id) }
|
||||
coVerify { imapClient.deleteMessage(any(), "INBOX", "7") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteMessage removes the local row even when there is nothing to delete server-side`() = runTest {
|
||||
val id = "acct:INBOX:8"
|
||||
coEvery { messageDao.getRouting(id) } returns null
|
||||
|
||||
val result = repository.deleteMessage(id)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { messageDao.deleteById(id) }
|
||||
coVerify(exactly = 0) { imapClient.deleteMessage(any(), any(), any()) }
|
||||
}
|
||||
|
||||
// --- sendMessage / copyAttachments ----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `sendMessage queues the outbox row and triggers the send worker`() = runTest {
|
||||
every { context.cacheDir } returns Files.createTempDirectory("outbox").toFile()
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
val saved = slot<OutboxEntity>()
|
||||
coEvery { outboxDao.insert(capture(saved)) } just Runs
|
||||
|
||||
val result = repository.sendMessage(
|
||||
OutgoingMessage(accountId = "acct", to = "bob@example.org", subject = "Hi", body = "Yo"),
|
||||
)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertEquals("acct", saved.captured.accountId)
|
||||
assertEquals("bob@example.org", saved.captured.toAddresses)
|
||||
assertEquals("", saved.captured.attachments) // no attachments -> empty json
|
||||
verify { sendScheduler.sendNow() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendMessage fails when the sending account is missing`() = runTest {
|
||||
coEvery { accountDao.getById("gone") } returns null
|
||||
|
||||
val result = repository.sendMessage(
|
||||
OutgoingMessage(accountId = "gone", to = "b@x.org", subject = "s", body = "b"),
|
||||
)
|
||||
|
||||
assertTrue(result.isFailure)
|
||||
coVerify(exactly = 0) { outboxDao.insert(any()) }
|
||||
verify(exactly = 0) { sendScheduler.sendNow() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendMessage copies each attachment into the outbox staging dir under a sanitized name`() = runTest {
|
||||
val cache = Files.createTempDirectory("outbox").toFile()
|
||||
every { context.cacheDir } returns cache
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
val saved = slot<OutboxEntity>()
|
||||
coEvery { outboxDao.insert(capture(saved)) } just Runs
|
||||
mockkStatic(Uri::class)
|
||||
every { Uri.parse(any()) } returns mockk()
|
||||
val resolver = mockk<ContentResolver>()
|
||||
every { context.contentResolver } returns resolver
|
||||
every { resolver.openInputStream(any()) } returns ByteArrayInputStream("PDFDATA".toByteArray())
|
||||
|
||||
val result = repository.sendMessage(
|
||||
OutgoingMessage(
|
||||
accountId = "acct",
|
||||
to = "bob@example.org",
|
||||
subject = "Hi",
|
||||
body = "Yo",
|
||||
attachments = listOf(OutgoingAttachment("content://pick/report", "../evil/report.pdf")),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
// The staged file keeps only the sanitized leaf name (no path traversal) with the copied bytes.
|
||||
val staged = File(cache, "outbox/${saved.captured.id}/0/report.pdf")
|
||||
assertTrue(staged.exists())
|
||||
assertEquals("PDFDATA", staged.readText())
|
||||
// The persisted json still carries the picked URI for the send worker.
|
||||
assertTrue(saved.captured.attachments.contains("content://pick/report"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sendMessage tolerates an attachment whose bytes cannot be read and still queues the message`() = runTest {
|
||||
val cache = Files.createTempDirectory("outbox").toFile()
|
||||
every { context.cacheDir } returns cache
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
val saved = slot<OutboxEntity>()
|
||||
coEvery { outboxDao.insert(capture(saved)) } just Runs
|
||||
mockkStatic(Uri::class)
|
||||
every { Uri.parse(any()) } returns mockk()
|
||||
val resolver = mockk<ContentResolver>()
|
||||
every { context.contentResolver } returns resolver
|
||||
// An unreadable URI resolves to no stream; the copy is skipped but the send still proceeds.
|
||||
every { resolver.openInputStream(any()) } returns null
|
||||
|
||||
val result = repository.sendMessage(
|
||||
OutgoingMessage(
|
||||
accountId = "acct",
|
||||
to = "bob@example.org",
|
||||
subject = "Hi",
|
||||
body = "Yo",
|
||||
attachments = listOf(OutgoingAttachment("content://pick/broken", "x.bin")),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
assertFalse(File(cache, "outbox/${saved.captured.id}/0/x.bin").exists())
|
||||
verify { sendScheduler.sendNow() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `buildReplyDraft omits the signature block when the account has signatures disabled`() = runTest {
|
||||
val id = "acct:INBOX:20"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { accountSettingsRepository.get("acct") } returns AccountSettings("acct", signatureEnabled = false)
|
||||
coEvery { imapClient.fetchForReply(any(), "INBOX", "20") } returns ReplyContext(
|
||||
fromEmail = "boss@example.org",
|
||||
toRecipients = listOf("ada@example.org"),
|
||||
ccRecipients = emptyList(),
|
||||
subject = "Plan",
|
||||
sentDateMillis = 0L,
|
||||
body = "Original",
|
||||
isHtml = false,
|
||||
)
|
||||
val draft = slot<DraftEntity>()
|
||||
coEvery { draftDao.upsert(capture(draft)) } just Runs
|
||||
|
||||
val result = repository.buildReplyDraft(id, ReplyMode.REPLY)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
// With signatures off, no RFC 3676 "-- " delimiter is baked above the quote, and the default
|
||||
// signature is never even resolved.
|
||||
assertFalse(draft.captured.body.contains("-- "))
|
||||
coVerify(exactly = 0) { signatureRepository.getDefault(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retryOutbox re-triggers the send worker`() = runTest {
|
||||
repository.retryOutbox()
|
||||
|
||||
verify { sendScheduler.sendNow() }
|
||||
}
|
||||
|
||||
// --- server search --------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `searchServer queries every account when unfiltered and stores hits as search-only`() = runTest {
|
||||
coEvery { accountDao.getAll() } returns listOf(
|
||||
accountEntity(id = "acct"),
|
||||
accountEntity(id = "acct2", email = "bob@example.org"),
|
||||
)
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { imapClient.search(any(), "INBOX", "invoice", any()) } returns listOf(
|
||||
FetchedMessage("11", "Ada", "ada@example.org", "Invoice", 1L, isRead = false, isFlagged = false),
|
||||
)
|
||||
val inserted = slot<List<MessageEntity>>()
|
||||
coEvery { messageDao.insertNew(capture(inserted)) } just Runs
|
||||
|
||||
repository.searchServer("invoice", accountId = null, folder = "INBOX")
|
||||
|
||||
// Both accounts are searched, and every hit is marked search-only (inInbox = false) so it
|
||||
// never masquerades as a synced row.
|
||||
coVerify(exactly = 2) { imapClient.search(any(), "INBOX", "invoice", any()) }
|
||||
assertTrue(inserted.captured.isNotEmpty() && inserted.captured.all { !it.inInbox })
|
||||
coVerify { messageDao.updateHeaderContent(any(), any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `searchServer restricts the search to the requested account`() = runTest {
|
||||
coEvery { accountDao.getAll() } returns listOf(
|
||||
accountEntity(id = "acct"),
|
||||
accountEntity(id = "acct2", email = "bob@example.org"),
|
||||
)
|
||||
val searched = slot<Account>()
|
||||
coEvery { connectionFactory.imapParamsFor(capture(searched)) } returns imapParams()
|
||||
coEvery { imapClient.search(any(), "INBOX", "q", any()) } returns emptyList()
|
||||
|
||||
repository.searchServer("q", accountId = "acct2", folder = "INBOX")
|
||||
|
||||
// Only the filtered account is contacted; an empty result inserts an empty batch and updates
|
||||
// no headers.
|
||||
assertEquals("acct2", searched.captured.id)
|
||||
coVerify(exactly = 1) { imapClient.search(any(), "INBOX", "q", any()) }
|
||||
coVerify(exactly = 1) { messageDao.insertNew(emptyList()) }
|
||||
coVerify(exactly = 0) { messageDao.updateHeaderContent(any(), any(), any(), any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearSearchResults deletes the transient search rows`() = runTest {
|
||||
repository.clearSearchResults()
|
||||
|
||||
coVerify { messageDao.deleteSearchRows() }
|
||||
}
|
||||
|
||||
// --- inlineImages edge cases ----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `inlineImages returns nothing when the message has no routing`() = runTest {
|
||||
coEvery { messageDao.getRouting("gone") } returns null
|
||||
|
||||
assertTrue(repository.inlineImages("gone").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inlineImages omits a cid part whose bytes cannot be fetched`() = runTest {
|
||||
every { context.cacheDir } returns Files.createTempDirectory("attach").toFile()
|
||||
val id = "acct:INBOX:9"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { attachmentDao.getForMessage(id) } returns listOf(
|
||||
AttachmentEntity(id, 0, "logo.png", "image/png", 4L, contentId = "logo@cid"),
|
||||
)
|
||||
// No cached file and the account is gone, so the on-demand fetch throws and the image is dropped.
|
||||
coEvery { accountDao.getById("acct") } returns null
|
||||
|
||||
assertTrue(repository.inlineImages(id).isEmpty())
|
||||
}
|
||||
|
||||
// --- openMessage fall-throughs --------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `openMessage fails when the message id has no routing`() = runTest {
|
||||
coEvery { messageDao.getRouting("gone") } returns null
|
||||
|
||||
assertTrue(repository.openMessage("gone").isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openMessage skips the server fetch when the account has been removed`() = runTest {
|
||||
val id = "acct:INBOX:10"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX") // unread, no body
|
||||
coEvery { accountDao.getById("acct") } returns null
|
||||
coEvery { messageDao.getById(id) } returns messageEntity(id, body = "stale")
|
||||
|
||||
val result = repository.openMessage(id)
|
||||
|
||||
assertEquals("stale", result.getOrThrow().body)
|
||||
coVerify(exactly = 0) { imapClient.fetchBodyMarkingSeen(any(), any(), any()) }
|
||||
coVerify(exactly = 0) { messageDao.setRead(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openMessage fails when the row vanishes before the body read`() = runTest {
|
||||
val id = "acct:INBOX:11"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX", bodyFetched = true, isRead = true)
|
||||
coEvery { messageDao.getById(id) } returns null
|
||||
|
||||
assertTrue(repository.openMessage(id).isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `refreshFolders fails when the account is unknown`() = runTest {
|
||||
coEvery { accountDao.getById("gone") } returns null
|
||||
|
||||
assertTrue(repository.refreshFolders("gone").isFailure)
|
||||
}
|
||||
|
||||
// --- prefetchMessage / downloadAttachment ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `prefetchMessage is a no-op success when the message has no routing`() = runTest {
|
||||
coEvery { messageDao.getRouting("gone") } returns null
|
||||
|
||||
assertTrue(repository.prefetchMessage("gone").isSuccess)
|
||||
coVerify(exactly = 0) { imapClient.fetchBodyPeek(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prefetchMessage is a no-op success when the account has been removed`() = runTest {
|
||||
val id = "acct:INBOX:12"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { accountDao.getById("acct") } returns null
|
||||
|
||||
assertTrue(repository.prefetchMessage(id).isSuccess)
|
||||
coVerify(exactly = 0) { imapClient.fetchBodyPeek(any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloadAttachment falls back to a default filename when the part metadata is missing`() = runTest {
|
||||
val cache = Files.createTempDirectory("attach").toFile()
|
||||
every { context.cacheDir } returns cache
|
||||
val id = "acct:INBOX:13"
|
||||
coEvery { messageDao.getRouting(id) } returns messageRouting(id, "INBOX")
|
||||
coEvery { attachmentDao.getForMessage(id) } returns emptyList() // no metadata for part 0
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
coEvery { imapClient.fetchAttachment(any(), "INBOX", "13", 0) } returns
|
||||
DownloadedAttachment("attachment", "application/octet-stream", "bytes".toByteArray())
|
||||
|
||||
val result = repository.downloadAttachment(id, 0)
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
// Stored under the "attachment" fallback name since no cached metadata carried a filename.
|
||||
assertEquals(File(cache, "attachments/acct_INBOX_13/0/attachment"), result.getOrThrow())
|
||||
}
|
||||
|
||||
// --- batch move fall-throughs ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `moveToFolder skips the server move for messages already in the destination folder`() = runTest {
|
||||
val id = "acct:Receipts:14"
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "Receipts"))
|
||||
coEvery { accountDao.getById("acct") } returns accountEntity()
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
|
||||
|
||||
val result = repository.moveToFolder(listOf(id), "Receipts")
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { messageDao.deleteByIds(listOf(id)) } // still optimistically removed locally
|
||||
coVerify(exactly = 0) { imapClient.moveMessages(any(), any(), any(), any()) } // no self-move
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expunge skips messages whose account no longer exists`() = runTest {
|
||||
val id = "acct:INBOX:15"
|
||||
coEvery { messageDao.getRoutingByIds(listOf(id)) } returns listOf(messageRouting(id, "INBOX"))
|
||||
coEvery { accountDao.getById("acct") } returns null // account removed mid-flight
|
||||
|
||||
val result = repository.expunge(listOf(id))
|
||||
|
||||
assertTrue(result.isSuccess)
|
||||
coVerify { messageDao.deleteByIds(listOf(id)) } // local removal still happens
|
||||
coVerify(exactly = 0) { imapClient.deleteMessage(any(), any(), any()) } // nothing pushed server-side
|
||||
}
|
||||
|
||||
// --- fixtures -------------------------------------------------------------------------------
|
||||
|
||||
private fun messageEntity(
|
||||
id: String,
|
||||
folder: String = "INBOX",
|
||||
accountId: String = "acct",
|
||||
body: String = "",
|
||||
isHtml: Boolean = false,
|
||||
bodyFetched: Boolean = false,
|
||||
isRead: Boolean = false,
|
||||
) = MessageEntity(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "snippet",
|
||||
body = body,
|
||||
isHtml = isHtml,
|
||||
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 draftEntity(id: String, attachmentsJson: String) = DraftEntity(
|
||||
id = id,
|
||||
accountId = "acct",
|
||||
toAddresses = "bob@example.org",
|
||||
ccAddresses = "",
|
||||
bccAddresses = "",
|
||||
subject = "Hi",
|
||||
body = "body",
|
||||
updatedAt = 0L,
|
||||
attachments = attachmentsJson,
|
||||
)
|
||||
|
||||
private fun outboxEntity(id: String) = OutboxEntity(
|
||||
id = id,
|
||||
accountId = "acct",
|
||||
toAddresses = "bob@example.org",
|
||||
ccAddresses = "",
|
||||
subject = "Hi",
|
||||
body = "body",
|
||||
createdAt = 0L,
|
||||
)
|
||||
|
||||
private fun accountEntity(id: String = "acct", email: String = "ada@example.org") = AccountEntity(
|
||||
id = id,
|
||||
email = email,
|
||||
displayName = "Ada",
|
||||
authType = "PASSWORD_IMAP",
|
||||
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
|
||||
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
|
||||
)
|
||||
|
||||
private fun imapParams() = ImapConnectionParams(
|
||||
host = "imap.example.org",
|
||||
port = 993,
|
||||
security = MailSecurity.SSL_TLS,
|
||||
username = "ada@example.org",
|
||||
secret = "secret",
|
||||
useXoauth2 = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.domain.model
|
||||
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Covers the pure behaviour and field-carrying contracts of the domain models that the mapper and
|
||||
* repository suites don't reach directly: [AccountSettings.signatureBlock]'s three branches,
|
||||
* [Signature.plainText], the default-argument constructors ([OutboxMessage]/[Signature]) and the
|
||||
* display-name fallbacks in [Account.outlook] / [MailProvider.createAccount].
|
||||
*/
|
||||
class DomainModelCoverageTest {
|
||||
|
||||
@Test
|
||||
fun `signatureBlock appends an RFC 3676 delimited, right-trimmed block when enabled and non-blank`() {
|
||||
val settings = AccountSettings(accountId = "acct", signature = "Ada Lovelace ", signatureEnabled = true)
|
||||
|
||||
assertEquals("\n\n-- \nAda Lovelace", settings.signatureBlock())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signatureBlock is empty when signatures are disabled`() {
|
||||
assertEquals("", AccountSettings("acct", signature = "Ada", signatureEnabled = false).signatureBlock())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signatureBlock is empty when the signature is blank even though it is enabled`() {
|
||||
assertEquals("", AccountSettings("acct", signature = " ", signatureEnabled = true).signatureBlock())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AccountSettings defaults enable signatures and notifications with no retention override`() {
|
||||
val settings = AccountSettings("acct")
|
||||
|
||||
assertTrue(settings.signatureEnabled)
|
||||
assertTrue(settings.notificationsEnabled)
|
||||
assertNull(settings.retentionCount)
|
||||
assertNull(settings.retentionMonths)
|
||||
assertEquals("", settings.signature)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Signature exposes its fields, defaults to non-default, and converts its html to plain text`() {
|
||||
val sig = Signature(id = "acct:sig", accountId = "acct", name = "Work", html = "<p>Regards, <b>Ada</b></p>")
|
||||
|
||||
assertEquals("acct:sig", sig.id)
|
||||
assertEquals("acct", sig.accountId)
|
||||
assertEquals("Work", sig.name)
|
||||
assertEquals("<p>Regards, <b>Ada</b></p>", sig.html)
|
||||
assertFalse(sig.isDefault)
|
||||
assertTrue(sig.plainText().contains("Regards"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Signature can be flagged as the account default`() {
|
||||
assertTrue(Signature("i", "acct", "Primary", "<p>x</p>", isDefault = true).isDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OutboxMessage defaults its html body to null`() {
|
||||
val msg =
|
||||
OutboxMessage(id = "o", to = "bob@example.org", subject = "s", body = "b", createdAt = 1L, lastError = null)
|
||||
|
||||
assertEquals("o", msg.id)
|
||||
assertEquals("bob@example.org", msg.to)
|
||||
assertEquals("s", msg.subject)
|
||||
assertEquals("b", msg.body)
|
||||
assertEquals(1L, msg.createdAt)
|
||||
assertNull(msg.lastError)
|
||||
assertNull(msg.bodyHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OutboxMessage carries an html body and last error when supplied`() {
|
||||
val msg = OutboxMessage("o", "bob@example.org", "s", "b", 1L, "smtp 550", "<p>b</p>")
|
||||
|
||||
assertEquals("smtp 550", msg.lastError)
|
||||
assertEquals("<p>b</p>", msg.bodyHtml)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Account outlook falls back to the email when the display name is blank`() {
|
||||
val account = Account.outlook("me@outlook.com", displayName = "")
|
||||
|
||||
assertEquals("me@outlook.com", account.displayName)
|
||||
assertEquals("outlook:me@outlook.com", account.id)
|
||||
assertEquals("me@outlook.com", account.email)
|
||||
assertEquals(AuthType.OAUTH_OUTLOOK, account.authType)
|
||||
assertEquals("outlook.office365.com", account.imap.host)
|
||||
assertEquals("smtp.office365.com", account.smtp.host)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Account outlook keeps an explicit non-blank display name`() {
|
||||
assertEquals("Work", Account.outlook("me@outlook.com", "Work").displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MailProvider createAccount falls back to the trimmed email when the display name is blank`() {
|
||||
val account = MailProvider.GMAIL.createAccount("user@gmail.com", displayName = " ")
|
||||
|
||||
assertEquals("user@gmail.com", account.displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Message carries every field it is constructed with`() {
|
||||
val message = Message(
|
||||
id = "acct:INBOX:1",
|
||||
accountId = "acct",
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "snip",
|
||||
body = "body",
|
||||
isHtml = true,
|
||||
timestampMillis = 5L,
|
||||
isRead = true,
|
||||
isStarred = true,
|
||||
folder = "INBOX",
|
||||
inInbox = true,
|
||||
bodyFetched = true,
|
||||
)
|
||||
|
||||
assertEquals("acct:INBOX:1", message.id)
|
||||
assertEquals("acct", message.accountId)
|
||||
assertEquals("Ada", message.sender)
|
||||
assertEquals("ada@example.org", message.senderEmail)
|
||||
assertEquals("Hi", message.subject)
|
||||
assertEquals("snip", message.snippet)
|
||||
assertEquals("body", message.body)
|
||||
assertTrue(message.isHtml)
|
||||
assertEquals(5L, message.timestampMillis)
|
||||
assertTrue(message.isRead)
|
||||
assertTrue(message.isStarred)
|
||||
assertEquals("INBOX", message.folder)
|
||||
assertTrue(message.inInbox)
|
||||
assertTrue(message.bodyFetched)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Folder carries every field including the parsed hierarchy delimiter`() {
|
||||
val folder = Folder(
|
||||
accountId = "acct",
|
||||
fullName = "[Gmail]/Sent Mail",
|
||||
displayName = "Sent Mail",
|
||||
role = FolderRole.SENT,
|
||||
selectable = true,
|
||||
specialUse = true,
|
||||
hierarchyDelimiter = '/',
|
||||
)
|
||||
|
||||
assertEquals("acct", folder.accountId)
|
||||
assertEquals("[Gmail]/Sent Mail", folder.fullName)
|
||||
assertEquals("Sent Mail", folder.displayName)
|
||||
assertEquals(FolderRole.SENT, folder.role)
|
||||
assertTrue(folder.selectable)
|
||||
assertTrue(folder.specialUse)
|
||||
assertEquals('/', folder.hierarchyDelimiter)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user