Release 1.0.0 (#20)
First stable release. Merging this bumps versionName to 1.0.0 and triggers the release pipeline (F-Droid, Codeberg, Play). **App** - CalDAV sync built in, with Agendula's own task store; OpenTasks / tasks.org stay available and can be copied over in Settings → Storage - repeating tasks, several reminders per task, lists managed in the app, iCalendar import/export, widget and Quick Settings tile - a list can be kept out of the smart lists (#18) and gets its own notification channel (#17) - duplicate a task with its subtasks (#16) - HTML descriptions shown as plain text (#15) - relative day words in reminder notifications (#14) - asks for exact-alarm access instead of claiming USE_EXACT_ALARM, and re-arms reminders when that access changes **Release plumbing** - floret-kit bumped to v0.4.0; the old pin was never pushed, so a clean clone couldn't check out the submodule. 0.4.0 drops CrashConfig.issueTitle (crash issues are always filed in English) - prebuilt .so files ship unstripped, so the build no longer depends on whether an NDK is installed; now checked by check_reproducible_release.sh - official F-Droid recipe in docs/fdroid-official/, to submit to fdroiddata once v1.0.0 is tagged - Google Play: fastlane uploads the AAB and every locale's What's New after the F-Droid release; a separate listing lane pushes text and graphics from the fastlane tree, which CI now checks against Play's limits - store listing: title "Agendula: Tasks" in every locale, icon, feature graphic, screenshots and 1.0.0 changelogs in en-US, en-GB, de-DE and pt-BR crash_report_issue_title is now unused but stays until Weblate removes the translated copies. Closes #14, closes #15, closes #16, closes #17, closes #18 Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/20
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,123 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestName
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The credential store, against a real Keystore.
|
||||
*
|
||||
* Instrumented rather than Robolectric because the thing under test *is* the
|
||||
* platform: a shadowed Keystore would encrypt and decrypt happily and prove
|
||||
* nothing about whether the key spec is usable for background sync.
|
||||
*
|
||||
* The case that matters most is the last one. A restored backup carries the
|
||||
* ciphertext but not the key — Keystore keys are non-exportable — so the blob
|
||||
* becomes permanently undecryptable. That must surface as "sign in again", never
|
||||
* as a crash and never as a silently non-syncing account, which is why
|
||||
* `backup_rules.xml` excludes this file in the first place.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class CredentialStoreTest {
|
||||
|
||||
@get:Rule val testName = TestName()
|
||||
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var dataStore: DataStore<Preferences>
|
||||
private lateinit var store: CredentialStore
|
||||
|
||||
private val context: Context get() = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// ⚠️ A file per test, and a scope we can cancel. DataStore's FileStorage
|
||||
// keeps a process-wide set of active files and refuses a second
|
||||
// connection to one ("There are multiple DataStores active for the same
|
||||
// file"); the entry is released only when the owning scope's job
|
||||
// completes, and the factory's default scope is never cancelled. Sharing
|
||||
// one file across methods therefore fails every test after the first.
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
dataStore = PreferenceDataStoreFactory.create(scope = scope) {
|
||||
context.preferencesDataStoreFile("credential_store_test_${testName.methodName}")
|
||||
}
|
||||
store = CredentialStore(dataStore)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
runTest { store.clearAll() }
|
||||
scope.cancel()
|
||||
context.preferencesDataStoreFile("credential_store_test_${testName.methodName}").delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anAppPasswordRoundTrips() = runTest {
|
||||
assertThat(store.put(accountId = 1L, appPassword = "s3cret-app-pw")).isTrue()
|
||||
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("s3cret-app-pw"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNonLatin1PasswordSurvives() = runTest {
|
||||
// The same charset trap the Basic interceptor has: anything that silently
|
||||
// mangles "ä" produces a 401 the user reads as a wrong password.
|
||||
store.put(accountId = 1L, appPassword = "pä§§wörd-🔐")
|
||||
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("pä§§wörd-🔐"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountsDoNotShareACredential() = runTest {
|
||||
store.put(1L, "first")
|
||||
store.put(2L, "second")
|
||||
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Present("first"))
|
||||
assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnknownAccountIsAbsentRatherThanAnError() = runTest {
|
||||
assertThat(store.get(99L)).isEqualTo(CredentialStore.Secret.Absent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearingRemovesOnlyThatAccount() = runTest {
|
||||
store.put(1L, "first")
|
||||
store.put(2L, "second")
|
||||
store.clear(1L)
|
||||
assertThat(store.get(1L)).isEqualTo(CredentialStore.Secret.Absent)
|
||||
assertThat(store.get(2L)).isEqualTo(CredentialStore.Secret.Present("second"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aCiphertextThisDeviceCannotDecryptMeansReAuthenticate() = runTest {
|
||||
// Stands in for the restored-backup case: the blob is present and
|
||||
// well-formed Base64, but was not produced by this device's key.
|
||||
dataStore.edit {
|
||||
it[stringPreferencesKey("caldav_app_password_1")] =
|
||||
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
}
|
||||
assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBlobThatIsNotBase64AtAllIsAlsoRecoverable() = runTest {
|
||||
dataStore.edit { it[stringPreferencesKey("caldav_app_password_1")] = "not base64 !!" }
|
||||
assertThat(store.get(1L)).isInstanceOf(CredentialStore.Secret.Unrecoverable::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.jeanlucmakiola.agendula.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.R
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The account type and authority exist in two places that cannot see each other:
|
||||
* `SyncContract`, derived from `BuildConfig.APPLICATION_ID`, and the `resValue`
|
||||
* strings the XML descriptors read. Drift between them is invisible at build
|
||||
* time and shows up as an account the sync framework will not trigger — the
|
||||
* silent no-op, with nothing in the log.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SyncContractTest {
|
||||
|
||||
private val context: Context get() = ApplicationProvider.getApplicationContext()
|
||||
|
||||
@Test
|
||||
fun theAccountTypeMatchesTheAuthenticatorDescriptor() {
|
||||
assertThat(SyncContract.ACCOUNT_TYPE)
|
||||
.isEqualTo(context.getString(R.string.account_type))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theAuthorityMatchesTheSyncAdapterDescriptor() {
|
||||
assertThat(SyncContract.AUTHORITY)
|
||||
.isEqualTo(context.getString(R.string.sync_authority))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theAuthorityMatchesTheStubProviderInTheManifest() {
|
||||
// The provider is what makes the authority real; a mismatch here means
|
||||
// requestSync addresses nothing.
|
||||
val provider = context.packageManager
|
||||
.resolveContentProvider(SyncContract.AUTHORITY, 0)
|
||||
assertThat(provider).isNotNull()
|
||||
assertThat(provider!!.name).isEqualTo(SyncStubProvider::class.java.name)
|
||||
}
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.legacy
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The one-shot import, against `assets/tasks-v23.db` — the dmfs v23 fixture
|
||||
* `scripts/make_import_fixture.py` seeds. Instrumented because both halves need
|
||||
* a real SQLite: the source file and Room.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OneShotImportTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var prefs: DataStore<Preferences>
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var importer: OneShotImport
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
prefs = PreferenceDataStoreFactory.create(scope = scope) {
|
||||
temp.newFile("import-${counter++}.preferences_pb").also(File::delete)
|
||||
}
|
||||
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
importer = OneShotImport(context, db, prefs)
|
||||
legacyFile().delete()
|
||||
archiveFile().delete()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
scope.cancel()
|
||||
legacyFile().delete()
|
||||
archiveFile().delete()
|
||||
}
|
||||
|
||||
private fun legacyFile() = context.getDatabasePath(OneShotImport.LEGACY_NAME)
|
||||
private fun archiveFile() = context.getDatabasePath(OneShotImport.ARCHIVE_NAME)
|
||||
|
||||
/** The fixture, copied out of the test APK's assets. */
|
||||
private fun fixture(target: File = temp.newFile("tasks-v23-copy.db")): File {
|
||||
InstrumentationRegistry.getInstrumentation().context.assets.open(FIXTURE).use { source ->
|
||||
target.outputStream().use(source::copyTo)
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private fun taskRows(): Map<String, TaskEntity> =
|
||||
db.tasks().tasks(null, includeCompleted = true).associate { it.task.title!! to it.task }
|
||||
|
||||
// --- what lands -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun importsEveryLiveTaskAndLeavesTheDeletedOneBehind() {
|
||||
val counts = importer.importFrom(fixture())
|
||||
|
||||
assertThat(counts).isEqualTo(ImportCounts(lists = 3, tasks = 8, alarms = 2))
|
||||
assertThat(taskRows().keys).containsExactly(
|
||||
"Buy milk",
|
||||
"Call the dentist",
|
||||
"Gather receipts",
|
||||
"Renew domain",
|
||||
"Water the plants",
|
||||
"Team offsite",
|
||||
"Task in a hidden list",
|
||||
"Ship the release",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importsEveryListAsADeviceOnlyListWithItsFlags() {
|
||||
importer.importFrom(fixture())
|
||||
|
||||
val lists = db.taskLists().lists().associateBy { it.list.name }
|
||||
assertThat(lists.keys).containsExactly("Personal", "Hidden list", "Work")
|
||||
assertThat(lists.values.map { it.list.accountId }).containsExactly(null, null, null)
|
||||
assertThat(lists.getValue("Personal").list.isVisible).isTrue()
|
||||
assertThat(lists.getValue("Hidden list").list.isVisible).isFalse()
|
||||
// The list that sat under a real account: still imported, owner kept.
|
||||
assertThat(lists.getValue("Work").list.owner).isEqualTo("Me")
|
||||
assertThat(lists.getValue("Work").list.color).isEqualTo(0xFF2244AA.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carriesTheTaskFieldsAcross() {
|
||||
importer.importFrom(fixture())
|
||||
val tasks = taskRows()
|
||||
|
||||
val milk = tasks.getValue("Buy milk")
|
||||
assertThat(milk.due).isEqualTo(Instant.fromEpochMilliseconds(T0 + DAY))
|
||||
assertThat(milk.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(milk.createdAt).isEqualTo(Instant.fromEpochMilliseconds(T0))
|
||||
|
||||
val dentist = tasks.getValue("Call the dentist")
|
||||
assertThat(dentist.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(dentist.percentComplete).isEqualTo(40)
|
||||
|
||||
val domain = tasks.getValue("Renew domain")
|
||||
assertThat(domain.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(domain.completedAt).isEqualTo(Instant.fromEpochMilliseconds(T0 - DAY))
|
||||
|
||||
val plants = tasks.getValue("Water the plants")
|
||||
assertThat(plants.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH")
|
||||
assertThat(plants.timezone).isEqualTo("Europe/Berlin")
|
||||
assertThat(plants.dtstart).isEqualTo(Instant.fromEpochMilliseconds(T0))
|
||||
|
||||
assertThat(tasks.getValue("Team offsite").isAllDay).isTrue()
|
||||
}
|
||||
|
||||
// --- uids -----------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun keepsExistingUidsAndMintsOneWhereTheLegacyRowHadNone() {
|
||||
importer.importFrom(fixture())
|
||||
val tasks = taskRows()
|
||||
|
||||
assertThat(tasks.getValue("Buy milk").uid).isEqualTo("a1b2c3d4-0000-4000-8000-000000000001")
|
||||
// The external-account row's uid is what lets it be re-attached later.
|
||||
assertThat(tasks.getValue("Ship the release").uid)
|
||||
.isEqualTo("a1b2c3d4-0000-4000-8000-000000000009")
|
||||
|
||||
val minted = tasks.getValue("Call the dentist").uid
|
||||
assertThat(minted).isNotEmpty()
|
||||
assertThat(UUID.fromString(minted).version()).isEqualTo(4)
|
||||
assertThat(tasks.values.map { it.uid }.toSet()).hasSize(tasks.size)
|
||||
}
|
||||
|
||||
// --- the id remap ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun remapsListIdsOntoTheNewRowIds() {
|
||||
importer.importFrom(fixture())
|
||||
|
||||
val lists = db.taskLists().lists().associateBy { it.list.name }
|
||||
val byList = db.tasks().tasks(null, includeCompleted = true)
|
||||
.groupBy { it.task.listId }
|
||||
.mapValues { (_, rows) -> rows.size }
|
||||
|
||||
assertThat(byList[lists.getValue("Personal").list.id]).isEqualTo(6)
|
||||
assertThat(byList[lists.getValue("Hidden list").list.id]).isEqualTo(1)
|
||||
assertThat(byList[lists.getValue("Work").list.id]).isEqualTo(1)
|
||||
// No task kept a dmfs row id that Room never handed out.
|
||||
assertThat(byList.keys).containsExactlyElementsIn(lists.values.map { it.list.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun remapsParentIdsOntoTheNewRowIds() {
|
||||
importer.importFrom(fixture())
|
||||
val tasks = taskRows()
|
||||
|
||||
val parent = tasks.getValue("Buy milk")
|
||||
val child = tasks.getValue("Gather receipts")
|
||||
assertThat(child.parentId).isEqualTo(parent.id)
|
||||
assertThat(db.tasks().subtasks(parent.id).map { it.task.title }).containsExactly("Gather receipts")
|
||||
assertThat(tasks.values.filter { it.parentId != null }).hasSize(1)
|
||||
}
|
||||
|
||||
// --- alarms ---------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun importsAlarmsAndSkipsEveryOtherProperty() {
|
||||
importer.importFrom(fixture())
|
||||
val tasks = taskRows()
|
||||
|
||||
assertThat(db.alarms().all()).hasSize(2)
|
||||
|
||||
val milk = db.alarms().forTask(tasks.getValue("Buy milk").id).single()
|
||||
assertThat(milk.minutesBefore).isEqualTo(30)
|
||||
assertThat(milk.reference).isEqualTo(AlarmReference.DUE)
|
||||
assertThat(milk.message).isNull()
|
||||
|
||||
val release = db.alarms().forTask(tasks.getValue("Ship the release").id).single()
|
||||
assertThat(release.minutesBefore).isEqualTo(1440)
|
||||
assertThat(release.reference).isEqualTo(AlarmReference.DUE)
|
||||
assertThat(release.message).isEqualTo("Ship it")
|
||||
|
||||
// The category property on task 1 is not an alarm.
|
||||
assertThat(db.alarms().all().map { it.message }).doesNotContain("Errands")
|
||||
}
|
||||
|
||||
// --- running it -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun runIfNeededImportsArchivesTheSourceAndThenDoesNothing() = runBlocking {
|
||||
fixture(legacyFile())
|
||||
|
||||
val first = importer.runIfNeeded()
|
||||
|
||||
assertThat(first).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
|
||||
assertThat(legacyFile().exists()).isFalse()
|
||||
assertThat(archiveFile().exists()).isTrue()
|
||||
assertThat(importer.isDone.first()).isTrue()
|
||||
|
||||
val second = importer.runIfNeeded()
|
||||
|
||||
assertThat(second).isEqualTo(ImportResult.AlreadyDone)
|
||||
assertThat(taskRows()).hasSize(8)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anInterruptedImportResumesFromTheArchiveWithoutDoubling() = runBlocking {
|
||||
// The process dying between the commit and the flag write is the one gap
|
||||
// the DataStore flag cannot cover on its own. Because the rename happens
|
||||
// first and the import always replaces, the next run finds the archive and
|
||||
// redoes the same work rather than importing a second copy.
|
||||
fixture(legacyFile())
|
||||
importer.runIfNeeded()
|
||||
importer.clearCompletion()
|
||||
|
||||
val resumed = importer.runIfNeeded()
|
||||
|
||||
assertThat(resumed).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
|
||||
assertThat(taskRows()).hasSize(8)
|
||||
assertThat(db.taskLists().lists()).hasSize(3)
|
||||
assertThat(db.alarms().all()).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runIfNeededMarksItselfDoneWhenThereIsNoLegacyDatabase() = runBlocking {
|
||||
assertThat(importer.runIfNeeded()).isEqualTo(ImportResult.NothingToImport)
|
||||
assertThat(importer.isDone.first()).isTrue()
|
||||
assertThat(taskRows()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reimportFromTheArchiveReplacesRatherThanMerges() = runBlocking {
|
||||
fixture(legacyFile())
|
||||
importer.runIfNeeded()
|
||||
|
||||
val again = importer.reimportFromArchive()
|
||||
|
||||
assertThat(again).isEqualTo(ImportResult.Imported(ImportCounts(3, 8, 2)))
|
||||
assertThat(db.taskLists().lists()).hasSize(3)
|
||||
assertThat(taskRows()).hasSize(8)
|
||||
assertThat(db.alarms().all()).hasSize(2)
|
||||
assertThat(archiveFile().exists()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replacingTwiceFromTheSameFileLeavesOneCopy() {
|
||||
importer.importFrom(fixture())
|
||||
val counts = importer.importFrom(fixture(temp.newFile("second.db")), replaceExisting = true)
|
||||
|
||||
assertThat(counts).isEqualTo(ImportCounts(3, 8, 2))
|
||||
assertThat(taskRows()).hasSize(8)
|
||||
assertThat(db.taskLists().lists()).hasSize(3)
|
||||
assertThat(db.alarms().all()).hasSize(2)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FIXTURE = "tasks-v23.db"
|
||||
const val T0 = 1_768_467_600_000L
|
||||
const val DAY = 86_400_000L
|
||||
var counter = 0
|
||||
}
|
||||
}
|
||||
+542
@@ -0,0 +1,542 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The seam over Room, exercised through [de.jeanlucmakiola.agendula.data.tasks
|
||||
* .TasksDataSource] rather than the DAOs — recurrence expansion and override
|
||||
* forking only exist at this level.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RoomTasksDataSourceTest {
|
||||
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: RoomTasksDataSource
|
||||
private var listId = 0L
|
||||
|
||||
/** Truncated to the store's granularity: instants are columns of epoch millis. */
|
||||
private val now get() = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds())
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
TasksDatabase::class.java,
|
||||
).allowMainThreadQueries().build()
|
||||
source = RoomTasksDataSource(db)
|
||||
listId = source.createLocalList("Personal", 0xFF112233.toInt())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun form(
|
||||
title: String = "task",
|
||||
due: Instant? = null,
|
||||
percentComplete: Int? = null,
|
||||
) = TaskForm(title = title, listId = listId, due = due, percentComplete = percentComplete)
|
||||
|
||||
/** A list that belongs to an account, so writes owe a server something. */
|
||||
private fun syncedList(): Long {
|
||||
val accountId = db.accounts().insert(
|
||||
AccountEntity(displayName = "me@example.com", username = "me"),
|
||||
)
|
||||
return db.taskLists().insert(
|
||||
TaskListEntity(name = "Work", color = 0, accountId = accountId, href = "https://s/w/"),
|
||||
)
|
||||
}
|
||||
|
||||
/** Turns [taskId] into a weekly series anchored at [anchor]. */
|
||||
private fun makeRecurring(taskId: Long, anchor: Instant, rule: String = "FREQ=WEEKLY") {
|
||||
val entity = db.tasks().entity(taskId)!!
|
||||
db.tasks().update(entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = rule))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aWriteNamesTheListsItTouched() {
|
||||
val touched = mutableListOf<Set<Long>>()
|
||||
val observed = RoomTasksDataSource(db) { touched += it }
|
||||
val work = syncedList()
|
||||
val id = observed.insertTask(form().copy(listId = work))
|
||||
|
||||
observed.updateTask(id, form().copy(listId = listId))
|
||||
|
||||
assertThat(touched).containsExactly(setOf(work), setOf(work, listId)).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingASyncedTaskLeavesATombstoneInTheOldList() {
|
||||
val work = syncedList()
|
||||
val id = source.insertTask(form().copy(listId = work))
|
||||
db.tasks().markSynced(listOf(id), "https://s/w/a.ics", "e1")
|
||||
|
||||
source.updateTask(id, form().copy(listId = listId))
|
||||
|
||||
val moved = db.tasks().entity(id)!!
|
||||
assertThat(moved.listId).isEqualTo(listId)
|
||||
assertThat(moved.href).isNull()
|
||||
val tombstone = db.tasks().allIn(work).single()
|
||||
assertThat(tombstone.isDeleted).isTrue()
|
||||
assertThat(tombstone.href).isEqualTo("https://s/w/a.ics")
|
||||
assertThat(tombstone.etag).isEqualTo("e1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createsAndReadsBackALocalList() {
|
||||
val lists = source.taskLists()
|
||||
|
||||
assertThat(lists).hasSize(1)
|
||||
assertThat(lists.single().name).isEqualTo("Personal")
|
||||
// No account, so the list still has to report something the lists screen
|
||||
// can group under.
|
||||
assertThat(lists.single().isLocal).isTrue()
|
||||
assertThat(lists.single().accountName).isEqualTo("Local")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renamesAndRecoloursAList() {
|
||||
source.updateList(listId, " Errands ", 0xFF445566.toInt())
|
||||
|
||||
val list = source.taskLists().single()
|
||||
assertThat(list.name).isEqualTo("Errands")
|
||||
assertThat(list.color).isEqualTo(0xFF445566.toInt())
|
||||
// Nothing to sync a device-only list to, so the edit leaves it clean.
|
||||
assertThat(db.taskLists().entity(listId)!!.isDirty).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingAListTakesItsTasksWithIt() {
|
||||
source.insertTask(form(title = "Buy milk"))
|
||||
source.insertTask(form(title = "Call the bank"))
|
||||
val other = source.createLocalList("Work", 0xFF778899.toInt())
|
||||
val keeper = source.insertTask(TaskForm(title = "Ship it", listId = other))
|
||||
|
||||
source.deleteList(listId)
|
||||
|
||||
assertThat(source.taskLists().map { it.id }).containsExactly(other)
|
||||
assertThat(source.tasks(TaskQuery(includeCompleted = true)).map { it.taskId })
|
||||
.containsExactly(keeper)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createsAndReadsBackANonRecurringTask() {
|
||||
val due = now + 1.days
|
||||
val id = source.insertTask(form(title = "Buy milk", due = due))
|
||||
|
||||
val task = source.task(id)!!
|
||||
|
||||
assertThat(task.taskId).isEqualTo(id)
|
||||
assertThat(task.title).isEqualTo("Buy milk")
|
||||
assertThat(task.due).isEqualTo(due)
|
||||
assertThat(task.isRecurring).isFalse()
|
||||
// A task that does not recur has no occurrence anchor, so it keys and edits
|
||||
// by task id exactly as it did against the provider.
|
||||
assertThat(task.occurrenceStart).isNull()
|
||||
assertThat(task.occurrenceKey).isEqualTo("$id")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mintsAUidForEveryTask() {
|
||||
val id = source.insertTask(form())
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.uid).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expandsARecurringSeriesIntoManyOccurrences() {
|
||||
val anchor = now
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, anchor)
|
||||
|
||||
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||
|
||||
// The provider materialised exactly one upcoming occurrence; we expand the
|
||||
// whole window, so a weekly series yields well over a hundred.
|
||||
assertThat(occurrences.size).isGreaterThan(100)
|
||||
assertThat(occurrences.map { it.occurrenceStart }).containsNoDuplicates()
|
||||
assertThat(occurrences.map { it.occurrenceKey }).containsNoDuplicates()
|
||||
assertThat(occurrences.all { it.isRecurring }).isTrue()
|
||||
// Each occurrence keeps the series' length rather than the master's dates.
|
||||
val first = occurrences.minBy { it.occurrenceStart!! }
|
||||
assertThat(first.due!! - first.start!!).isEqualTo(1.days)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exactlyOneOccurrenceIsTheCurrentOne() {
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, now - 30.days)
|
||||
|
||||
val occurrences = source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id }
|
||||
|
||||
assertThat(occurrences.count { it.distanceFromCurrent == 0 }).isEqualTo(1)
|
||||
assertThat(source.task(id)!!.distanceFromCurrent).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editingOneOccurrenceForksARecurrenceIdOverride() {
|
||||
val anchor = now
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, anchor)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
|
||||
|
||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||
// RFC 5545's model: the override shares its master's UID — that is what
|
||||
// makes it an override rather than a separate task. The dmfs provider
|
||||
// detached the occurrence into a new task with its own UID instead.
|
||||
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
|
||||
assertThat(override.masterId).isEqualTo(id)
|
||||
assertThat(override.recurrenceId).isEqualTo(target.occurrenceStart)
|
||||
assertThat(override.rrule).isNull()
|
||||
assertThat(override.title).isEqualTo("Water them twice")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
val open = { source.tasks(TaskQuery(listId = listId)).filter { it.taskId == id } }
|
||||
val before = open()
|
||||
val target = before.first { it.distanceFromCurrent == 0 }
|
||||
|
||||
source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
|
||||
|
||||
// Writing the status onto the master would close the series: the master is
|
||||
// the row the task query filters on, so every occurrence would vanish.
|
||||
val after = open()
|
||||
assertThat(after).hasSize(before.size - 1)
|
||||
assertThat(after.map { it.occurrenceStart }).doesNotContain(target.occurrenceStart)
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
|
||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||
assertThat(override.uid).isEqualTo(db.tasks().entity(id)!!.uid)
|
||||
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(override.rrule).isNull()
|
||||
// The override stands for *that* occurrence, so it carries the
|
||||
// occurrence's resolved times, not the master's anchor.
|
||||
assertThat(override.dtstart).isEqualTo(target.occurrenceStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reopeningACompletedOccurrenceReusesItsOverride() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.first { it.taskId == id && it.distanceFromCurrent == 0 }
|
||||
|
||||
source.setCompletedInstance(id, target.occurrenceStart!!, completed = true)
|
||||
source.setCompletedInstance(id, target.occurrenceStart, completed = false)
|
||||
|
||||
assertThat(db.tasks().overrides(id)).hasSize(1)
|
||||
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status)
|
||||
.isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart })
|
||||
.contains(target.occurrenceStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancellingOneOccurrenceLeavesTheRestOfTheSeriesOpen() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.first { it.taskId == id && it.distanceFromCurrent == 0 }
|
||||
|
||||
source.setCancelledInstance(id, target.occurrenceStart!!, cancelled = true)
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status).isEqualTo(TaskStatus.CANCELLED)
|
||||
|
||||
source.setCancelledInstance(id, target.occurrenceStart, cancelled = false)
|
||||
|
||||
assertThat(db.tasks().overrides(id)).hasSize(1)
|
||||
assertThat(db.tasks().override(id, target.occurrenceStart)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completingANonRecurringTaskThroughTheInstancePathWritesTheRowItself() {
|
||||
val id = source.insertTask(form(title = "Buy milk", due = now + 1.days))
|
||||
|
||||
source.setCompletedInstance(id, now, completed = true)
|
||||
|
||||
assertThat(db.tasks().overrides(id)).isEmpty()
|
||||
assertThat(db.tasks().entity(id)!!.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOverrideReplacesOnlyItsOwnOccurrence() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
// The list holds this series alone, so no filter is needed — and none can
|
||||
// be written on taskId, since the override reports its own row id.
|
||||
val before = source.tasks(TaskQuery(listId = listId))
|
||||
val target = before.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
|
||||
|
||||
val after = source.tasks(TaskQuery(listId = listId))
|
||||
assertThat(after).hasSize(before.size)
|
||||
val edited = after.single { it.title == "Water them twice" }
|
||||
assertThat(edited.occurrenceStart).isEqualTo(target.occurrenceStart)
|
||||
assertThat(after.filter { it.occurrenceStart == target.occurrenceStart }).hasSize(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* An edited occurrence addresses its own row, not the master's. That is what
|
||||
* sends the *next* edit down `updateTask` rather than forking a second time:
|
||||
* an override carries no rule, so it reads back as non-recurring.
|
||||
*/
|
||||
@Test
|
||||
fun anEditedOccurrenceReportsTheOverridesOwnId() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
val target = source.tasks(TaskQuery(listId = listId)).first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "Water them twice"))
|
||||
|
||||
val edited = source.tasks(TaskQuery(listId = listId)).single { it.title == "Water them twice" }
|
||||
val overrideId = db.tasks().override(id, target.occurrenceStart)!!.id
|
||||
assertThat(edited.taskId).isEqualTo(overrideId)
|
||||
assertThat(edited.taskId).isNotEqualTo(id)
|
||||
assertThat(source.task(overrideId)!!.isRecurring).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun editingASeriesDoesNotReAnchorItWhenOneOccurrenceIsEdited() {
|
||||
val anchor = now
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, anchor)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 2 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(due = now + 99.days))
|
||||
|
||||
assertThat(db.tasks().entity(id)!!.dtstart).isEqualTo(anchor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updatingANonRecurringTaskWritesThroughToItsRow() {
|
||||
val id = source.insertTask(form(title = "old"))
|
||||
|
||||
source.updateTask(id, form(title = "new"))
|
||||
|
||||
assertThat(source.task(id)!!.title).isEqualTo("new")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completionTogglesTheWholeTriple() {
|
||||
val id = source.insertTask(form())
|
||||
|
||||
source.setCompleted(id, completed = true)
|
||||
val done = db.tasks().entity(id)!!
|
||||
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
|
||||
assertThat(done.percentComplete).isEqualTo(100)
|
||||
assertThat(done.completedAt).isNotNull()
|
||||
|
||||
source.setCompleted(id, completed = false)
|
||||
assertThat(db.tasks().entity(id)!!.completedAt).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun completedTasksAreExcludedUnlessAskedFor() {
|
||||
val id = source.insertTask(form())
|
||||
source.setCompleted(id, completed = true)
|
||||
|
||||
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = false))).isEmpty()
|
||||
assertThat(source.tasks(TaskQuery(listId = listId, includeCompleted = true))).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
|
||||
// The whole reminder, not just the minute count: collapsing it to a bare
|
||||
// Int is what fired an imported START-referenced alarm off DUE, and an
|
||||
// alarm this seam sets from the UI is always due-referenced.
|
||||
source.setAlarm(id, 30)
|
||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 30))
|
||||
|
||||
source.setAlarm(id, 60)
|
||||
assertThat(db.alarms().forTask(id)).hasSize(1)
|
||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(minutesBefore = 60))
|
||||
|
||||
source.setAlarm(id, null)
|
||||
assertThat(source.alarms()).doesNotContainKey(id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingTheEditableReminderLeavesTheOthersAlone() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
val start = TaskReminder(minutesBefore = 10, fromStart = true)
|
||||
source.setReminders(id, listOf(start, TaskReminder(30), TaskReminder(120)))
|
||||
|
||||
source.setAlarm(id, 45)
|
||||
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30), TaskReminder(45)).inOrder()
|
||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(45))
|
||||
|
||||
source.setAlarm(id, null)
|
||||
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder()
|
||||
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(30))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
|
||||
val id = source.insertTask(form(due = now + 1.days))
|
||||
makeRecurring(id, now)
|
||||
source.setAlarm(id, 30)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form())
|
||||
|
||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingATaskInALocalListRemovesItOutright() {
|
||||
val id = source.insertTask(form())
|
||||
|
||||
source.deleteTask(id)
|
||||
|
||||
// No account knows about it, so there is nothing to tombstone for.
|
||||
assertThat(db.tasks().entity(id)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingASeriesTakesItsOverridesWithIt() {
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, now)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
||||
|
||||
source.deleteTask(id)
|
||||
|
||||
assertThat(db.tasks().allOverrides(listId)).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aForkedOccurrenceCarriesTheSeriesReminder() {
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, now)
|
||||
source.setAlarm(id, minutesBeforeDue = 30)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
||||
|
||||
// The fork copies the master's properties, which is what carries the
|
||||
// reminder across — the repository is what puts the series' own back.
|
||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||
assertThat(db.alarms().forTask(override.id).single().minutesBefore).isEqualTo(30)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anOccurrenceCannotBeMovedOutOfItsSeriesList() {
|
||||
val other = source.createLocalList("Work", 0)
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, now)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||
|
||||
source.updateTask(override.id, TaskForm(title = "moved", listId = other))
|
||||
|
||||
// ⚠️ list_id = B with master_id in list A is invisible in both — the task
|
||||
// query skips non-null master_id, and the override query finds no master
|
||||
// in B — while still uploading as part of A's resource.
|
||||
assertThat(db.tasks().entity(override.id)!!.listId).isEqualTo(listId)
|
||||
assertThat(db.tasks().entity(override.id)!!.title).isEqualTo("moved")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingASyncedSeriesTombstonesItsOverridesToo() {
|
||||
val syncedList = syncedList()
|
||||
val id = source.insertTask(TaskForm(title = "Standup", listId = syncedList))
|
||||
makeRecurring(id, now)
|
||||
val target = source.tasks(TaskQuery(listId = syncedList))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
source.updateInstance(id, target.occurrenceStart!!, TaskForm(title = "moved", listId = syncedList))
|
||||
|
||||
source.deleteTask(id)
|
||||
|
||||
// ⚠️ master_id cascades on delete, and a tombstone deletes nothing — so
|
||||
// a master marked alone left the resource reading as partly deleted, the
|
||||
// DELETE was never sent, and the task stayed on the server for ever.
|
||||
val rows = db.tasks().allIn(syncedList)
|
||||
assertThat(rows).hasSize(2)
|
||||
assertThat(rows.all { it.isDeleted && it.isDirty }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingOneOccurrenceExceptsItOnTheMaster() {
|
||||
val id = source.insertTask(form())
|
||||
makeRecurring(id, now)
|
||||
val target = source.tasks(TaskQuery(listId = listId))
|
||||
.filter { it.taskId == id }
|
||||
.first { it.distanceFromCurrent == 1 }
|
||||
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved"))
|
||||
val override = db.tasks().override(id, target.occurrenceStart)!!
|
||||
|
||||
source.deleteTask(override.id)
|
||||
|
||||
// ⚠️ Dropping the override row un-overrides the occurrence, and the
|
||||
// master's RRULE regenerates it. The EXDATE is the deletion.
|
||||
assertThat(db.tasks().entity(override.id)).isNull()
|
||||
assertThat(db.tasks().entity(id)!!.exdate).isNotEmpty()
|
||||
assertThat(source.tasks(TaskQuery(listId = listId)).map { it.occurrenceStart })
|
||||
.doesNotContain(target.occurrenceStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subtasksReadBackUnderTheirParent() {
|
||||
val parent = source.insertTask(form(title = "Prepare invoice"))
|
||||
val child = source.insertTask(form(title = "Gather receipts").copy(parentId = parent))
|
||||
|
||||
assertThat(source.subtasks(parent).map { it.taskId }).containsExactly(child)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exportReadsMastersNotOccurrences() {
|
||||
val id = source.insertTask(form(title = "Water the plants"))
|
||||
makeRecurring(id, now)
|
||||
|
||||
val exported = source.exportTasks(listId)
|
||||
|
||||
// One row carrying the rule, not one row per occurrence with the rule lost.
|
||||
assertThat(exported).hasSize(1)
|
||||
assertThat(exported.single().rrule).isEqualTo("FREQ=WEEKLY")
|
||||
assertThat(exported.single().uid).isNotEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertingIntoAMissingListFails() {
|
||||
val thrown = runCatching { source.insertTask(form().copy(listId = 9_999)) }.exceptionOrNull()
|
||||
|
||||
assertThat(thrown).isNotNull()
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.testing.MigrationTestHelper
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* The migration harness, proven against the committed schema in `app/schemas/`.
|
||||
*
|
||||
* There is one schema version today, so all there is to assert is that the helper
|
||||
* can build v1 from the exported JSON, seed it, and validate it back — i.e. the
|
||||
* export, the assets wiring and the identity hash all line up. That is the point:
|
||||
* the first real migration only has to add its own case.
|
||||
*
|
||||
* **Adding a v1 → v2 case.** When sync adds columns, bump [TasksDatabase]'s
|
||||
* `version`, let KSP export `2.json`, declare the `Migration(1, 2)` next to the
|
||||
* database, and add a test here shaped like this:
|
||||
*
|
||||
* ```
|
||||
* helper.createDatabase(TEST_DB, 1).use { db ->
|
||||
* db.execSQL("INSERT INTO task_lists (name, color) VALUES ('Groceries', 0)")
|
||||
* }
|
||||
* helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2).use { db ->
|
||||
* // read the seeded rows back — validation proves the shape, not the data
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabaseMigrationTest {
|
||||
|
||||
@get:Rule
|
||||
val helper = MigrationTestHelper(
|
||||
InstrumentationRegistry.getInstrumentation(),
|
||||
TasksDatabase::class.java,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun buildsV1FromTheExportedSchema() {
|
||||
helper.createDatabase(TEST_DB, 1).use { db ->
|
||||
db.execSQL("INSERT INTO task_lists (id, name, color) VALUES (1, 'Groceries', 0)")
|
||||
db.execSQL("INSERT INTO tasks (id, list_id, uid, title) VALUES (1, 1, 'uid-1', 'Buy milk')")
|
||||
|
||||
db.query("SELECT title FROM tasks").use { cursor ->
|
||||
assertThat(cursor.moveToFirst()).isTrue()
|
||||
assertThat(cursor.getString(0)).isEqualTo("Buy milk")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validatesV1AgainstTheExportedSchema() {
|
||||
helper.createDatabase(TEST_DB, 1).close()
|
||||
|
||||
// No migrations to run: v1 is opened and checked against 1.json, which is
|
||||
// what proves the harness rather than the schema.
|
||||
helper.runMigrationsAndValidate(TEST_DB, 1, true).use { db ->
|
||||
assertThat(db.version).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TEST_DB = "migration-test.db"
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import kotlin.time.measureTime
|
||||
import kotlin.time.measureTimedValue
|
||||
|
||||
/**
|
||||
* The plan's shape at scale: 5,000 tasks with 20 recurring series, read the way a
|
||||
* smart list reads them — one `tasks(TaskQuery(includeCompleted = true))`, which
|
||||
* includes expanding every series in memory.
|
||||
*
|
||||
* The assertion is a deliberately loose ceiling, so it catches a real regression
|
||||
* rather than CI jitter; the printed numbers are what the check is actually for.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabasePerformanceTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: RoomTasksDataSource
|
||||
private var listId = 0L
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
delete()
|
||||
db = Room.databaseBuilder(context, TasksDatabase::class.java, DB)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
source = RoomTasksDataSource(db)
|
||||
listId = source.createLocalList("Everything", 0xFF112233.toInt())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsFiveThousandTasksWithTwentySeriesInsideTheBudget() {
|
||||
val seeded = measureTime { seed() }
|
||||
|
||||
// Discard the first read: it pays for statement compilation and page cache
|
||||
// warming, which a running app has already paid.
|
||||
source.tasks(TaskQuery(includeCompleted = true))
|
||||
val (tasks, elapsed) = measureTimedValue {
|
||||
source.tasks(TaskQuery(includeCompleted = true))
|
||||
}
|
||||
|
||||
println(
|
||||
"[perf] $TASK_COUNT tasks / $SERIES_COUNT series -> ${tasks.size} occurrences " +
|
||||
"in $elapsed (seed $seeded)",
|
||||
)
|
||||
// Expansion is bounded twice over: the read window is 1 year back and 2
|
||||
// forward, and each series stops at ExpansionWindow.maxOccurrences (500),
|
||||
// so the occurrence count cannot grow with the age of the series.
|
||||
assertThat(tasks.size).isAtLeast(TASK_COUNT)
|
||||
assertThat(elapsed.inWholeMilliseconds).isLessThan(CEILING_MILLIS)
|
||||
}
|
||||
|
||||
private fun seed() {
|
||||
val anchor = Clock.System.now() - 30.days
|
||||
val ids = ArrayList<Long>(TASK_COUNT)
|
||||
db.runInTransaction {
|
||||
repeat(TASK_COUNT) { index ->
|
||||
ids += source.insertTask(
|
||||
TaskForm(title = "Task $index", listId = listId, due = anchor + index.days),
|
||||
)
|
||||
}
|
||||
}
|
||||
db.runInTransaction {
|
||||
ids.take(SERIES_COUNT).forEach { id ->
|
||||
val entity = db.tasks().entity(id)!!
|
||||
db.tasks().update(
|
||||
entity.copy(dtstart = anchor, due = anchor + 1.days, rrule = "FREQ=DAILY"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete() {
|
||||
val base = context.getDatabasePath(DB)
|
||||
base.delete()
|
||||
listOf("-wal", "-shm").forEach { File(base.path + it).delete() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DB = "performance-test.db"
|
||||
const val TASK_COUNT = 5_000
|
||||
const val SERIES_COUNT = 20
|
||||
const val CEILING_MILLIS = 8_000L
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The Auto Backup restore path, on disk.
|
||||
*
|
||||
* Auto Backup copies database files without checkpointing, and Room runs in WAL
|
||||
* mode — so `.db` alone can be a *stale* copy of a database whose recent writes
|
||||
* are still in the `-wal` sidecar. `res/xml/backup_rules.xml` carries all three
|
||||
* files and [DatabaseCheckpoint] truncates the log on `ON_STOP`; this asserts
|
||||
* that both of those actually do what they claim, and that neither alone is an
|
||||
* assumption.
|
||||
*
|
||||
* A file copy of a live database stands in for the backup transport — the
|
||||
* transport is what Auto Backup does to these files, and it is not what is under
|
||||
* test here.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabaseRestoreTest {
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: RoomTasksDataSource
|
||||
private var listId = 0L
|
||||
private var restored: TasksDatabase? = null
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
delete(LIVE)
|
||||
delete(BACKUP)
|
||||
db = open(LIVE)
|
||||
source = RoomTasksDataSource(db)
|
||||
listId = source.createLocalList("Personal", 0xFF112233.toInt())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
restored?.close()
|
||||
db.close()
|
||||
delete(LIVE)
|
||||
delete(BACKUP)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roomRunsInWalMode() {
|
||||
// Everything below is only interesting because of this.
|
||||
assertThat(journalMode()).isEqualTo("wal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBackupOfTheDbFileAloneLosesWhateverIsStillInTheWal() {
|
||||
write("checkpointed")
|
||||
checkpoint()
|
||||
write("only in the wal")
|
||||
|
||||
backUp(withSidecars = false)
|
||||
|
||||
assertThat(restore()).containsExactly("checkpointed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBackupThatCarriesTheSidecarsKeepsTheLastWrite() {
|
||||
write("checkpointed")
|
||||
checkpoint()
|
||||
write("only in the wal")
|
||||
|
||||
backUp(withSidecars = true)
|
||||
|
||||
assertThat(restore()).containsExactly("checkpointed", "only in the wal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkpointingFirstMakesTheDbFileAloneEnough() {
|
||||
write("checkpointed")
|
||||
checkpoint()
|
||||
write("last write")
|
||||
|
||||
// What DatabaseCheckpoint runs on ON_STOP — the fallback for a restore
|
||||
// that arrives without the sidecars.
|
||||
checkpoint()
|
||||
backUp(withSidecars = false)
|
||||
|
||||
assertThat(restore()).containsExactly("checkpointed", "last write")
|
||||
}
|
||||
|
||||
// --- the moving parts -----------------------------------------------------
|
||||
|
||||
private fun open(name: String): TasksDatabase =
|
||||
Room.databaseBuilder(context, TasksDatabase::class.java, name)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
|
||||
private fun write(title: String) {
|
||||
source.insertTask(TaskForm(title = title, listId = listId))
|
||||
}
|
||||
|
||||
private fun journalMode(): String =
|
||||
db.openHelper.writableDatabase.query("PRAGMA journal_mode").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
cursor.getString(0).lowercase()
|
||||
}
|
||||
|
||||
/** [DatabaseCheckpoint]'s pragma, asserting it was not blocked by a reader. */
|
||||
private fun checkpoint() {
|
||||
db.openHelper.writableDatabase.query("PRAGMA wal_checkpoint(TRUNCATE)").use { cursor ->
|
||||
cursor.moveToFirst()
|
||||
assertThat(cursor.getInt(0)).isEqualTo(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the live database the way Auto Backup would: no checkpoint, files as they lie. */
|
||||
private fun backUp(withSidecars: Boolean) {
|
||||
delete(BACKUP)
|
||||
val live = context.getDatabasePath(LIVE)
|
||||
val backup = context.getDatabasePath(BACKUP)
|
||||
live.copyTo(backup, overwrite = true)
|
||||
if (!withSidecars) return
|
||||
SIDECARS.forEach { suffix ->
|
||||
val from = File(live.path + suffix)
|
||||
if (from.exists()) from.copyTo(File(backup.path + suffix), overwrite = true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the copy as a fresh install would and reports the task titles that survived. */
|
||||
private fun restore(): List<String> {
|
||||
restored?.close()
|
||||
val database = open(BACKUP).also { restored = it }
|
||||
return RoomTasksDataSource(database).tasks(TaskQuery(includeCompleted = true)).map { it.title }
|
||||
}
|
||||
|
||||
private fun delete(name: String) {
|
||||
val base = context.getDatabasePath(name)
|
||||
base.delete()
|
||||
SIDECARS.forEach { File(base.path + it).delete() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LIVE = "restore-live.db"
|
||||
const val BACKUP = "restore-backup.db"
|
||||
val SIDECARS = listOf("-wal", "-shm")
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.room
|
||||
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The schema, exercised through the DAOs. Instrumented rather than JVM because
|
||||
* the app's unit tests are plain JUnit 5 with no Robolectric, and Room needs a
|
||||
* real SQLite.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TasksDatabaseTest {
|
||||
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var lists: TaskListDao
|
||||
private lateinit var tasks: TaskDao
|
||||
private lateinit var alarms: TaskAlarmDao
|
||||
private lateinit var accounts: AccountDao
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
db = Room.inMemoryDatabaseBuilder(
|
||||
ApplicationProvider.getApplicationContext(),
|
||||
TasksDatabase::class.java,
|
||||
).allowMainThreadQueries().build()
|
||||
lists = db.taskLists()
|
||||
tasks = db.tasks()
|
||||
alarms = db.alarms()
|
||||
accounts = db.accounts()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() = db.close()
|
||||
|
||||
private fun newList(name: String = "Groceries", accountId: Long? = null): Long =
|
||||
lists.insert(TaskListEntity(name = name, color = 0xFF00FF00.toInt(), accountId = accountId))
|
||||
|
||||
private fun newTask(
|
||||
listId: Long,
|
||||
uid: String = "uid-${counter++}",
|
||||
title: String? = "Buy milk",
|
||||
status: TaskStatus = TaskStatus.NEEDS_ACTION,
|
||||
parentId: Long? = null,
|
||||
masterId: Long? = null,
|
||||
recurrenceId: Instant? = null,
|
||||
): Long = tasks.insert(
|
||||
TaskEntity(
|
||||
listId = listId,
|
||||
uid = uid,
|
||||
title = title,
|
||||
status = status,
|
||||
parentId = parentId,
|
||||
masterId = masterId,
|
||||
recurrenceId = recurrenceId,
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun writesAndReadsAListWithItsTasks() {
|
||||
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
|
||||
val listId = newList(accountId = accountId)
|
||||
val due = Instant.fromEpochMilliseconds(1_700_000_000_000)
|
||||
val taskId = tasks.insert(
|
||||
TaskEntity(
|
||||
listId = listId,
|
||||
uid = "uid-1",
|
||||
title = "Buy milk",
|
||||
description = "2%",
|
||||
due = due,
|
||||
priority = 3,
|
||||
status = TaskStatus.IN_PROCESS,
|
||||
percentComplete = 40,
|
||||
),
|
||||
)
|
||||
|
||||
val list = lists.lists().single()
|
||||
assertThat(list.list.id).isEqualTo(listId)
|
||||
assertThat(list.list.name).isEqualTo("Groceries")
|
||||
assertThat(list.accountDisplayName).isEqualTo("Fastmail")
|
||||
|
||||
val row = tasks.task(taskId)!!
|
||||
assertThat(row.task.title).isEqualTo("Buy milk")
|
||||
assertThat(row.task.due).isEqualTo(due)
|
||||
// Stored raw: an off-bucket PRIORITY must come back as it went in.
|
||||
assertThat(row.task.priority).isEqualTo(3)
|
||||
assertThat(row.task.status).isEqualTo(TaskStatus.IN_PROCESS)
|
||||
assertThat(row.task.percentComplete).isEqualTo(40)
|
||||
assertThat(row.listName).isEqualTo("Groceries")
|
||||
assertThat(row.accountDisplayName).isEqualTo("Fastmail")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsTasksOfOneListAndHidesClosedOnesUnlessAsked() {
|
||||
val a = newList("A")
|
||||
val b = newList("B")
|
||||
newTask(a, title = "open")
|
||||
newTask(a, title = "done", status = TaskStatus.COMPLETED)
|
||||
newTask(a, title = "cancelled", status = TaskStatus.CANCELLED)
|
||||
newTask(b, title = "elsewhere")
|
||||
|
||||
assertThat(tasks.tasks(a, includeCompleted = false).map { it.task.title })
|
||||
.containsExactly("open")
|
||||
assertThat(tasks.tasks(a, includeCompleted = true)).hasSize(3)
|
||||
assertThat(tasks.tasks(null, includeCompleted = true)).hasSize(4)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsSubtasksByParent() {
|
||||
val listId = newList()
|
||||
val parent = newTask(listId, title = "parent")
|
||||
newTask(listId, title = "child", parentId = parent)
|
||||
|
||||
assertThat(tasks.subtasks(parent).map { it.task.title }).containsExactly("child")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hidesTombstonesFromReadsAndExports() {
|
||||
val listId = newList()
|
||||
val taskId = newTask(listId)
|
||||
tasks.markDeleted(taskId, Instant.fromEpochMilliseconds(1))
|
||||
|
||||
assertThat(tasks.tasks(listId, includeCompleted = true)).isEmpty()
|
||||
assertThat(tasks.task(taskId)).isNull()
|
||||
assertThat(tasks.exportTasks(listId)).isEmpty()
|
||||
assertThat(tasks.entity(taskId)).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun keepsOverridesOutOfTheMasterReads() {
|
||||
val listId = newList()
|
||||
val master = newTask(listId, uid = "series")
|
||||
val override = newTask(
|
||||
listId,
|
||||
uid = "series",
|
||||
masterId = master,
|
||||
recurrenceId = Instant.fromEpochMilliseconds(5_000),
|
||||
)
|
||||
|
||||
assertThat(tasks.tasks(listId, includeCompleted = true).map { it.task.id })
|
||||
.containsExactly(master)
|
||||
assertThat(tasks.overrides(master).map { it.id }).containsExactly(override)
|
||||
assertThat(tasks.allOverrides(listId).map { it.id }).containsExactly(override)
|
||||
assertThat(tasks.override(master, Instant.fromEpochMilliseconds(5_000))?.id)
|
||||
.isEqualTo(override)
|
||||
assertThat(tasks.exportTasks(listId).map { it.id }).containsExactly(master)
|
||||
}
|
||||
|
||||
// --- cascades -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun deletingAListDeletesItsTasks() {
|
||||
val listId = newList()
|
||||
val taskId = newTask(listId)
|
||||
|
||||
lists.delete(listId)
|
||||
|
||||
assertThat(tasks.entity(taskId)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingASeriesDeletesItsOverrides() {
|
||||
val listId = newList()
|
||||
val master = newTask(listId, uid = "series")
|
||||
val override = newTask(
|
||||
listId,
|
||||
uid = "series",
|
||||
masterId = master,
|
||||
recurrenceId = Instant.fromEpochMilliseconds(5_000),
|
||||
)
|
||||
|
||||
tasks.delete(master)
|
||||
|
||||
assertThat(tasks.entity(override)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingAParentPromotesItsSubtasks() {
|
||||
val listId = newList()
|
||||
val parent = newTask(listId, title = "parent")
|
||||
val child = newTask(listId, title = "child", parentId = parent)
|
||||
|
||||
tasks.delete(parent)
|
||||
|
||||
val promoted = tasks.entity(child)
|
||||
assertThat(promoted).isNotNull()
|
||||
assertThat(promoted!!.parentId).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingATaskDeletesItsAlarms() {
|
||||
val listId = newList()
|
||||
val taskId = newTask(listId)
|
||||
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
|
||||
assertThat(alarms.all()).hasSize(1)
|
||||
|
||||
tasks.delete(taskId)
|
||||
|
||||
assertThat(alarms.all()).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deletingAnAccountDetachesItsListsInsteadOfDeletingThem() {
|
||||
val accountId = accounts.insert(AccountEntity(displayName = "Fastmail"))
|
||||
val listId = newList(accountId = accountId)
|
||||
|
||||
accounts.delete(accountId)
|
||||
|
||||
assertThat(lists.entity(listId)!!.accountId).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun replacingAnAlarmLeavesOnlyTheNewOne() {
|
||||
val listId = newList()
|
||||
val taskId = newTask(listId)
|
||||
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 15))
|
||||
alarms.replaceForTask(taskId, TaskAlarmEntity(taskId = taskId, minutesBefore = 30))
|
||||
|
||||
assertThat(alarms.forTask(taskId).map { it.minutesBefore }).containsExactly(30)
|
||||
assertThat(alarms.forTask(taskId).single().reference).isEqualTo(AlarmReference.DUE)
|
||||
|
||||
alarms.replaceForTask(taskId, null)
|
||||
assertThat(alarms.forTask(taskId)).isEmpty()
|
||||
}
|
||||
|
||||
// --- the unique index -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun anOverrideMayShareItsMastersUid() {
|
||||
val listId = newList()
|
||||
val master = newTask(listId, uid = "series")
|
||||
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(1))
|
||||
newTask(listId, uid = "series", masterId = master, recurrenceId = Instant.fromEpochMilliseconds(2))
|
||||
|
||||
assertThat(tasks.overrides(master)).hasSize(2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsTwoOverridesOfTheSameOccurrence() {
|
||||
val listId = newList()
|
||||
val master = newTask(listId, uid = "series")
|
||||
val at = Instant.fromEpochMilliseconds(1)
|
||||
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
|
||||
|
||||
val failure = runCatching {
|
||||
newTask(listId, uid = "series", masterId = master, recurrenceId = at)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertThat(failure).isNotNull()
|
||||
assertThat(failure!!.message).contains("UNIQUE")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theSameUidMayExistInAnotherList() {
|
||||
val a = newList("A")
|
||||
val b = newList("B")
|
||||
newTask(a, uid = "shared")
|
||||
newTask(b, uid = "shared")
|
||||
|
||||
assertThat(tasks.byUid(a, "shared")).isNotNull()
|
||||
assertThat(tasks.byUid(b, "shared")).isNotNull()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
var counter = 0
|
||||
}
|
||||
}
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
package de.jeanlucmakiola.agendula.data.tasks.transfer
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderEnvironment
|
||||
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskQuery
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
|
||||
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
|
||||
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
|
||||
import de.jeanlucmakiola.agendula.domain.Priority
|
||||
import de.jeanlucmakiola.agendula.domain.Task
|
||||
import de.jeanlucmakiola.agendula.domain.TaskForm
|
||||
import de.jeanlucmakiola.agendula.domain.TaskList
|
||||
import de.jeanlucmakiola.agendula.domain.TaskStatus
|
||||
import de.jeanlucmakiola.agendula.domain.export.ExportTask
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import javax.inject.Provider
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* The copy out of an external provider and into Room — the upgrade path every
|
||||
* released install actually needs, since no release ever bundled the provider
|
||||
* `OneShotImport` reads.
|
||||
*
|
||||
* The source is a fake [TasksDataSource] rather than a live OpenTasks: what is
|
||||
* worth testing is the write half — id remapping, uid collisions, verified
|
||||
* counts, the once-only guard — and pinning that to a device with a third-party
|
||||
* app installed would mean it never ran. Instrumented all the same, because the
|
||||
* destination is a real Room database in a real transaction.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExternalImportTest {
|
||||
|
||||
@get:Rule
|
||||
val temp = TemporaryFolder()
|
||||
|
||||
private val context: Context = ApplicationProvider.getApplicationContext()
|
||||
private lateinit var scope: CoroutineScope
|
||||
private lateinit var prefs: DataStore<Preferences>
|
||||
private lateinit var db: TasksDatabase
|
||||
private lateinit var source: FakeExternalStore
|
||||
private lateinit var importer: ExternalImport
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
prefs = PreferenceDataStoreFactory.create(scope = scope) {
|
||||
temp.newFile("transfer-${counter++}.preferences_pb").also(File::delete)
|
||||
}
|
||||
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
source = FakeExternalStore()
|
||||
importer = ExternalImport(
|
||||
external = Provider { source },
|
||||
resolver = ProviderResolver(NoProviderInstalled),
|
||||
database = db,
|
||||
dataStore = prefs,
|
||||
io = Dispatchers.IO,
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
db.close()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun copiesListsTasksAndAlarms() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(task(100, "Milk"), task(101, "Bread")),
|
||||
9L to listOf(task(200, "Invoice")),
|
||||
)
|
||||
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
|
||||
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isEqualTo(
|
||||
TransferResult.Copied(TransferCounts(lists = 2, tasks = 3, alarms = 1)),
|
||||
)
|
||||
assertThat(db.taskLists().lists().map { it.list.name })
|
||||
.containsExactly("Errands", "Work")
|
||||
assertThat(db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.title })
|
||||
.containsExactly("Milk", "Bread", "Invoice")
|
||||
assertThat(importer.hasRun.first()).isTrue()
|
||||
}
|
||||
|
||||
/** Every list arrives device-only: the account belongs to the sync app. */
|
||||
@Test
|
||||
fun importedListsAreDeviceOnly() = runBlocking {
|
||||
source.lists = listOf(list(7, "Shared", accountName = "me@example.org"))
|
||||
source.tasks = mapOf(7L to listOf(task(100, "Milk")))
|
||||
|
||||
importer.run()
|
||||
|
||||
assertThat(db.taskLists().lists().single().list.accountId).isNull()
|
||||
}
|
||||
|
||||
/** Provider row ids are the source's; Room mints its own and the link follows. */
|
||||
@Test
|
||||
fun remapsParentIdsOntoTheNewRowIds() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
// Child before parent, so a naive single pass would not find the parent.
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(task(100, "Subtask", parentId = 200), task(200, "Parent")),
|
||||
)
|
||||
|
||||
importer.run()
|
||||
|
||||
val rows = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task }
|
||||
val parent = rows.single { it.title == "Parent" }
|
||||
val child = rows.single { it.title == "Subtask" }
|
||||
assertThat(child.parentId).isEqualTo(parent.id)
|
||||
assertThat(child.parentId).isNotEqualTo(200L)
|
||||
}
|
||||
|
||||
/**
|
||||
* A `RECURRENCE-ID` override reaches the read seam as another master-shaped row
|
||||
* sharing its series' uid. The unique index on (list, uid, recurrence_id)
|
||||
* would reject it and take the whole copy down, so it gets a fresh uid.
|
||||
*/
|
||||
@Test
|
||||
fun aDuplicateUidDoesNotAbortTheCopy() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(
|
||||
task(100, "Weekly", uid = "shared-uid"),
|
||||
task(101, "Weekly, that one week", uid = "shared-uid"),
|
||||
),
|
||||
)
|
||||
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isInstanceOf(TransferResult.Copied::class.java)
|
||||
val uids = db.tasks().tasks(listId = null, includeCompleted = true).map { it.task.uid }
|
||||
assertThat(uids).hasSize(2)
|
||||
assertThat(uids.toSet()).hasSize(2)
|
||||
assertThat(uids).contains("shared-uid")
|
||||
}
|
||||
|
||||
/** A START-referenced reminder must not come across as a before-due one. */
|
||||
@Test
|
||||
fun preservesTheAlarmReference() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
source.tasks = mapOf(7L to listOf(task(100, "Standup")))
|
||||
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 10, fromStart = true))
|
||||
|
||||
importer.run()
|
||||
|
||||
val alarm = db.alarms().all().single()
|
||||
assertThat(alarm.reference).isEqualTo(AlarmReference.START)
|
||||
assertThat(alarm.minutesBefore).isEqualTo(10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEmptySourceWritesNothingAndIsNotMarkedDone() = runBlocking {
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isEqualTo(TransferResult.NothingToCopy)
|
||||
assertThat(db.taskLists().lists()).isEmpty()
|
||||
// Still on offer: there was nothing to copy, not a copy that happened.
|
||||
assertThat(importer.hasRun.first()).isFalse()
|
||||
}
|
||||
|
||||
/** A read that blows up must leave Room exactly as it was. */
|
||||
@Test
|
||||
fun aFailedReadRollsBackAndLeavesTheGuardOpen() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"))
|
||||
source.failOnExport = true
|
||||
|
||||
val result = importer.run()
|
||||
|
||||
assertThat(result).isInstanceOf(TransferResult.Failed::class.java)
|
||||
assertThat(db.taskLists().lists()).isEmpty()
|
||||
assertThat(importer.hasRun.first()).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previewCountsWhatARunWouldWrite() = runBlocking {
|
||||
source.lists = listOf(list(7, "Errands"), list(9, "Work"))
|
||||
source.tasks = mapOf(
|
||||
7L to listOf(task(100, "Milk"), task(101, "Bread")),
|
||||
9L to listOf(task(200, "Invoice")),
|
||||
)
|
||||
source.alarms = mapOf(100L to TaskReminder(minutesBefore = 30))
|
||||
// preview() resolves the provider itself, so it needs one to be installed.
|
||||
val withProvider = ExternalImport(
|
||||
external = Provider { source },
|
||||
resolver = ProviderResolver(OpenTasksInstalledAndGranted),
|
||||
database = db,
|
||||
dataStore = prefs,
|
||||
io = Dispatchers.IO,
|
||||
)
|
||||
|
||||
assertThat(withProvider.preview())
|
||||
.isEqualTo(TransferCounts(lists = 2, tasks = 3, alarms = 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun previewIsNullWithoutAReadableProvider() = runBlocking {
|
||||
assertThat(importer.preview()).isNull()
|
||||
}
|
||||
|
||||
// --- fixtures --------------------------------------------------------------
|
||||
|
||||
private fun list(id: Long, name: String, accountName: String = "Device") = TaskList(
|
||||
id = id,
|
||||
name = name,
|
||||
color = 0xFF7E57C2.toInt(),
|
||||
accountName = accountName,
|
||||
accountType = "org.dmfs.account.LOCAL",
|
||||
isSynced = true,
|
||||
isVisible = true,
|
||||
owner = null,
|
||||
)
|
||||
|
||||
private fun task(
|
||||
id: Long,
|
||||
title: String,
|
||||
uid: String? = "uid-$id",
|
||||
parentId: Long? = null,
|
||||
) = ExportTask(
|
||||
taskId = id,
|
||||
uid = uid,
|
||||
title = title,
|
||||
description = null,
|
||||
location = null,
|
||||
url = null,
|
||||
priority = Priority.NONE,
|
||||
status = TaskStatus.NEEDS_ACTION,
|
||||
percentComplete = null,
|
||||
start = null,
|
||||
due = Instant.fromEpochMilliseconds(1_800_000_000_000),
|
||||
isAllDay = false,
|
||||
completedAt = null,
|
||||
created = null,
|
||||
lastModified = null,
|
||||
rrule = null,
|
||||
rdate = null,
|
||||
parentId = parentId,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
var counter = 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Only the three reads the copy makes; everything else is out of scope. */
|
||||
private class FakeExternalStore : TasksDataSource {
|
||||
var lists: List<TaskList> = emptyList()
|
||||
var tasks: Map<Long, List<ExportTask>> = emptyMap()
|
||||
var alarms: Map<Long, TaskReminder> = emptyMap()
|
||||
var failOnExport = false
|
||||
|
||||
override fun taskLists(): List<TaskList> = lists
|
||||
|
||||
override fun exportTasks(listId: Long): List<ExportTask> {
|
||||
if (failOnExport) error("provider went away mid-read")
|
||||
return tasks[listId].orEmpty()
|
||||
}
|
||||
|
||||
override fun alarms(): Map<Long, TaskReminder> = alarms
|
||||
|
||||
override fun tasks(query: TaskQuery): List<Task> = unused()
|
||||
override fun task(taskId: Long): Task? = unused()
|
||||
override fun subtasks(parentTaskId: Long): List<Task> = unused()
|
||||
override fun insertTask(form: TaskForm): Long = unused()
|
||||
override fun updateTask(taskId: Long, form: TaskForm) = unused()
|
||||
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
|
||||
override fun setAlarm(taskId: Long, minutesBeforeDue: Int?) = unused()
|
||||
override fun setReminders(taskId: Long, reminders: List<TaskReminder>) = unused()
|
||||
override fun setCompleted(taskId: Long, completed: Boolean) = unused()
|
||||
override fun setCompletedInstance(taskId: Long, occurrenceStart: Instant, completed: Boolean) = unused()
|
||||
override fun deleteTask(taskId: Long) = unused()
|
||||
override fun setCancelled(taskId: Long, cancelled: Boolean) = unused()
|
||||
override fun setCancelledInstance(taskId: Long, occurrenceStart: Instant, cancelled: Boolean) = unused()
|
||||
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm) = unused()
|
||||
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm): Long = unused()
|
||||
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
|
||||
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()
|
||||
override fun createLocalList(name: String, color: Int): Long = unused()
|
||||
override fun updateList(listId: Long, name: String, color: Int) = unused()
|
||||
override fun deleteList(listId: Long) = unused()
|
||||
override fun registerObserver(onChange: () -> Unit): AutoCloseable = unused()
|
||||
|
||||
private fun unused(): Nothing = error("the copy does not call this")
|
||||
}
|
||||
|
||||
/** No tasks provider on the device: `preview()` has nothing to read. */
|
||||
private object NoProviderInstalled : ProviderEnvironment {
|
||||
override fun packageDeclaring(authority: String): String? = null
|
||||
override fun isGranted(permission: String): Boolean = false
|
||||
override fun appLabel(packageName: String): String? = null
|
||||
}
|
||||
|
||||
private object OpenTasksInstalledAndGranted : ProviderEnvironment {
|
||||
override fun packageDeclaring(authority: String): String? =
|
||||
"org.dmfs.tasks".takeIf { authority == "org.dmfs.tasks" }
|
||||
|
||||
override fun isGranted(permission: String): Boolean = permission.startsWith("org.dmfs.permission.")
|
||||
override fun appLabel(packageName: String): String = "OpenTasks"
|
||||
}
|
||||
Reference in New Issue
Block a user