Skip to content

Commit 5bd2684

Browse files
committed
feat(db-repository): implement update message in MessageLifecycleRepository
- Add update(message, accountId, folderId) to MessageLifecycleRepository with legacy adapter - Add MessageAlreadyExists error and duplicate detection in create() via MessageQueryRepository - Use create/update in K9BackendFolder.saveMessage, recovering from MessageAlreadyExists - Use create/update in DraftOperations.saveDraftLocally - Derive legacy download state from message flags in the mapper - Add update and duplicate-detection tests to DefaultMessageLifecycleRepositoryTest GG-ID: c-a66bc30 GG-Parent: c-639ad93
1 parent cadd4f6 commit 5bd2684

14 files changed

Lines changed: 236 additions & 104 deletions

File tree

‎app-common/src/main/kotlin/net/thunderbird/app/common/feature/mail/message/MailMessageModule.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ internal val mailMessageModule = module {
6161
single<MessageLifecycleRepository> {
6262
DefaultMessageLifecycleRepository(
6363
logger = get(),
64+
messageQueryRepository = get(),
6465
messageStoreManager = get(),
6566
saveMessageDataCreator = get(),
6667
messageMapper = get(),

‎app-common/src/main/kotlin/net/thunderbird/app/common/feature/mail/message/data/repository/DefaultMessageLifecycleRepository.kt‎

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import kotlinx.coroutines.withContext
1010
import net.thunderbird.app.common.feature.mail.message.domain.model.LegacyMessageSource
1111
import net.thunderbird.components.core.logging.Logger
1212
import net.thunderbird.components.core.outcome.Outcome
13+
import net.thunderbird.components.core.outcome.fold
1314
import net.thunderbird.feature.account.AccountId
1415
import net.thunderbird.feature.mail.folder.FolderId
1516
import net.thunderbird.feature.mail.folder.LegacyFolderIdFactory
@@ -19,8 +20,10 @@ import net.thunderbird.feature.mail.message.Message
1920
import net.thunderbird.feature.mail.message.MessageDownloadState
2021
import net.thunderbird.feature.mail.message.MessageId
2122
import net.thunderbird.feature.mail.message.MessageServerId
23+
import net.thunderbird.feature.mail.message.domain.GetMessageIdCriteria
2224
import net.thunderbird.feature.mail.message.domain.MessageLifecycleError
2325
import net.thunderbird.feature.mail.message.domain.MessageLifecycleRepository
26+
import net.thunderbird.feature.mail.message.domain.MessageQueryRepository
2427
import net.thunderbird.feature.mail.message.mapper.MessageDataMapper
2528
import com.fsck.k9.mail.Message as LegacyMessageDto
2629
import com.fsck.k9.mail.MessageDownloadState as LegacyMessageDownloadState
@@ -30,6 +33,7 @@ private const val LOG_ID = "[repository][message-lifecycle]"
3033

3134
class DefaultMessageLifecycleRepository(
3235
private val logger: Logger,
36+
private val messageQueryRepository: MessageQueryRepository,
3337
private val messageStoreManager: MessageStoreManager,
3438
private val saveMessageDataCreator: SaveMessageDataCreator,
3539
private val messageMapper: MessageDataMapper<LegacyMessageDto>,
@@ -41,6 +45,12 @@ class DefaultMessageLifecycleRepository(
4145
accountId: AccountId,
4246
folderId: FolderId?,
4347
): Outcome<MessageId, MessageLifecycleError> = withContext(ioDispatcher) {
48+
val messageServerId = message.serverId
49+
val existingMessageId = verifyMessageExists(folderId, messageServerId, accountId)
50+
if (existingMessageId != null) {
51+
logger.warn(TAG) { "$LOG_ID message already exists with id '$existingMessageId'. Use update instead." }
52+
return@withContext Outcome.failure(MessageLifecycleError.MessageAlreadyExists(existingMessageId))
53+
}
4454
logger.debug(TAG) {
4555
"$LOG_ID creating new message in folder '${folderId ?: "<outbox-id>"}' for account '$accountId'"
4656
}
@@ -54,11 +64,10 @@ class DefaultMessageLifecycleRepository(
5464
}
5565
logger.verbose(TAG) { "$LOG_ID assigning message to folder id = $legacyFolderId" }
5666

57-
val messageServerId = message.serverId
5867
val messageId = if (messageServerId == null) {
59-
createLocalMessage(messageStore, legacyFolderId, messageData)
68+
upsertLocalMessage(messageStore, legacyFolderId, messageData)
6069
} else {
61-
createRemoteMessage(messageStore, legacyFolderId, messageServerId, messageData)
70+
upsertRemoteMessage(messageStore, legacyFolderId, messageServerId, messageData)
6271
}
6372
logger.verbose(TAG) { "$LOG_ID message created successfully. MessageId = $messageId" }
6473
Outcome.success(messageId)
@@ -67,8 +76,32 @@ class DefaultMessageLifecycleRepository(
6776
override suspend fun update(
6877
message: Message,
6978
accountId: AccountId,
70-
): Outcome<MessageId, MessageLifecycleError> {
71-
TODO("Not yet implemented")
79+
folderId: FolderId,
80+
): Outcome<MessageId, MessageLifecycleError> = withContext(ioDispatcher) {
81+
// NOTE: The legacy store resolves remote messages by folder id, server id (messages.uid), so
82+
// message.id is not strictly needed for that branch today. We still require it so the
83+
// update() contract is the same for local and remote messages and stays valid when
84+
// the data source is replaced.
85+
// Revisit upsertRemoteMessage once the new db can update by message id directly.
86+
val messageId = requireNotNull(message.id) { "$LOG_ID The message.id is required to update a message." }
87+
logger.debug(TAG) {
88+
"$LOG_ID updating message '$messageId' in folder '$folderId' for account '$accountId'"
89+
}
90+
logger.verbose(TAG) { "$LOG_ID message = $message" }
91+
val legacyMessage = messageMapper.toDto(message)
92+
val downloadState = message.downloadState.toLegacyDownloadState()
93+
val messageData = saveMessageDataCreator.createSaveMessageData(legacyMessage, downloadState)
94+
val messageStore = messageStoreManager.getMessageStore(accountId)
95+
val legacyFolderId = LegacyFolderIdFactory.toLegacyId(folderId)
96+
val messageServerId = message.serverId
97+
val updatedMessageId = if (messageServerId == null) {
98+
val legacyExistingMessageId = LegacyMessageIdFactory.toLegacyId(messageId)
99+
upsertLocalMessage(messageStore, legacyFolderId, messageData, legacyExistingMessageId)
100+
} else {
101+
upsertRemoteMessage(messageStore, legacyFolderId, messageServerId, messageData)
102+
}
103+
logger.verbose(TAG) { "$LOG_ID message updated successfully. MessageId = $updatedMessageId" }
104+
Outcome.success(updatedMessageId)
72105
}
73106

74107
override suspend fun move(
@@ -93,21 +126,36 @@ class DefaultMessageLifecycleRepository(
93126
TODO("Not yet implemented")
94127
}
95128

96-
private fun createLocalMessage(
129+
private suspend fun verifyMessageExists(
130+
folderId: FolderId?,
131+
messageServerId: MessageServerId?,
132+
accountId: AccountId,
133+
): MessageId? {
134+
return if (folderId != null && messageServerId != null) {
135+
messageQueryRepository
136+
.findIdByCriteria(accountId, GetMessageIdCriteria(folderId, messageServerId))
137+
.fold(onSuccess = { it }, onFailure = { throw it.throwable })
138+
} else {
139+
null
140+
}
141+
}
142+
143+
private fun upsertLocalMessage(
97144
messageStore: ListenableMessageStore,
98145
legacyFolderId: Long,
99146
messageData: SaveMessageData,
147+
existingLegacyMessageId: Long? = null,
100148
): MessageId {
101149
logger.verbose(TAG) { "$LOG_ID creating local message in folder id '$legacyFolderId'" }
102150
val legacyMessageId = messageStore.saveLocalMessage(
103151
folderId = legacyFolderId,
104152
messageData = messageData,
105-
existingMessageId = null,
153+
existingMessageId = existingLegacyMessageId,
106154
)
107155
return LegacyMessageIdFactory.of(legacyMessageId)
108156
}
109157

110-
private fun createRemoteMessage(
158+
private fun upsertRemoteMessage(
111159
messageStore: ListenableMessageStore,
112160
legacyFolderId: Long,
113161
messageServerId: MessageServerId,

‎app-common/src/test/kotlin/net/thunderbird/app/common/feature/mail/message/data/repository/DefaultMessageLifecycleRepositoryTest.kt‎

Lines changed: 77 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,22 @@ import app.k9mail.legacy.mailstore.MessageStoreFactory
66
import app.k9mail.legacy.mailstore.MessageStoreManager
77
import app.k9mail.legacy.mailstore.SaveMessageData
88
import app.k9mail.legacy.message.extractors.PreviewResult
9+
import assertk.assertFailure
910
import assertk.assertThat
1011
import assertk.assertions.isEqualTo
12+
import assertk.assertions.isInstanceOf
1113
import com.fsck.k9.mail.internet.MimeMessage
1214
import com.fsck.k9.mailstore.SaveMessageDataCreator
1315
import kotlin.test.Test
1416
import kotlinx.coroutines.Dispatchers
1517
import kotlinx.coroutines.ExperimentalCoroutinesApi
16-
import kotlinx.coroutines.flow.Flow
17-
import kotlinx.coroutines.flow.flowOf
1818
import kotlinx.coroutines.test.UnconfinedTestDispatcher
1919
import kotlinx.coroutines.test.runTest
2020
import kotlinx.datetime.LocalDateTime
2121
import net.thunderbird.app.common.feature.mail.FakeData
22+
import net.thunderbird.components.core.logging.testing.TestLogger
2223
import net.thunderbird.components.core.outcome.Outcome
23-
import net.thunderbird.core.android.account.AccountRemovedListener
24-
import net.thunderbird.core.android.account.AccountsChangeListener
2524
import net.thunderbird.core.android.account.LegacyAccount
26-
import net.thunderbird.core.android.account.LegacyAccountDto
27-
import net.thunderbird.core.android.account.LegacyAccountDtoManager
28-
import net.thunderbird.core.logging.testing.TestLogger
2925
import net.thunderbird.feature.account.AccountId
3026
import net.thunderbird.feature.mail.folder.FolderId
3127
import net.thunderbird.feature.mail.folder.LegacyFolderIdFactory
@@ -40,11 +36,13 @@ import net.thunderbird.feature.mail.message.MessageHeaders
4036
import net.thunderbird.feature.mail.message.MessageId
4137
import net.thunderbird.feature.mail.message.MessageServerId
4238
import net.thunderbird.feature.mail.message.domain.GetMessageIdCriteria
39+
import net.thunderbird.feature.mail.message.domain.MessageLifecycleError
4340
import net.thunderbird.feature.mail.message.domain.MessageQueryError
4441
import net.thunderbird.feature.mail.message.domain.MessageQueryRepository
4542
import net.thunderbird.feature.mail.message.mapper.MessageDataMapper
4643
import org.mockito.kotlin.mock
4744
import org.mockito.kotlin.verify
45+
import org.mockito.kotlin.verifyNoInteractions
4846
import org.mockito.kotlin.whenever
4947
import com.fsck.k9.mail.Message as LegacyMessageDto
5048
import com.fsck.k9.mail.MessageDownloadState as LegacyMessageDownloadState
@@ -81,7 +79,6 @@ class DefaultMessageLifecycleRepositoryTest {
8179
private val saveMessageDataCreator = mock<SaveMessageDataCreator>()
8280
private val messageQueryRepository = FakeMessageQueryRepository()
8381
private val messageStoreManager = MessageStoreManager(
84-
accountManager = FakeLegacyAccountDtoManager(accounts = listOf(LegacyAccountDto(account.id))),
8582
messageStoreFactory = FakeMessageStoreFactory(
8683
messageStoresByUuid = mapOf(
8784
account.id to ListenableMessageStore(messageStore, mainImmediateDispatcher = Dispatchers.Unconfined),
@@ -92,14 +89,15 @@ class DefaultMessageLifecycleRepositoryTest {
9289
@OptIn(ExperimentalCoroutinesApi::class)
9390
private val testSubject = DefaultMessageLifecycleRepository(
9491
logger = TestLogger(),
92+
messageQueryRepository = messageQueryRepository,
9593
messageStoreManager = messageStoreManager,
9694
saveMessageDataCreator = saveMessageDataCreator,
9795
messageMapper = FakeMessageDataMapper(legacyMessage),
9896
outboxFolderManager = FakeOutboxFolderManager(outboxFolderId = OUTBOX_FOLDER_ID),
9997
ioDispatcher = UnconfinedTestDispatcher(),
10098
)
10199

102-
// region create
100+
// region [create]
103101

104102
@Test
105103
fun `create should save a remote message when it does not exist yet`() = runTest {
@@ -114,6 +112,25 @@ class DefaultMessageLifecycleRepositoryTest {
114112

115113
// Assert
116114
assertThat(outcome).isEqualTo(Outcome.success(LegacyMessageIdFactory.of(SAVED_MESSAGE_ID)))
115+
assertThat(messageQueryRepository.lastCriteria)
116+
.isEqualTo(GetMessageIdCriteria(folderId, MessageServerId(SERVER_ID)))
117+
verify(messageStore).saveRemoteMessage(FOLDER_ID, SERVER_ID, saveMessageData)
118+
}
119+
120+
@Test
121+
fun `create should return MessageAlreadyExists without touching the store when the message exists`() = runTest {
122+
// Arrange
123+
val existingMessageId = LegacyMessageIdFactory.of(EXISTING_MESSAGE_ID)
124+
messageQueryRepository.result = Outcome.success(existingMessageId)
125+
val message = buildDomainMessage(serverId = MessageServerId(SERVER_ID))
126+
127+
// Act
128+
val outcome = testSubject.create(message, accountId, folderId)
129+
130+
// Assert
131+
assertThat(outcome).isEqualTo(Outcome.failure(MessageLifecycleError.MessageAlreadyExists(existingMessageId)))
132+
verifyNoInteractions(messageStore)
133+
verifyNoInteractions(saveMessageDataCreator)
117134
}
118135

119136
@Test
@@ -164,6 +181,56 @@ class DefaultMessageLifecycleRepositoryTest {
164181
verify(saveMessageDataCreator).createSaveMessageData(legacyMessage, LegacyMessageDownloadState.PARTIAL, null)
165182
}
166183

184+
// endregion [create]
185+
186+
// region [update]
187+
188+
@Test
189+
fun `update should replace the existing local message when the message has no server id`() = runTest {
190+
// Arrange
191+
val message = buildDomainMessage(id = LegacyMessageIdFactory.of(EXISTING_MESSAGE_ID), serverId = null)
192+
stubSaveMessageData()
193+
whenever(messageStore.saveLocalMessage(FOLDER_ID, saveMessageData, EXISTING_MESSAGE_ID))
194+
.thenReturn(EXISTING_MESSAGE_ID)
195+
196+
// Act
197+
val outcome = testSubject.update(message, accountId, folderId)
198+
199+
// Assert
200+
assertThat(outcome).isEqualTo(Outcome.success(LegacyMessageIdFactory.of(EXISTING_MESSAGE_ID)))
201+
verify(messageStore).saveLocalMessage(FOLDER_ID, saveMessageData, EXISTING_MESSAGE_ID)
202+
}
203+
204+
@Test
205+
fun `update should save the remote message by folder and server id`() = runTest {
206+
// Arrange
207+
val message = buildDomainMessage(
208+
id = LegacyMessageIdFactory.of(EXISTING_MESSAGE_ID),
209+
serverId = MessageServerId(SERVER_ID),
210+
)
211+
stubSaveMessageData()
212+
whenever(messageStore.saveRemoteMessage(FOLDER_ID, SERVER_ID, saveMessageData))
213+
.thenReturn(EXISTING_MESSAGE_ID)
214+
215+
// Act
216+
val outcome = testSubject.update(message, accountId, folderId)
217+
218+
// Assert
219+
assertThat(outcome).isEqualTo(Outcome.success(LegacyMessageIdFactory.of(EXISTING_MESSAGE_ID)))
220+
verify(messageStore).saveRemoteMessage(FOLDER_ID, SERVER_ID, saveMessageData)
221+
}
222+
223+
@Test
224+
fun `update should fail fast when the message has no id`() = runTest {
225+
// Arrange
226+
val message = buildDomainMessage(id = null, serverId = null)
227+
228+
// Act & Assert
229+
assertFailure { testSubject.update(message, accountId, folderId) }
230+
.isInstanceOf<IllegalArgumentException>()
231+
verifyNoInteractions(messageStore)
232+
}
233+
167234
// endregion
168235

169236
private fun stubSaveMessageData() {
@@ -203,26 +270,10 @@ class DefaultMessageLifecycleRepositoryTest {
203270
)
204271
}
205272

206-
private class FakeLegacyAccountDtoManager(
207-
accounts: List<LegacyAccountDto>,
208-
) : LegacyAccountDtoManager {
209-
private val accountsByUuid = accounts.associateBy { it.id }
210-
211-
override fun getAccounts(): List<LegacyAccountDto> = accountsByUuid.values.toList()
212-
override fun getAccountsFlow(): Flow<List<LegacyAccountDto>> = flowOf(getAccounts())
213-
override fun getById(accountId: AccountId): LegacyAccountDto? = accountsByUuid[accountId]
214-
override fun observeById(accountId: AccountId): Flow<LegacyAccountDto?> = flowOf(getById(accountId))
215-
override fun addAccountRemovedListener(listener: AccountRemovedListener) = Unit
216-
override fun moveAccount(account: LegacyAccountDto, newPosition: Int) = Unit
217-
override fun addOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) = Unit
218-
override fun removeOnAccountsChangeListener(accountsChangeListener: AccountsChangeListener) = Unit
219-
override fun saveAccount(account: LegacyAccountDto) = Unit
220-
}
221-
222273
private class FakeMessageStoreFactory(
223274
private val messageStoresByUuid: Map<AccountId, ListenableMessageStore>,
224275
) : MessageStoreFactory {
225-
override fun create(account: LegacyAccountDto): ListenableMessageStore = messageStoresByUuid.getValue(account.id)
276+
override fun create(accountId: AccountId): ListenableMessageStore = messageStoresByUuid.getValue(accountId)
226277
}
227278

228279
private class FakeMessageQueryRepository : MessageQueryRepository {

‎feature/mail/message/api/src/commonMain/kotlin/net/thunderbird/feature/mail/message/domain/MessageLifecycleRepository.kt‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ interface MessageLifecycleRepository {
2525
folderId: FolderId? = null,
2626
): Outcome<MessageId, MessageLifecycleError>
2727

28-
suspend fun update(message: Message, accountId: AccountId): Outcome<MessageId, MessageLifecycleError>
28+
suspend fun update(
29+
message: Message,
30+
accountId: AccountId,
31+
folderId: FolderId,
32+
): Outcome<MessageId, MessageLifecycleError>
33+
2934
suspend fun move(messageId: MessageId, destinationFolderId: FolderId): Outcome<MessageId, MessageLifecycleError>
3035
suspend fun copy(messageId: MessageId, destinationFolderId: FolderId): Outcome<MessageId, MessageLifecycleError>
3136
suspend fun destroy(
@@ -37,4 +42,10 @@ interface MessageLifecycleRepository {
3742

3843
sealed interface MessageLifecycleError {
3944
val throwable: Throwable?
45+
46+
data class MessageAlreadyExists(val messageId: MessageId) : MessageLifecycleError {
47+
override val throwable: Throwable? = null
48+
}
49+
50+
data class UnhandledError(override val throwable: Throwable) : MessageLifecycleError
4051
}

0 commit comments

Comments
 (0)