summaryrefslogtreecommitdiff
path: root/backend/imap/src/test/java/com
diff options
context:
space:
mode:
authortest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
committertest@dangofactory.cos <you@example.com>2026-08-20 16:57:18 +0000
commit50ce5d5e6cd0d720ff437d35eace545816848428 (patch)
treeb59af206a36265cf081945e434900b32b0c43edd /backend/imap/src/test/java/com
downloadmonocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.gz
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.tar.bz2
monocles_mail-50ce5d5e6cd0d720ff437d35eace545816848428.zip
Inital
Diffstat (limited to 'backend/imap/src/test/java/com')
-rw-r--r--backend/imap/src/test/java/com/fsck/k9/backend/imap/BackendIdleRefreshManagerTest.kt185
-rw-r--r--backend/imap/src/test/java/com/fsck/k9/backend/imap/ImapSyncTest.kt330
-rw-r--r--backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapFolder.kt179
-rw-r--r--backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapStore.kt48
-rw-r--r--backend/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapMessageHelper.kt3
5 files changed, 745 insertions, 0 deletions
diff --git a/backend/imap/src/test/java/com/fsck/k9/backend/imap/BackendIdleRefreshManagerTest.kt b/backend/imap/src/test/java/com/fsck/k9/backend/imap/BackendIdleRefreshManagerTest.kt
new file mode 100644
index 0000000..04fc3e4
--- /dev/null
+++ b/backend/imap/src/test/java/com/fsck/k9/backend/imap/BackendIdleRefreshManagerTest.kt
@@ -0,0 +1,185 @@
+package com.fsck.k9.backend.imap
+
+import assertk.assertThat
+import assertk.assertions.isEqualTo
+import assertk.assertions.isFalse
+import assertk.assertions.isTrue
+import org.junit.Test
+
+private const val START_TIME = 100_000_000L
+
+class BackendIdleRefreshManagerTest {
+ val alarmManager = MockSystemAlarmManager(START_TIME)
+ val idleRefreshManager = BackendIdleRefreshManager(alarmManager)
+
+ @Test
+ fun `single timer`() {
+ val timeout = 15 * 60 * 1000L
+ val callback = RecordingCallback()
+
+ idleRefreshManager.startTimer(timeout, callback::alarm)
+ alarmManager.advanceTime(timeout)
+
+ assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout))
+ assertThat(callback.wasCalled).isTrue()
+ }
+
+ @Test
+ fun `starting two timers in quick succession`() {
+ val timeout = 15 * 60 * 1000L
+ val callback1 = RecordingCallback()
+ val callback2 = RecordingCallback()
+
+ idleRefreshManager.startTimer(timeout, callback1::alarm)
+ // Advance clock less than MIN_TIMER_DELTA
+ alarmManager.advanceTime(100)
+ idleRefreshManager.startTimer(timeout, callback2::alarm)
+ alarmManager.advanceTime(timeout)
+
+ assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout))
+ assertThat(callback1.wasCalled).isTrue()
+ assertThat(callback2.wasCalled).isTrue()
+ }
+
+ @Test
+ fun `starting second timer some time after first should trigger both at initial trigger time`() {
+ val timeout = 15 * 60 * 1000L
+ val waitTime = 10 * 60 * 1000L
+ val callback1 = RecordingCallback()
+ val callback2 = RecordingCallback()
+
+ idleRefreshManager.startTimer(timeout, callback1::alarm)
+ // Advance clock by more than MIN_TIMER_DELTA but less than 'timeout'
+ alarmManager.advanceTime(waitTime)
+
+ assertThat(callback1.wasCalled).isFalse()
+
+ idleRefreshManager.startTimer(timeout, callback2::alarm)
+ alarmManager.advanceTime(timeout - waitTime)
+
+ assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout))
+ assertThat(callback1.wasCalled).isTrue()
+ assertThat(callback2.wasCalled).isTrue()
+ }
+
+ @Test
+ fun `second timer with lower timeout should reschedule alarm`() {
+ val timeout1 = 15 * 60 * 1000L
+ val timeout2 = 10 * 60 * 1000L
+ val callback1 = RecordingCallback()
+ val callback2 = RecordingCallback()
+
+ idleRefreshManager.startTimer(timeout1, callback1::alarm)
+
+ assertThat(alarmManager.triggerTime).isEqualTo(START_TIME + timeout1)
+
+ idleRefreshManager.startTimer(timeout2, callback2::alarm)
+ alarmManager.advanceTime(timeout2)
+
+ assertThat(alarmManager.alarmTimes).isEqualTo(listOf(START_TIME + timeout1, START_TIME + timeout2))
+ assertThat(callback1.wasCalled).isTrue()
+ assertThat(callback2.wasCalled).isTrue()
+ }
+
+ @Test
+ fun `do not trigger timers earlier than necessary`() {
+ val timeout1 = 10 * 60 * 1000L
+ val timeout2 = 23 * 60 * 1000L
+ val callback1 = RecordingCallback()
+ val callback2 = RecordingCallback()
+ val callback3 = RecordingCallback()
+
+ idleRefreshManager.startTimer(timeout1, callback1::alarm)
+ idleRefreshManager.startTimer(timeout2, callback2::alarm)
+
+ alarmManager.advanceTime(timeout1)
+ assertThat(callback1.wasCalled).isTrue()
+ assertThat(callback2.wasCalled).isFalse()
+
+ idleRefreshManager.startTimer(timeout1, callback3::alarm)
+
+ alarmManager.advanceTime(timeout1)
+
+ assertThat(alarmManager.alarmTimes).isEqualTo(
+ listOf(START_TIME + timeout1, START_TIME + timeout2, START_TIME + timeout1 + timeout1)
+ )
+ assertThat(callback2.wasCalled).isTrue()
+ assertThat(callback3.wasCalled).isTrue()
+ }
+
+ @Test
+ fun `reset timers`() {
+ val timeout = 10 * 60 * 1000L
+ val callback = RecordingCallback()
+
+ idleRefreshManager.startTimer(timeout, callback::alarm)
+
+ alarmManager.advanceTime(5 * 60 * 1000L)
+ assertThat(callback.wasCalled).isFalse()
+
+ idleRefreshManager.resetTimers()
+
+ assertThat(alarmManager.triggerTime).isEqualTo(NO_TRIGGER_TIME)
+ assertThat(callback.wasCalled).isTrue()
+ }
+
+ @Test
+ fun `cancel timer`() {
+ val timeout = 10 * 60 * 1000L
+ val callback = RecordingCallback()
+
+ val timer = idleRefreshManager.startTimer(timeout, callback::alarm)
+
+ alarmManager.advanceTime(5 * 60 * 1000L)
+ timer.cancel()
+
+ assertThat(alarmManager.triggerTime).isEqualTo(NO_TRIGGER_TIME)
+ assertThat(callback.wasCalled).isFalse()
+ }
+}
+
+class RecordingCallback {
+ var wasCalled = false
+ private set
+
+ fun alarm() {
+ wasCalled = true
+ }
+}
+
+typealias Callback = () -> Unit
+private const val NO_TRIGGER_TIME = -1L
+
+class MockSystemAlarmManager(startTime: Long) : SystemAlarmManager {
+ var now = startTime
+ var triggerTime = NO_TRIGGER_TIME
+ var callback: Callback? = null
+ val alarmTimes = mutableListOf<Long>()
+
+ override fun setAlarm(triggerTime: Long, callback: () -> Unit) {
+ this.triggerTime = triggerTime
+ this.callback = callback
+ alarmTimes.add(triggerTime)
+ }
+
+ override fun cancelAlarm() {
+ this.triggerTime = NO_TRIGGER_TIME
+ this.callback = null
+ }
+
+ override fun now(): Long = now
+
+ fun advanceTime(delta: Long) {
+ now += delta
+ if (now >= triggerTime) {
+ trigger()
+ }
+ }
+
+ private fun trigger() {
+ callback?.invoke().also {
+ triggerTime = NO_TRIGGER_TIME
+ callback = null
+ }
+ }
+}
diff --git a/backend/imap/src/test/java/com/fsck/k9/backend/imap/ImapSyncTest.kt b/backend/imap/src/test/java/com/fsck/k9/backend/imap/ImapSyncTest.kt
new file mode 100644
index 0000000..1417faf
--- /dev/null
+++ b/backend/imap/src/test/java/com/fsck/k9/backend/imap/ImapSyncTest.kt
@@ -0,0 +1,330 @@
+package com.fsck.k9.backend.imap
+
+import app.k9mail.backend.testing.InMemoryBackendStorage
+import assertk.assertThat
+import assertk.assertions.containsAll
+import assertk.assertions.containsExactlyInAnyOrder
+import assertk.assertions.isEmpty
+import assertk.assertions.isFalse
+import assertk.assertions.isTrue
+import com.fsck.k9.backend.api.FolderInfo
+import com.fsck.k9.backend.api.SyncConfig
+import com.fsck.k9.backend.api.SyncConfig.ExpungePolicy
+import com.fsck.k9.backend.api.SyncListener
+import com.fsck.k9.mail.FetchProfile
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.mail.FolderType
+import com.fsck.k9.mail.Message
+import com.fsck.k9.mail.MessageDownloadState
+import com.fsck.k9.mail.buildMessage
+import com.fsck.k9.mail.store.imap.FetchListener
+import com.fsck.k9.mail.store.imap.ImapMessage
+import java.util.Date
+import org.apache.james.mime4j.dom.field.DateTimeField
+import org.apache.james.mime4j.field.DefaultFieldParser
+import org.junit.Test
+import org.mockito.Mockito.verify
+import org.mockito.kotlin.any
+import org.mockito.kotlin.atLeast
+import org.mockito.kotlin.eq
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.never
+
+private const val ACCOUNT_NAME = "Account-1"
+private const val FOLDER_SERVER_ID = "FOLDER_ONE"
+private const val MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE = 1000
+private const val DEFAULT_VISIBLE_LIMIT = 25
+private const val DEFAULT_MESSAGE_DATE = "Tue, 04 Jan 2022 10:00:00 +0100"
+
+class ImapSyncTest {
+ private val backendStorage = createBackendStorage()
+ private val backendFolder = backendStorage.getFolder(FOLDER_SERVER_ID)
+ private val imapStore = TestImapStore()
+ private val imapFolder = imapStore.addFolder(FOLDER_SERVER_ID)
+ private val imapSync = ImapSync(ACCOUNT_NAME, backendStorage, imapStore)
+ private val syncListener = mock<SyncListener>()
+ private val defaultSyncConfig = createSyncConfig()
+
+ @Test
+ fun `sync of empty folder should notify listener`() {
+ imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
+
+ verify(syncListener).syncStarted(FOLDER_SERVER_ID)
+ verify(syncListener).syncAuthenticationSuccess()
+ verify(syncListener).syncFinished(FOLDER_SERVER_ID)
+ verify(syncListener, never()).syncFailed(folderServerId = any(), message = any(), exception = any())
+ }
+
+ @Test
+ fun `sync of folder with negative messageCount should return an error`() {
+ imapFolder.messageCount = -1
+
+ imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
+
+ verify(syncListener).syncFailed(
+ folderServerId = eq(FOLDER_SERVER_ID),
+ message = eq("Exception: Message count -1 for folder $FOLDER_SERVER_ID"),
+ exception = any()
+ )
+ }
+
+ @Test
+ fun `successful sync should close folder`() {
+ imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
+
+ assertThat(imapFolder.isClosed).isTrue()
+ }
+
+ @Test
+ fun `sync with error should close folder`() {
+ imapFolder.messageCount = -1
+
+ imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
+
+ assertThat(imapFolder.isClosed).isTrue()
+ }
+
+ @Test
+ fun `sync with ExpungePolicy ON_POLL should expunge remote folder`() {
+ val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.ON_POLL)
+
+ imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
+
+ assertThat(imapFolder.wasExpunged).isTrue()
+ }
+
+ @Test
+ fun `sync with ExpungePolicy MANUALLY should not expunge remote folder`() {
+ val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.MANUALLY)
+
+ imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
+
+ assertThat(imapFolder.wasExpunged).isFalse()
+ }
+
+ @Test
+ fun `sync with ExpungePolicy IMMEDIATELY should not expunge remote folder`() {
+ val syncConfig = defaultSyncConfig.copy(expungePolicy = ExpungePolicy.IMMEDIATELY)
+
+ imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
+
+ assertThat(imapFolder.wasExpunged).isFalse()
+ }
+
+ @Test
+ fun `sync with syncRemoteDeletions=true should remove local messages`() {
+ addMessageToBackendFolder(uid = 42)
+ val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = true)
+
+ imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
+
+ assertThat(backendFolder.getMessageServerIds()).isEmpty()
+ verify(syncListener).syncStarted(FOLDER_SERVER_ID)
+ verify(syncListener).syncFinished(FOLDER_SERVER_ID)
+ }
+
+ @Test
+ fun `sync with syncRemoteDeletions=false should not remove local messages`() {
+ addMessageToBackendFolder(uid = 23)
+ val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = false)
+
+ imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
+
+ assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("23")
+ verify(syncListener).syncStarted(FOLDER_SERVER_ID)
+ verify(syncListener).syncFinished(FOLDER_SERVER_ID)
+ }
+
+ @Test
+ fun `sync should remove messages older than earliestPollDate`() {
+ addMessageToImapAndBackendFolder(uid = 23, date = "Mon, 03 Jan 2022 10:00:00 +0100")
+ addMessageToImapAndBackendFolder(uid = 42, date = "Wed, 05 Jan 2022 20:00:00 +0100")
+ val syncConfig = defaultSyncConfig.copy(
+ syncRemoteDeletions = true,
+ earliestPollDate = "Tue, 04 Jan 2022 12:00:00 +0100".toDate()
+ )
+
+ imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
+
+ assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("42")
+ }
+
+ @Test
+ fun `sync with new messages on server should download messages`() {
+ addMessageToImapFolder(uid = 9)
+ addMessageToImapFolder(uid = 13)
+
+ imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
+
+ assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("9", "13")
+ verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "9", isOldMessage = false)
+ verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "13", isOldMessage = false)
+ }
+
+ @Test
+ fun `sync downloading old messages should notify listener with isOldMessage=true`() {
+ addMessageToBackendFolder(uid = 42)
+ addMessageToImapFolder(uid = 23)
+ addMessageToImapFolder(uid = 42)
+
+ imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
+
+ assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("23", "42")
+ verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "23", isOldMessage = true)
+ }
+
+ @Test
+ fun `determining the highest UID should use numerical ordering`() {
+ addMessageToBackendFolder(uid = 9)
+ addMessageToBackendFolder(uid = 100)
+ // When text ordering is used: "9" > "100" -> highest UID = 9 (when it should be 100)
+ // With 80 > 9 the message on the server is considered a new message, but it shouldn't be (80 < 100)
+ addMessageToImapFolder(uid = 80)
+
+ imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
+
+ verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "80", isOldMessage = true)
+ }
+
+ @Test
+ fun `sync should update flags of existing messages`() {
+ addMessageToBackendFolder(uid = 2)
+ addMessageToImapFolder(uid = 2, flags = setOf(Flag.SEEN, Flag.ANSWERED))
+
+ imapSync.sync(FOLDER_SERVER_ID, defaultSyncConfig, syncListener)
+
+ assertThat(backendFolder.getMessageFlags(messageServerId = "2")).containsAll(Flag.SEEN, Flag.ANSWERED)
+ }
+
+ @Test
+ fun `sync with UIDVALIDITY change should clear all messages`() {
+ imapFolder.setUidValidity(1)
+ addMessageToImapFolder(uid = 300)
+ addMessageToImapFolder(uid = 301)
+ val syncConfig = defaultSyncConfig.copy(syncRemoteDeletions = false)
+
+ imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
+
+ assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("300", "301")
+
+ imapFolder.setUidValidity(9000)
+ imapFolder.removeAllMessages()
+ addMessageToImapFolder(uid = 1)
+
+ imapSync.sync(FOLDER_SERVER_ID, syncConfig, syncListener)
+
+ assertThat(backendFolder.getMessageServerIds()).containsExactlyInAnyOrder("1")
+ verify(syncListener).syncNewMessage(FOLDER_SERVER_ID, messageServerId = "1", isOldMessage = false)
+ }
+
+ @Test
+ fun `sync with multiple FETCH responses when downloading small message should report correct progress`() {
+ val folderServerId = "FOLDER_TWO"
+ backendStorage.createBackendFolder(folderServerId)
+ val specialImapFolder = object : TestImapFolder(folderServerId) {
+ override fun fetch(
+ messages: List<ImapMessage>,
+ fetchProfile: FetchProfile,
+ listener: FetchListener?,
+ maxDownloadSize: Int
+ ) {
+ super.fetch(messages, fetchProfile, listener, maxDownloadSize)
+
+ // When fetching the body simulate an additional FETCH response
+ if (FetchProfile.Item.BODY in fetchProfile) {
+ val message = messages.first()
+ listener?.onFetchResponse(message, isFirstResponse = false)
+ }
+ }
+ }
+ specialImapFolder.addMessage(42)
+ imapStore.addFolder(specialImapFolder)
+
+ imapSync.sync(folderServerId, defaultSyncConfig, syncListener)
+
+ verify(syncListener, atLeast(1)).syncProgress(folderServerId, completed = 1, total = 1)
+ verify(syncListener, never()).syncProgress(folderServerId, completed = 2, total = 1)
+ }
+
+ private fun addMessageToBackendFolder(uid: Long, date: String = DEFAULT_MESSAGE_DATE) {
+ val messageServerId = uid.toString()
+ val message = createSimpleMessage(messageServerId, date).apply {
+ setUid(messageServerId)
+ }
+ backendFolder.saveMessage(message, MessageDownloadState.FULL)
+
+ val highestKnownUid = backendFolder.getFolderExtraNumber("imapHighestKnownUid") ?: 0
+ if (uid > highestKnownUid) {
+ backendFolder.setFolderExtraNumber("imapHighestKnownUid", uid)
+ }
+ }
+
+ private fun addMessageToImapFolder(uid: Long, flags: Set<Flag> = emptySet(), date: String = DEFAULT_MESSAGE_DATE) {
+ imapFolder.addMessage(uid, flags, date)
+ }
+
+ private fun TestImapFolder.addMessage(
+ uid: Long,
+ flags: Set<Flag> = emptySet(),
+ date: String = DEFAULT_MESSAGE_DATE
+ ) {
+ val messageServerId = uid.toString()
+ val message = createSimpleMessage(messageServerId, date)
+ addMessage(uid, message)
+
+ if (flags.isNotEmpty()) {
+ val imapMessage = getMessage(messageServerId)
+ setFlags(listOf(imapMessage), flags, true)
+ }
+ }
+
+ private fun addMessageToImapAndBackendFolder(uid: Long, date: String) {
+ addMessageToBackendFolder(uid, date)
+ addMessageToImapFolder(uid, date = date)
+ }
+
+ private fun createBackendStorage(): InMemoryBackendStorage {
+ return InMemoryBackendStorage().apply {
+ createBackendFolder(FOLDER_SERVER_ID)
+ }
+ }
+
+ private fun InMemoryBackendStorage.createBackendFolder(serverId: String) {
+ createFolderUpdater().use { updater ->
+ val folderInfo = FolderInfo(
+ serverId = serverId,
+ name = "irrelevant",
+ type = FolderType.REGULAR
+ )
+ updater.createFolders(listOf(folderInfo))
+ }
+ }
+
+ private fun createSyncConfig(): SyncConfig {
+ return SyncConfig(
+ expungePolicy = ExpungePolicy.MANUALLY,
+ earliestPollDate = null,
+ syncRemoteDeletions = true,
+ maximumAutoDownloadMessageSize = MAXIMUM_AUTO_DOWNLOAD_MESSAGE_SIZE,
+ defaultVisibleLimit = DEFAULT_VISIBLE_LIMIT,
+ syncFlags = setOf(Flag.SEEN, Flag.FLAGGED, Flag.ANSWERED, Flag.FORWARDED)
+ )
+ }
+
+ private fun createSimpleMessage(uid: String, date: String, text: String = "UID: $uid"): Message {
+ return buildMessage {
+ header("Subject", "Test Message")
+ header("From", "alice@domain.example")
+ header("To", "Bob <bob@domain.example>")
+ header("Date", date)
+ header("Message-ID", "<msg-$uid@domain.example>")
+
+ textBody(text)
+ }
+ }
+}
+
+private fun String.toDate(): Date {
+ val dateTimeField = DefaultFieldParser.parse("Date: $this") as DateTimeField
+ return dateTimeField.date
+}
diff --git a/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapFolder.kt b/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapFolder.kt
new file mode 100644
index 0000000..729a909
--- /dev/null
+++ b/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapFolder.kt
@@ -0,0 +1,179 @@
+package com.fsck.k9.backend.imap
+
+import com.fsck.k9.mail.BodyFactory
+import com.fsck.k9.mail.FetchProfile
+import com.fsck.k9.mail.Flag
+import com.fsck.k9.mail.Message
+import com.fsck.k9.mail.MessageRetrievalListener
+import com.fsck.k9.mail.Part
+import com.fsck.k9.mail.store.imap.FetchListener
+import com.fsck.k9.mail.store.imap.ImapFolder
+import com.fsck.k9.mail.store.imap.ImapMessage
+import com.fsck.k9.mail.store.imap.OpenMode
+import com.fsck.k9.mail.store.imap.createImapMessage
+import java.util.Date
+
+open class TestImapFolder(override val serverId: String) : ImapFolder {
+ override var mode: OpenMode? = null
+ protected set
+
+ override var messageCount: Int = 0
+
+ var wasExpunged: Boolean = false
+ private set
+
+ val isClosed: Boolean
+ get() = mode == null
+
+ private val messages = mutableMapOf<Long, Message>()
+ private val messageFlags = mutableMapOf<Long, MutableSet<Flag>>()
+ private var uidValidity: Long? = null
+
+ fun addMessage(uid: Long, message: Message) {
+ require(!messages.containsKey(uid)) {
+ "Folder '$serverId' already contains a message with the UID $uid"
+ }
+
+ messages[uid] = message
+ messageFlags[uid] = mutableSetOf()
+
+ messageCount = messages.size
+ }
+
+ fun removeAllMessages() {
+ messages.clear()
+ messageFlags.clear()
+ }
+
+ fun setUidValidity(value: Long) {
+ uidValidity = value
+ }
+
+ override fun open(mode: OpenMode) {
+ this.mode = mode
+ }
+
+ override fun close() {
+ mode = null
+ }
+
+ override fun exists(): Boolean {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun getUidValidity() = uidValidity
+
+ override fun getMessage(uid: String): ImapMessage {
+ return createImapMessage(uid)
+ }
+
+ override fun getUidFromMessageId(messageId: String): String? {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun getMessages(
+ start: Int,
+ end: Int,
+ earliestDate: Date?,
+ listener: MessageRetrievalListener<ImapMessage>?
+ ): List<ImapMessage> {
+ require(start > 0)
+ require(end >= start)
+ require(end <= messages.size)
+
+ return messages.keys.sortedDescending()
+ .slice((start - 1) until end)
+ .map { createImapMessage(uid = it.toString()) }
+ }
+
+ override fun areMoreMessagesAvailable(indexOfOldestMessage: Int, earliestDate: Date?): Boolean {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun fetch(
+ messages: List<ImapMessage>,
+ fetchProfile: FetchProfile,
+ listener: FetchListener?,
+ maxDownloadSize: Int
+ ) {
+ if (messages.isEmpty()) return
+
+ for (imapMessage in messages) {
+ val uid = imapMessage.uid.toLong()
+
+ val flags = messageFlags[uid].orEmpty().toSet()
+ imapMessage.setFlags(flags, true)
+
+ val storedMessage = this.messages[uid] ?: error("Message $uid not found")
+ for (header in storedMessage.headers) {
+ imapMessage.addHeader(header.name, header.value)
+ }
+ imapMessage.body = storedMessage.body
+
+ listener?.onFetchResponse(imapMessage, isFirstResponse = true)
+ }
+ }
+
+ override fun fetchPart(
+ message: ImapMessage,
+ part: Part,
+ bodyFactory: BodyFactory,
+ maxDownloadSize: Int
+ ) {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun search(
+ queryString: String?,
+ requiredFlags: Set<Flag>?,
+ forbiddenFlags: Set<Flag>?,
+ performFullTextSearch: Boolean
+ ): List<ImapMessage> {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun appendMessages(messages: List<Message>): Map<String, String>? {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun setFlags(flags: Set<Flag>, value: Boolean) {
+ if (value) {
+ for (messageFlagSet in messageFlags.values) {
+ messageFlagSet.addAll(flags)
+ }
+ } else {
+ for (messageFlagSet in messageFlags.values) {
+ messageFlagSet.removeAll(flags)
+ }
+ }
+ }
+
+ override fun setFlags(messages: List<ImapMessage>, flags: Set<Flag>, value: Boolean) {
+ for (message in messages) {
+ val uid = message.uid.toLong()
+ val messageFlagSet = messageFlags[uid] ?: error("Unknown message with UID $uid")
+ if (value) {
+ messageFlagSet.addAll(flags)
+ } else {
+ messageFlagSet.removeAll(flags)
+ }
+ }
+ }
+
+ override fun copyMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun moveMessages(messages: List<ImapMessage>, folder: ImapFolder): Map<String, String>? {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun expunge() {
+ mode = OpenMode.READ_WRITE
+ wasExpunged = true
+ }
+
+ override fun expungeUids(uids: List<String>) {
+ throw UnsupportedOperationException("not implemented")
+ }
+}
diff --git a/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapStore.kt b/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapStore.kt
new file mode 100644
index 0000000..db698d5
--- /dev/null
+++ b/backend/imap/src/test/java/com/fsck/k9/backend/imap/TestImapStore.kt
@@ -0,0 +1,48 @@
+package com.fsck.k9.backend.imap
+
+import com.fsck.k9.mail.FolderType
+import com.fsck.k9.mail.store.imap.FolderListItem
+import com.fsck.k9.mail.store.imap.ImapFolder
+import com.fsck.k9.mail.store.imap.ImapStore
+
+class TestImapStore : ImapStore {
+ private val folders = mutableMapOf<String, ImapFolder>()
+
+ fun addFolder(serverId: String): TestImapFolder {
+ require(!folders.containsKey(serverId)) { "Folder '$serverId' already exists" }
+
+ return TestImapFolder(serverId).also { folder ->
+ folders[serverId] = folder
+ }
+ }
+
+ fun addFolder(folder: ImapFolder) {
+ val serverId = folder.serverId
+ require(!folders.containsKey(serverId)) { "Folder '$serverId' already exists" }
+
+ folders[serverId] = folder
+ }
+
+ override fun getFolder(name: String): ImapFolder {
+ return folders[name] ?: error("Folder '$name' not found")
+ }
+
+ override fun getFolders(): List<FolderListItem> {
+ return folders.values.map { folder ->
+ FolderListItem(
+ serverId = folder.serverId,
+ name = "irrelevant",
+ type = FolderType.REGULAR,
+ oldServerId = null
+ )
+ }
+ }
+
+ override fun checkSettings() {
+ throw UnsupportedOperationException("not implemented")
+ }
+
+ override fun closeAllConnections() {
+ throw UnsupportedOperationException("not implemented")
+ }
+}
diff --git a/backend/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapMessageHelper.kt b/backend/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapMessageHelper.kt
new file mode 100644
index 0000000..c78d12b
--- /dev/null
+++ b/backend/imap/src/test/java/com/fsck/k9/mail/store/imap/ImapMessageHelper.kt
@@ -0,0 +1,3 @@
+package com.fsck.k9.mail.store.imap
+
+fun createImapMessage(uid: String) = ImapMessage(uid)