diff --git a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/FolderDrawerTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/FolderDrawerTest.kt index bf52dea..711d87c 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/FolderDrawerTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/FolderDrawerTest.kt @@ -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. diff --git a/app/src/test/kotlin/org/libremail/data/local/FolderMapperTest.kt b/app/src/test/kotlin/org/libremail/data/local/FolderMapperTest.kt new file mode 100644 index 0000000..ca01e64 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/local/FolderMapperTest.kt @@ -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, 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) + } +} diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index 712d003..f89f909 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -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>() + 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" diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/FolderLabelsTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/FolderLabelsTest.kt index 0b4682f..43b3493 100644 --- a/app/src/test/kotlin/org/libremail/ui/mailbox/FolderLabelsTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/mailbox/FolderLabelsTest.kt @@ -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, friendly: Map) = 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",