test(folders): cover attributes-to-specialUse wiring; fix FolderLabelsTest fidelity claim #113

Merged
JMR-dev merged 1 commits from test-specialuse-wiring into main 2026-07-02 07:36:33 +00:00
4 changed files with 133 additions and 6 deletions
@@ -42,16 +42,20 @@ class FolderDrawerTest {
folders = listOf(
folder("imap:a", "INBOX", "INBOX", FolderRole.INBOX),
folder("imap:a", "[Gmail]/Sent Mail", "Sent Mail", FolderRole.SENT),
folder("imap:a", "Archive", "Archive", FolderRole.ARCHIVE),
// Give ARCHIVE a server name that differs from its friendly label so the assertion
// below actually discriminates a role-to-label regression (displayName != friendly).
folder("imap:a", "[Gmail]/All Mail", "All Mail", FolderRole.ARCHIVE),
folder("imap:a", "Receipts", "Receipts", FolderRole.NORMAL),
),
)
composeTestRule.onNodeWithText(string(R.string.folder_inbox)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.folder_archive)).assertIsDisplayed()
// Standard folders use the friendly role name ("Sent"), not the raw server name ("Sent Mail").
// Standard folders use the friendly role name, not the raw server name — verified for both
// Sent ("Sent Mail" -> "Sent") and Archive ("All Mail" -> "Archive").
composeTestRule.onNodeWithText(string(R.string.folder_sent)).assertIsDisplayed()
composeTestRule.onNodeWithText("Sent Mail").assertDoesNotExist()
composeTestRule.onNodeWithText(string(R.string.folder_archive)).assertIsDisplayed()
composeTestRule.onNodeWithText("All Mail").assertDoesNotExist()
// Normal folders keep their server name.
composeTestRule.onNodeWithText("Receipts").assertIsDisplayed()
// A single account shows no account switcher / "All Inboxes" entry.
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import org.junit.Test
import org.libremail.domain.model.FolderRole
import org.libremail.mail.FetchedFolder
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Pins the one production link between a server LIST response and a persisted folder:
* [FetchedFolder.toEntity] derives the folder's role (via [FolderRole.roleOf]) and its server
* special-use flag (via [FolderRole.isServerSpecial]) from the IMAP attributes, and toDomain carries
* that flag back out. Guards the mutation the PR #54 review found: dropping the specialUse assignment
* still compiles (the column defaults to false) and silently persists a non-special folder for every
* server folder.
*/
class FolderMapperTest {
private fun entityFor(attributes: List<String>, displayName: String = "Neutral") = FetchedFolder(
fullName = "Parent/$displayName",
displayName = displayName,
attributes = attributes,
selectable = true,
).toEntity(accountId = "acct", sortOrder = 3)
@Test
fun `role-bearing special-use attributes map to their role and mark the folder special`() {
val roleByAttribute = mapOf(
"\\Sent" to FolderRole.SENT,
"\\Drafts" to FolderRole.DRAFTS,
"\\Junk" to FolderRole.SPAM,
"\\Trash" to FolderRole.TRASH,
"\\Archive" to FolderRole.ARCHIVE,
)
roleByAttribute.forEach { (attribute, expectedRole) ->
val entity = entityFor(listOf(attribute))
assertEquals(expectedRole.name, entity.role, "role for $attribute")
assertTrue(entity.specialUse, "specialUse for $attribute")
}
}
@Test
fun `the all-mail and starred attributes mark the folder special but drive no role of their own`() {
// Gmail's "All Mail" (\All) and "Starred" (\Flagged) are server special-use, yet roleOf maps
// neither to a role (the role still comes from the name — here neutral, so NORMAL). specialUse
// and role are independent axes; this documents the current wiring ahead of the #65 refactor.
listOf("\\All", "\\Flagged").forEach { attribute ->
val entity = entityFor(listOf(attribute))
assertTrue(entity.specialUse, "specialUse for $attribute")
assertEquals(FolderRole.NORMAL.name, entity.role, "role for $attribute")
}
}
@Test
fun `a folder with no special-use attributes is not marked special`() {
val entity = entityFor(attributes = emptyList(), displayName = "Receipts")
assertFalse(entity.specialUse)
assertEquals(FolderRole.NORMAL.name, entity.role)
}
@Test
fun `a non special-use list flag alone does not mark the folder special`() {
// \HasNoChildren is a structural LIST flag, not an RFC 6154 SPECIAL-USE attribute.
assertFalse(entityFor(listOf("\\HasNoChildren"), displayName = "Receipts").specialUse)
}
@Test
fun `the server sort order is carried onto the persisted entity`() {
assertEquals(3, entityFor(listOf("\\Sent")).sortOrder)
}
@Test
fun `toDomain carries the special-use flag back out for both values`() {
assertTrue(entityFor(listOf("\\Drafts")).toDomain().specialUse)
assertFalse(entityFor(emptyList(), displayName = "Receipts").toDomain().specialUse)
}
}
@@ -35,6 +35,7 @@ import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.ReplyMode
import org.libremail.mail.AttachmentPart
import org.libremail.mail.DownloadedAttachment
import org.libremail.mail.FetchedFolder
import org.libremail.mail.ImapClient
import org.libremail.mail.MessageContent
import org.libremail.mail.ReplyContext
@@ -94,19 +95,52 @@ class MailRepositoryImplTest {
}
@Test
fun `observeFolders maps cached folders with their roles`() = runTest {
fun `observeFolders maps cached folders with their roles and server special-use flag`() = runTest {
every { folderDao.observeForAccount("acct") } returns flowOf(
listOf(FolderEntity("acct", "[Gmail]/Sent Mail", "Sent Mail", "SENT", selectable = true, sortOrder = 1)),
listOf(
FolderEntity(
accountId = "acct",
fullName = "[Gmail]/Sent Mail",
displayName = "Sent Mail",
role = "SENT",
selectable = true,
sortOrder = 1,
specialUse = true,
),
),
)
repository.observeFolders("acct").test {
val folders = awaitItem()
assertEquals(1, folders.size)
assertEquals(FolderRole.SENT, folders.first().role)
assertEquals("Sent Mail", folders.first().displayName)
// toDomain must carry the persisted special-use flag through to the domain model.
assertTrue(folders.first().specialUse)
awaitComplete()
}
}
@Test
fun `refreshFolders persists the server special-use flag derived from folder attributes`() = runTest {
coEvery { accountDao.getById("acct") } returns accountEntity()
coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams()
// A provider-built \Drafts folder (RFC 6154 SPECIAL-USE) beside an attribute-less user folder.
coEvery { imapClient.listFolders(any()) } returns listOf(
FetchedFolder("[Gmail]/Drafts", "Drafts", listOf("\\Drafts"), selectable = true),
FetchedFolder("Receipts", "Receipts", emptyList(), selectable = true),
)
val persisted = slot<List<FolderEntity>>()
coEvery { folderDao.replaceForAccount(any(), capture(persisted)) } just Runs
val result = repository.refreshFolders("acct")
assertTrue(result.isSuccess)
// The one production link (FetchedFolder.toEntity) must persist server special-use per folder,
// alongside the role it derives from the same attributes.
assertEquals(listOf(true, false), persisted.captured.map { it.specialUse })
assertEquals(listOf("DRAFTS", "NORMAL"), persisted.captured.map { it.role })
}
@Test
fun `openMessage fetches the body from the message's own folder, not the inbox`() = runTest {
val id = "acct:Archive:5"
@@ -34,10 +34,20 @@ class FolderLabelsTest {
smtp = ServerConfig("smtp", 587, MailSecurity.STARTTLS),
)
/** Builds base labels the way the drawer does: the friendly role name, else the raw display name. */
/**
* Builds base labels from an independent, hand-written [friendly] map, NOT by calling the
* production label source [org.libremail.ui.mailbox.folderDisplayLabel] — that is `@Composable`
* (it reads `stringResource`) and can't run in a JVM unit test. So this is a stand-in literal
* fixture that only mirrors the `folder_*` string values by hand; it deliberately pins the
* de-duplication logic in [resolveDrawerLabels], not the role-to-wording mapping. A change to the
* `folder_*` strings (or a new [folderDisplayLabel] branch) is out of scope here and won't be
* caught by this suite — FolderDrawerTest (androidTest, real resources) guards that wiring.
*/
private fun baseLabelsOf(folders: List<Folder>, friendly: Map<FolderRole, String>) =
folders.associate { it.fullName to (friendly[it.role] ?: it.displayName) }
// Hand-copied stand-ins for the current folder_* string values, not read from resources. See the
// note on baseLabelsOf: these pin the de-dup behavior, not the actual role-to-string wording.
private val friendlyNames = mapOf(
FolderRole.INBOX to "Inbox",
FolderRole.SENT to "Sent",