From e2606b775168029151116208f61cd62411821bc1 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 13:55:51 -0500 Subject: [PATCH] =?UTF-8?q?test(coverage):=20lane=201=20=E2=80=94=20reposi?= =?UTF-8?q?tory,=20mappers=20&=20domain=20logic=20to=20>=3D95%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add JVM-only unit tests (74 across 4 new, purely-additive files) covering the data/repository, data-mapper, and pure domain packages. No production code is changed. - AccountRepositoryImplTest: first tests for AccountRepositoryImpl — add/ test/delete/observe + reset-backfill, success and rejected-LIST failure paths (class now 100% instruction & line). - MailRepositoryImplCoverageTest: the MailRepositoryImpl methods/edges the existing suite skipped — observe-* flows, getMessage/getDraft, setStarred, deleteMessage, sendMessage + copyAttachments (incl. unreadable-URI skip), searchServer (all-accounts vs. filtered), and the account/row-gone fall-throughs. - MappersTest: entity<->domain mappers not otherwise pinned, incl. the unknown-enum fallbacks and FetchedMessage id/uid rules. - DomainModelCoverageTest: AccountSettings.signatureBlock branches, Signature.plainText, default-arg constructors, and display-name fallbacks (domain/model now 100% instruction & line). Closes #246 Co-Authored-By: Claude Opus 4.8 --- .../org/libremail/data/local/MappersTest.kt | 313 +++++++++ .../repository/AccountRepositoryImplTest.kt | 229 ++++++ .../MailRepositoryImplCoverageTest.kt | 663 ++++++++++++++++++ .../domain/model/DomainModelCoverageTest.kt | 164 +++++ 4 files changed, 1369 insertions(+) create mode 100644 app/src/test/kotlin/org/libremail/data/local/MappersTest.kt create mode 100644 app/src/test/kotlin/org/libremail/data/repository/AccountRepositoryImplTest.kt create mode 100644 app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplCoverageTest.kt create mode 100644 app/src/test/kotlin/org/libremail/domain/model/DomainModelCoverageTest.kt diff --git a/app/src/test/kotlin/org/libremail/data/local/MappersTest.kt b/app/src/test/kotlin/org/libremail/data/local/MappersTest.kt new file mode 100644 index 0000000..e3d5f1d --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/local/MappersTest.kt @@ -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 = "

hello

", + ) + + 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("

hello

", 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), + ) +} diff --git a/app/src/test/kotlin/org/libremail/data/repository/AccountRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/AccountRepositoryImplTest.kt new file mode 100644 index 0000000..96b1b20 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/repository/AccountRepositoryImplTest.kt @@ -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() + private val messageDao = mockk(relaxed = true) + private val folderDao = mockk(relaxed = true) + private val backfillProgressDao = mockk(relaxed = true) + private val credentialStore = mockk(relaxed = true) + private val imapClient = mockk() + private val syncScheduler = mockk(relaxed = true) + private val accountSettingsRepository = mockk(relaxed = true) + private val mailNotifier = mockk(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() + 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() + coEvery { imapClient.listFolders(capture(used)) } returns listOf( + FetchedFolder("INBOX", "INBOX", emptyList(), selectable = true), + ) + val saved = slot() + 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, + ) +} diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplCoverageTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplCoverageTest.kt new file mode 100644 index 0000000..c48d796 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplCoverageTest.kt @@ -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(relaxed = true) + private val accountDao = mockk(relaxed = true) + private val folderDao = mockk(relaxed = true) + private val attachmentDao = mockk(relaxed = true) + private val draftDao = mockk(relaxed = true) + private val outboxDao = mockk(relaxed = true) + private val imapClient = mockk(relaxed = true) + private val connectionFactory = mockk() + private val context = mockk(relaxed = true) + private val sendScheduler = mockk(relaxed = true) + private val accountSettingsRepository = mockk(relaxed = true) + private val signatureRepository = mockk(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(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() + 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() + 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() + coEvery { outboxDao.insert(capture(saved)) } just Runs + mockkStatic(Uri::class) + every { Uri.parse(any()) } returns mockk() + val resolver = mockk() + 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() + coEvery { outboxDao.insert(capture(saved)) } just Runs + mockkStatic(Uri::class) + every { Uri.parse(any()) } returns mockk() + val resolver = mockk() + 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() + 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>() + 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() + 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, + ) +} diff --git a/app/src/test/kotlin/org/libremail/domain/model/DomainModelCoverageTest.kt b/app/src/test/kotlin/org/libremail/domain/model/DomainModelCoverageTest.kt new file mode 100644 index 0000000..ec8a573 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/domain/model/DomainModelCoverageTest.kt @@ -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 = "

Regards, Ada

") + + assertEquals("acct:sig", sig.id) + assertEquals("acct", sig.accountId) + assertEquals("Work", sig.name) + assertEquals("

Regards, Ada

", 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", "

x

", 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", "

b

") + + assertEquals("smtp 550", msg.lastError) + assertEquals("

b

", 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) + } +}