test(sync): pin MessageDao retention/backfill boundary SQL on real SQLite
The MailPruner/MailBackfiller unit tests mock the DAO, so the queries that actually define the device-only retention floor were never exercised against a real database: the newest-N-by-(timestampMillis, uid) prune selection, the strict age cutoff, the windowed reconcile that spares backfilled history, and the lowest-uid / count / oldest floor probes the backfiller stops on. Add an instrumented MessageDao test on an in-memory Room DB covering all of them, including the timestamp/uid tie-break direction (a flipped ORDER BY would locally delete the user's newest mail) and inInbox/folder/account scoping. This closes the disjoint-sets safety argument at the SQL-boundary level, not just the orchestration level. Verified on the API 37 emulator (5/5). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.entity.MessageEntity
|
||||
|
||||
/**
|
||||
* Real-SQLite behavior of the retention / backfill boundary queries on [MessageDao] (issues #12/#13).
|
||||
*
|
||||
* These queries *define* the device-only retention floor — the pruner deletes below it
|
||||
* ([MessageDao.syncedIdsBeyondCountInFolder] / [MessageDao.syncedIdsOlderThan]) and the backfiller
|
||||
* stops above it ([MessageDao.lowestSyncedUid] / [MessageDao.countSynced] /
|
||||
* [MessageDao.oldestSyncedTimestamp]) — so the whole "backfill and prune never fight over the same
|
||||
* rows" guarantee rests on their SQL. The [org.libremail.data.sync.MailPruner] /
|
||||
* [org.libremail.data.sync.MailBackfiller] unit tests mock the DAO, so the `ORDER BY … DESC LIMIT`
|
||||
* newest-N selection, the strict age cutoff, and the windowed reconcile that spares backfilled history
|
||||
* are exercised here against a real database instead.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MessageDaoRetentionTest {
|
||||
|
||||
private lateinit var db: LibreMailDatabase
|
||||
private lateinit var dao: MessageDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db = Room.inMemoryDatabaseBuilder(context, LibreMailDatabase::class.java).build()
|
||||
dao = db.messageDao()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun message(
|
||||
id: String,
|
||||
accountId: String = "acct",
|
||||
folder: String = "INBOX",
|
||||
uid: Long = 0L,
|
||||
timestampMillis: Long = 1_000L,
|
||||
inInbox: Boolean = true,
|
||||
) = MessageEntity(
|
||||
id = id,
|
||||
accountId = accountId,
|
||||
sender = "Ada",
|
||||
senderEmail = "ada@example.org",
|
||||
subject = "Hi",
|
||||
snippet = "",
|
||||
body = "",
|
||||
timestampMillis = timestampMillis,
|
||||
isRead = false,
|
||||
isStarred = false,
|
||||
folder = folder,
|
||||
inInbox = inInbox,
|
||||
uid = uid,
|
||||
)
|
||||
|
||||
/**
|
||||
* The count-based prune boundary keeps the newest [keep] by recency and returns the REST for
|
||||
* deletion, breaking timestamp ties by the higher UID. This pins the ordering *direction* (a
|
||||
* flipped `DESC` would keep the OLDEST rows — i.e. locally delete the user's most recent mail) and
|
||||
* the tie-break, neither of which the mocked-DAO unit tests can catch.
|
||||
*/
|
||||
@Test
|
||||
fun syncedIdsBeyondCountInFolderKeepsNewestByTimestampThenUid() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("A", uid = 30, timestampMillis = 300), // newest
|
||||
message("B", uid = 25, timestampMillis = 200), // ties C on timestamp; higher uid => newer
|
||||
message("C", uid = 20, timestampMillis = 200),
|
||||
message("D", uid = 10, timestampMillis = 100), // oldest
|
||||
// Scoping decoys: a search-only row and another folder must never enter the ranking.
|
||||
message("SR", uid = 99, timestampMillis = 999, inInbox = false),
|
||||
message("AR", uid = 5, timestampMillis = 50, folder = "Archive"),
|
||||
),
|
||||
)
|
||||
|
||||
// Keep the newest 2 (A, B); the rest are prunable. B is kept over C purely by the uid tie-break.
|
||||
assertEquals(
|
||||
setOf("C", "D"),
|
||||
dao.syncedIdsBeyondCountInFolder("acct", "INBOX", keep = 2).toSet(),
|
||||
)
|
||||
// Keeping at least as many as exist prunes nothing.
|
||||
assertEquals(emptyList<String>(), dao.syncedIdsBeyondCountInFolder("acct", "INBOX", keep = 4))
|
||||
}
|
||||
|
||||
/**
|
||||
* The age-based prune boundary selects rows STRICTLY older than the cutoff, across every folder,
|
||||
* scoped to the account and to synced (non-search) rows only.
|
||||
*/
|
||||
@Test
|
||||
fun syncedIdsOlderThanCutsStrictlyBelowAcrossFoldersAndScopesToAccount() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("boundary", uid = 25, timestampMillis = 200), // == cutoff -> kept (strict `<`)
|
||||
message("old-inbox", uid = 10, timestampMillis = 100),
|
||||
message("old-archive", uid = 5, timestampMillis = 150, folder = "Archive"),
|
||||
message("old-search", uid = 1, timestampMillis = 1, inInbox = false), // not synced
|
||||
message("old-other-account", accountId = "acct2", uid = 1, timestampMillis = 1),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
setOf("old-inbox", "old-archive"),
|
||||
dao.syncedIdsOlderThan("acct", cutoffMillis = 200).toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The windowed reconcile deletes only synced rows at/above the recent-UID window that the server no
|
||||
* longer returns; older backfilled history (below the window), other folders, and search rows are
|
||||
* left intact — the core guarantee that a foreground sync no longer wipes backfilled history (#12).
|
||||
*/
|
||||
@Test
|
||||
fun deleteSyncedInWindowNotInSparesBelowWindowHistoryAndOtherFolders() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("below", uid = 10), // below the window -> spared
|
||||
message("kept", uid = 20), // in window, in keep set -> spared
|
||||
message("gone-1", uid = 30), // in window, not kept -> deleted
|
||||
message("gone-2", uid = 40), // in window, not kept -> deleted
|
||||
message("search", uid = 22, inInbox = false), // not synced -> spared
|
||||
message("other-folder", uid = 25, folder = "Archive"), // different folder -> spared
|
||||
),
|
||||
)
|
||||
|
||||
dao.deleteSyncedInWindowNotIn("acct", "INBOX", minWindowUid = 20, keepIds = listOf("kept"))
|
||||
|
||||
assertEquals(
|
||||
setOf("below", "kept", "search", "other-folder"),
|
||||
dao.observeAll().first().map { it.id }.toSet(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The backfiller's floor probes reflect only an account's synced rows in the given folder, and are
|
||||
* null/zero for a folder with nothing cached (so the backfiller then starts from `Long.MAX_VALUE`).
|
||||
*/
|
||||
@Test
|
||||
fun floorProbesReflectOnlySyncedRowsInTheFolder() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("a", uid = 30, timestampMillis = 300),
|
||||
message("d", uid = 10, timestampMillis = 100),
|
||||
message("search", uid = 1, timestampMillis = 1, inInbox = false), // excluded
|
||||
message("archive", uid = 5, timestampMillis = 50, folder = "Archive"), // different folder
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(10L, dao.lowestSyncedUid("acct", "INBOX"))
|
||||
assertEquals(2, dao.countSynced("acct", "INBOX"))
|
||||
assertEquals(100L, dao.oldestSyncedTimestamp("acct", "INBOX"))
|
||||
|
||||
assertNull(dao.lowestSyncedUid("acct", "Nonexistent"))
|
||||
assertEquals(0, dao.countSynced("acct", "Nonexistent"))
|
||||
assertNull(dao.oldestSyncedTimestamp("acct", "Nonexistent"))
|
||||
}
|
||||
|
||||
/** Backfill / prune enumerate their targets via [syncedFolders]: distinct synced folders, per account. */
|
||||
@Test
|
||||
fun syncedFoldersReturnsDistinctSyncedFoldersForTheAccount() = runBlocking {
|
||||
dao.insertNew(
|
||||
listOf(
|
||||
message("i1", folder = "INBOX"),
|
||||
message("i2", folder = "INBOX"),
|
||||
message("ar", folder = "Archive"),
|
||||
message("sr", folder = "Search", inInbox = false), // search-only folder excluded
|
||||
message("other", accountId = "acct2", folder = "Spam"), // other account excluded
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(setOf("INBOX", "Archive"), dao.syncedFolders("acct").toSet())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user