Merge branch 'main' into test-249-coverage-viewmodels-nonui

This commit is contained in:
Jason Ross
2026-07-03 14:34:32 -05:00
committed by GitHub
4 changed files with 1369 additions and 0 deletions
@@ -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)
}
}