Release 1.2.0 (#103)
Release — F-Droid repo + Gitea/Codeberg release + Play / detect (push) Successful in 8s
Renovate / renovate (push) Successful in 1m11s
Release — F-Droid repo + Gitea/Codeberg release + Play / release (push) Successful in 18m4s
Release — F-Droid repo + Gitea/Codeberg release + Play / play (push) Failing after 1m33s
Beta — Codeberg pre-release / detect (push) Successful in 5s
Beta — Codeberg pre-release / beta (push) Skipped

Stable 1.2.0, after 1.2.0-beta.1 and beta.2.

- Version bumped to 1.2.0 (1020099); the CHANGELOG entries moved from Unreleased to 1.2.0.
- What's New for every shipped locale, now including cs-CZ.
- Full store listing (title, short and full description) for every locale; new for cs-CZ, ar and fr-FR. Play only gets these through the `listing` lane.
- `main` is merged in with the branch side kept. #81 and the Weblate updates already came in through the #88 squash, and the only lines unique to `main` were string keys that #88 removed.

The #75 fix landed after beta.2, so no reporter has tested it yet.

Closes #72, closes #78, closes #75, closes #92

Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de>
Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/103
This commit is contained in:
Jean-Luc Makiolaandmakiolaj committed 2026-10-26 20:41:58 +01:00
1 parent 1d6c2a0d59
commit 08c297f30a
110 files changed
+6491 -784

No files matched your search

@@ -0,0 +1,157 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.ContentProviderOperation
import android.content.ContentValues
import android.content.Context
import android.content.pm.PermissionInfo
import android.os.Build
import android.os.ParcelFileDescriptor
import androidx.core.net.toUri
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 org.junit.Assert.assertThrows
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/** The provider as another app meets it: manifest, permission, URIs. */
@RunWith(AndroidJUnit4::class)
class AgendulaTaskProviderTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private val authority = "${context.packageName}.tasks"
private val readPermission = "${context.packageName}.permission.READ_TASKS"
private val writePermission = "${context.packageName}.permission.WRITE_TASKS"
private val resolver = context.contentResolver
private fun uri(path: String) = "content://$authority/$path".toUri()
@Test
fun isExportedUnderItsPermissions() {
val info = requireNotNull(context.packageManager.resolveContentProvider(authority, 0))
assertThat(info.name).isEqualTo(AgendulaTaskProvider::class.java.name)
assertThat(info.exported).isTrue()
assertThat(info.readPermission).isEqualTo(readPermission)
assertThat(info.writePermission).isEqualTo(writePermission)
}
@Test
fun bothPermissionsAreDangerous() {
listOf(readPermission, writePermission).forEach { permission ->
val info = context.packageManager.getPermissionInfo(permission, 0)
assertThat(info.protection).isEqualTo(PermissionInfo.PROTECTION_DANGEROUS)
assertThat(info.loadLabel(context.packageManager).toString()).isNotEmpty()
assertThat(info.loadDescription(context.packageManager)?.toString()).isNotEmpty()
}
}
@Test
fun anAppWithoutThePermissionIsDenied() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
// The shell is another uid that was never granted it.
val output = shell("content query --uri content://$authority/tasks")
assertThat(output).contains("Permission Denial")
}
@Test
fun anAppWithoutTheWritePermissionCannotWrite() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
val output = shell("content insert --uri content://$authority/tasks --bind title:s:x --bind list_id:i:1")
assertThat(output).contains("Permission Denial")
assertThat(output).contains(writePermission)
}
@Test
fun reportsOpenTasksMimeTypes() {
ProviderTable.entries.forEach { table ->
assertThat(resolver.getType(uri(table.path))).isEqualTo(table.mimeType(single = false))
assertThat(resolver.getType(uri("${table.path}/3"))).isEqualTo(table.mimeType(single = true))
}
}
@Test
fun everyTableAnswersWithItsColumns() {
ProviderTable.entries.forEach { table ->
resolver.query(uri(table.path), null, null, null, null)!!.use {
assertThat(it.columnNames.toList()).containsExactlyElementsIn(table.columns.map { c -> c.first }).inOrder()
}
}
}
@Test
fun aCursorWatchesTheUriItWasQueriedFor() {
val tasks = uri("tasks")
resolver.query(tasks, null, null, null, null)!!.use { assertThat(it.notificationUri).isEqualTo(tasks) }
}
@Test
fun aRowUriReadsOnlyThatRow() {
// Ids start at 1, so 0 names nothing whatever the store holds.
resolver.query(uri("tasks/0"), null, null, null, null)!!.use { assertThat(it.count).isEqualTo(0) }
resolver.query(uri("tasks/0"), null, "1 = 1 OR 1 = 1", null, null)!!.use { assertThat(it.count).isEqualTo(0) }
}
@Test
fun anUnknownUriIsRejected() {
assertThrows(IllegalArgumentException::class.java) { resolver.query(uri("events"), null, null, null, null) }
// ContentResolver.getType swallows the provider's exception and answers null.
assertThat(runCatching { resolver.getType(uri("tasks/abc")) }.getOrNull()).isNull()
}
@Test
fun listsAreReadOnly() {
val values = ContentValues().apply { put("list_name", "x") }
assertThrows(UnsupportedOperationException::class.java) { resolver.insert(uri("tasklists"), values) }
assertThrows(UnsupportedOperationException::class.java) { resolver.update(uri("tasklists/1"), values, null, null) }
assertThrows(UnsupportedOperationException::class.java) { resolver.delete(uri("tasklists/1"), null, null) }
}
@Test
fun aRowUriTakesNoInsert() {
assertThrows(IllegalArgumentException::class.java) {
resolver.insert(uri("tasks/1"), ContentValues().apply { put("title", "x") })
}
}
@Test
fun aBadWriteReachesTheClientAsIllegalArgument() {
// No list has this id, so nothing in the app's real store is touched.
val values = ContentValues().apply {
put("list_id", Long.MAX_VALUE)
put("title", "x")
}
assertThrows(IllegalArgumentException::class.java) { resolver.insert(uri("tasks"), values) }
}
@Test
fun aFailingBatchWritesNothing() {
val missingList = Long.MAX_VALUE
val operations = arrayListOf(
ContentProviderOperation.newInsert(uri("tasks")).withValue("list_id", missingList).withValue("title", "a").build(),
)
assertThrows(IllegalArgumentException::class.java) { resolver.applyBatch(authority, operations) }
resolver.query(uri("tasks"), arrayOf("_id"), "list_id = ?", arrayOf(missingList.toString()), null)!!
.use { assertThat(it.count).isEqualTo(0) }
}
/** stdout and stderr of [command]; the denial lands on stderr. */
private fun shell(command: String): String {
val (stdout, stdin, stderr) = InstrumentationRegistry.getInstrumentation().uiAutomation
.executeShellCommandRwe(command)
stdin.close()
return listOf(stdout, stderr).joinToString("\n") { pfd ->
ParcelFileDescriptor.AutoCloseInputStream(pfd).bufferedReader().use { it.readText() }
}
}
}
@@ -0,0 +1,214 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.content.Context
import android.database.ContentObserver
import android.database.Cursor
import android.net.Uri
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.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import kotlin.time.Clock
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** The snapshot follows the Room store, the storage mode and the clock, and says when it moved. */
@RunWith(AndroidJUnit4::class)
class ProviderSnapshotSourceTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var db: TasksDatabase
private lateinit var resolver: ProviderResolver
private lateinit var source: ProviderSnapshotSource
private val clock = MutableClock(Instant.parse("2026-10-10T08:00:00Z"))
private val changes = LinkedBlockingQueue<Uri>()
private val observer = object : ContentObserver(null) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
changes += uri ?: Uri.EMPTY
}
}
private var listId = 0L
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
resolver = ProviderResolver(NoProviderInstalled)
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {}, clock = clock)
listId = db.taskLists().insert(TaskListEntity(name = "Home", color = 0xFF112233.toInt()))
context.contentResolver.registerContentObserver(TasksContract.authorityUri(source.authority), true, observer)
}
@After
fun tearDown() {
context.contentResolver.unregisterContentObserver(observer)
db.close()
}
private fun query(table: ProviderTable, selection: String? = null, args: Array<String>? = null): Cursor =
source.query(table, null, selection, args, null)
private fun titles(): List<String?> = query(ProviderTable.TASKS).use { cursor ->
val index = cursor.getColumnIndexOrThrow(Tasks.TITLE)
buildList { while (cursor.moveToNext()) add(cursor.getString(index)) }
}
private fun insert(title: String, due: Instant? = null, rrule: String? = null): Long =
db.tasks().insert(TaskEntity(listId = listId, uid = title, title = title, due = due, rrule = rrule, timezone = "UTC"))
/** Waits for the coalesced notification, then drains any stragglers. */
private fun awaitChange() {
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
Thread.sleep(700)
changes.clear()
}
private fun assertNoChange() {
assertThat(changes.poll(1500, TimeUnit.MILLISECONDS)).isNull()
}
@Test
fun servesTheOwnStore() {
val id = insert("Milk")
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 10))
assertThat(titles()).containsExactly("Milk")
query(ProviderTable.LISTS).use {
it.moveToFirst()
assertThat(it.getString(it.getColumnIndexOrThrow(Lists.NAME))).isEqualTo("Home")
}
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
query(ProviderTable.PROPERTIES).use { assertThat(it.count).isEqualTo(1) }
}
@Test
fun aDeletedTaskIsNotPublished() {
val id = insert("Gone")
insert("Kept")
db.tasks().markDeleted(id, clock.now())
assertThat(titles()).containsExactly("Kept")
}
@Test
fun aWriteNotifiesObserversAndIsServedNext() {
source.start()
assertThat(titles()).isEmpty()
insert("Later")
awaitChange()
assertThat(titles()).containsExactly("Later")
}
@Test
fun aBurstOfWritesNotifiesOnce() {
source.start()
titles()
repeat(5) { insert("t$it") }
assertThat(changes.poll(5, TimeUnit.SECONDS)).isNotNull()
assertNoChange()
}
@Test
fun externalModeServesNothing() {
insert("Mine")
resolver.storageMode = StorageMode.EXTERNAL
ProviderTable.entries.forEach { table -> query(table).use { assertThat(it.count).isEqualTo(0) } }
}
@Test
fun switchingModeNotifiesAndRebuilds() {
source.start()
insert("Mine")
assertThat(titles()).containsExactly("Mine")
changes.clear()
resolver.storageMode = StorageMode.EXTERNAL
awaitChange()
assertThat(titles()).isEmpty()
resolver.storageMode = StorageMode.OWN
awaitChange()
assertThat(titles()).containsExactly("Mine")
}
@Test
fun writesInExternalModeDoNotNotify() {
resolver.storageMode = StorageMode.EXTERNAL
source.start()
titles()
insert("Hidden")
assertNoChange()
}
@Test
fun aClientSelectionNarrowsTheRows() {
insert("a")
insert("b")
query(ProviderTable.TASKS, "${Tasks.TITLE} = ?", arrayOf("b")).use { assertThat(it.count).isEqualTo(1) }
}
@Test
fun anHourOldSnapshotIsRebuiltAgainstTheClock() {
source.start()
insert("Daily", due = Instant.parse("2026-10-10T09:00:00Z"), rrule = "FREQ=DAILY;COUNT=3")
awaitChange()
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-10T09:00:00Z").toEpochMilliseconds())
clock.current += 1.days
assertThat(currentOriginalTime()).isEqualTo(Instant.parse("2026-10-11T09:00:00Z").toEpochMilliseconds())
awaitChange()
}
@Test
fun aFreshSnapshotIsReused() {
insert("a")
titles()
clock.current += 30.minutes
query(ProviderTable.INSTANCES).use { assertThat(it.count).isEqualTo(1) }
assertNoChange()
}
private fun currentOriginalTime(): Long =
query(ProviderTable.INSTANCES, "${Instances.DISTANCE_FROM_CURRENT} = 0").use {
assertThat(it.moveToFirst()).isTrue()
it.getLong(it.getColumnIndexOrThrow(Instances.INSTANCE_ORIGINAL_TIME))
}
}
private class MutableClock(var current: Instant) : Clock {
override fun now(): Instant = current
}
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
}
@@ -0,0 +1,811 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
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.ProviderEnvironment
import de.jeanlucmakiola.agendula.data.tasks.ProviderResolver
import de.jeanlucmakiola.agendula.data.tasks.StorageMode
import de.jeanlucmakiola.agendula.data.tasks.TasksContract
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Alarm
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Category
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Instances
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Properties
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import de.jeanlucmakiola.agendula.data.tasks.room.AccountEntity
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.RoomTasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.TaskAlarmEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
import org.junit.Assert.assertThrows
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.Duration.Companion.hours
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.awaitCancellation
import kotlin.time.Instant
/** Writes from another app, applied to the own store through the published tables. */
@RunWith(AndroidJUnit4::class)
class ProviderWriterTest {
private val context = ApplicationProvider.getApplicationContext<Context>()
private lateinit var db: TasksDatabase
private lateinit var resolver: ProviderResolver
private lateinit var source: ProviderSnapshotSource
private lateinit var writer: ProviderWriter
private var afterWrites = 0
private var home = 0L
/** Truncated to the store's granularity, and on the hour so occurrences land on round times. */
private val today = Instant.fromEpochMilliseconds(Clock.System.now().toEpochMilliseconds() / 3_600_000 * 3_600_000)
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, TasksDatabase::class.java).allowMainThreadQueries().build()
resolver = ProviderResolver(NoProvider)
source = ProviderSnapshotSource(context, db, resolver, awaitStartup = {})
writer = ProviderWriter(db, RoomTasksDataSource(db), source, resolver, afterWrite = { afterWrites++ })
home = db.taskLists().insert(TaskListEntity(name = "Home", color = 0))
}
@After
fun tearDown() = db.close()
private fun task(id: Long) = db.tasks().entity(id)
private fun insert(vararg values: Pair<String, Any?>): Long =
writer.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home, *values))
private fun byId(table: ProviderTable, id: Long) = "${table.idColumn} = $id"
private fun synced(readOnly: Boolean = false): Long {
val account = db.accounts().insert(AccountEntity(displayName = "me", username = "me"))
return db.taskLists().insert(
TaskListEntity(name = "Work", color = 0, accountId = account, href = "https://s/w/", isReadOnly = readOnly),
)
}
private fun dailySeries(title: String = "Daily"): Long = db.tasks().insert(
TaskEntity(listId = home, uid = title, title = title, dtstart = today - 1.days, due = today - 1.days + 1.hours,
rrule = "FREQ=DAILY;COUNT=5", timezone = "UTC"),
)
/** Each instance of [taskId]'s series by its original time, with its task row and status. */
private fun instances(): List<Triple<Long, Long, Int>> =
source.rows(
ProviderTable.INSTANCES,
listOf(Instances.INSTANCE_ORIGINAL_TIME, Instances.TASK_ID, Tasks.STATUS),
null,
null,
).map { (original, taskId, status) -> Triple(original as Long, taskId as Long, (status as Long).toInt()) }
private fun instanceId(original: Instant): Long =
source.rows(
ProviderTable.INSTANCES,
listOf(Tasks.ID),
"${Instances.INSTANCE_ORIGINAL_TIME} = ?",
arrayOf(original.toEpochMilliseconds().toString()),
).single().single() as Long
// --- inserts ----------------------------------------------------------------
@Test
fun anInsertedTaskIsPublishedAndOwedASync() {
val id = insert(Tasks.TITLE to "Milk", Tasks.PRIORITY to 1)
val stored = task(id)!!
assertThat(stored.title).isEqualTo("Milk")
assertThat(stored.priority).isEqualTo(1)
assertThat(stored.isDirty).isTrue()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.TITLE), byId(ProviderTable.TASKS, id), null))
.containsExactly(listOf("Milk"))
assertThat(afterWrites).isEqualTo(1)
}
@Test
fun anInsertNeedsAListThatExistsAndIsWritable() {
assertThrows(IllegalArgumentException::class.java) { writer.insert(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x")) }
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.LIST_ID to 999L) }
val readOnly = synced(readOnly = true)
assertThrows(UnsupportedOperationException::class.java) { insert(Tasks.LIST_ID to readOnly) }
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun anOccurrenceCannotBeInsertedAsATask() {
val series = dailySeries()
assertThrows(IllegalArgumentException::class.java) {
insert(Tasks.ORIGINAL_INSTANCE_ID to series, Tasks.ORIGINAL_INSTANCE_TIME to today.toEpochMilliseconds())
}
}
@Test
fun aDuplicateUidIsRejected() {
insert(Tasks.UID to "same")
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.UID to "same") }
}
@Test
fun listsAndInstancesTakeNoInserts() {
assertThrows(UnsupportedOperationException::class.java) {
writer.insert(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "New"))
}
assertThrows(UnsupportedOperationException::class.java) { writer.insert(ProviderTable.INSTANCES, emptyMap()) }
assertThrows(UnsupportedOperationException::class.java) {
writer.update(ProviderTable.LISTS, mapOf(TasksContract.Lists.NAME to "x"), null, null)
}
assertThrows(UnsupportedOperationException::class.java) { writer.delete(ProviderTable.LISTS, null, null) }
}
@Test
fun anUnknownColumnIsRejected() {
assertThrows(IllegalArgumentException::class.java) { insert("etag" to "x") }
}
@Test
fun nothingIsWrittenInExternalMode() {
val id = insert(Tasks.TITLE to "Mine")
resolver.storageMode = StorageMode.EXTERNAL
assertThrows(IllegalStateException::class.java) { insert() }
assertThrows(IllegalStateException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), null, null)
}
assertThat(task(id)!!.title).isEqualTo("Mine")
}
// --- updates ----------------------------------------------------------------
@Test
fun anUpdateChangesTheRowsItsSelectionMatches() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("b"))
assertThat(count).isEqualTo(1)
assertThat(task(a)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(task(b)!!.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(b)!!.completedAt).isNotNull()
}
@Test
fun anUpdateMatchingNothingChangesNothing() {
insert(Tasks.TITLE to "a")
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.TITLE} = 'zzz'", null)).isEqualTo(0)
}
@Test
fun aTaskInAReadOnlyListCannotChange() {
val readOnly = synced(readOnly = true)
val id = db.tasks().insert(TaskEntity(listId = readOnly, uid = "r", title = "theirs"))
assertThrows(UnsupportedOperationException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "mine"), byId(ProviderTable.TASKS, id), null)
}
assertThrows(UnsupportedOperationException::class.java) {
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
}
assertThat(task(id)!!.title).isEqualTo("theirs")
}
@Test
fun aTaskMovesBetweenLists() {
val work = synced()
val id = insert(Tasks.TITLE to "t")
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
assertThat(db.tasks().live().single { it.title == "t" }.listId).isEqualTo(work)
}
@Test
fun aParentMustBeInTheSameListAndNotADescendant() {
val work = synced()
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val elsewhere = insert(Tasks.LIST_ID to work, Tasks.TITLE to "elsewhere")
assertThat(task(child)!!.parentId).isEqualTo(parent)
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to elsewhere), byId(ProviderTable.TASKS, child), null)
}
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.PARENT_ID to child), byId(ProviderTable.TASKS, parent), null)
}
assertThrows(IllegalArgumentException::class.java) { insert(Tasks.PARENT_ID to 999L) }
}
@Test
fun movingASeriesDropsTheOverridesItNoLongerGenerates() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).hasSize(1)
writer.update(
ProviderTable.TASKS,
mapOf(Tasks.DTSTART to (today + 2.hours).toEpochMilliseconds(), Tasks.DUE to (today + 3.hours).toEpochMilliseconds()),
byId(ProviderTable.TASKS, series),
null,
)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aClientCannotTouchSyncState() {
val work = synced()
val id = db.tasks().insert(
TaskEntity(listId = work, uid = "s", title = "t", href = "/s.ics", etag = "e", sequence = 2, unknownProperties = "X-A:1"),
)
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "new", "_sync_id" to "x", "sync1" to "y"), byId(ProviderTable.TASKS, id), null)
val stored = task(id)!!
assertThat(stored.title).isEqualTo("new")
assertThat(stored.href).isEqualTo("/s.ics")
assertThat(stored.etag).isEqualTo("e")
assertThat(stored.sequence).isEqualTo(2)
assertThat(stored.unknownProperties).isEqualTo("X-A:1")
assertThat(stored.isDirty).isTrue()
}
// --- instances --------------------------------------------------------------
@Test
fun completingAnOccurrenceTicksOffOnlyThatOne() {
val series = dailySeries()
val count = writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(count).isEqualTo(1)
assertThat(task(series)!!.status).isEqualTo(TaskStatus.NEEDS_ACTION)
val override = db.tasks().overrides(series).single()
assertThat(override.recurrenceId).isEqualTo(today)
assertThat(override.status).isEqualTo(TaskStatus.COMPLETED)
val statuses = instances().associate { it.first to it.third }
assertThat(statuses[today.toEpochMilliseconds()]).isEqualTo(TasksContract.STATUS_COMPLETED)
assertThat(statuses.filterKeys { it != today.toEpochMilliseconds() }.values.toSet())
.containsExactly(TasksContract.STATUS_NEEDS_ACTION)
}
@Test
fun anOccurrenceCanBeCancelledAndReopened() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 3), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.CANCELLED)
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun contentWrittenToAnOccurrenceEditsTheSeries() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun anOccurrenceCannotBeMoved() {
dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.DUE to 0L), byId(ProviderTable.INSTANCES, instanceId(today)), null)
}
}
@Test
fun anOccurrenceHasNoPartialProgress() {
dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50), byId(ProviderTable.INSTANCES, instanceId(today)), null)
}
}
@Test
fun aPlainTaskInstanceWritesTheTask() {
val id = insert(Tasks.TITLE to "t", Tasks.DUE to today.toEpochMilliseconds())
val instance = source.rows(ProviderTable.INSTANCES, listOf(Tasks.ID), "${Instances.TASK_ID} = $id", null).single().single() as Long
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.PERCENT_COMPLETE to 50, Tasks.DUE to (today + 1.days).toEpochMilliseconds()), byId(ProviderTable.INSTANCES, instance), null)
assertThat(task(id)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(task(id)!!.due).isEqualTo(today + 1.days)
}
@Test
fun deletingAnOccurrenceExceptsIt() {
val series = dailySeries()
assertThat(writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)).isEqualTo(1)
assertThat(task(series)!!.exdate).isNotNull()
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
assertThat(instances()).hasSize(4)
}
@Test
fun deletingAModifiedOccurrenceExceptsItToo() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).isEmpty()
assertThat(instances().map { it.first }).doesNotContain(today.toEpochMilliseconds())
}
// --- deletes ----------------------------------------------------------------
@Test
fun aParentGoesWithItsSubtasks() {
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
val other = insert(Tasks.TITLE to "other")
assertThat(writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)).isEqualTo(1)
assertThat(task(parent)).isNull()
assertThat(task(child)).isNull()
assertThat(task(grandchild)).isNull()
assertThat(task(other)).isNotNull()
}
@Test
fun aSyncedTaskLeavesATombstone() {
val work = synced()
val id = insert(Tasks.LIST_ID to work, Tasks.TITLE to "t")
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, id), null)
assertThat(task(id)!!.isDeleted).isTrue()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(0)
}
@Test
fun deletingAnOverrideThroughTasksExceptsTheOccurrence() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, override), null)
assertThat(task(series)).isNotNull()
assertThat(task(series)!!.exdate).isNotNull()
assertThat(instances()).hasSize(4)
}
// --- properties -------------------------------------------------------------
@Test
fun anAlarmCanBeAddedChangedAndRemoved() {
val id = insert(Tasks.TITLE to "t", Tasks.DUE to (today + 1.days).toEpochMilliseconds())
db.tasks().update(task(id)!!.copy(isDirty = false))
val propertyId = writer.insert(
ProviderTable.PROPERTIES,
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE, Alarm.MINUTES_BEFORE to "15", Alarm.REFERENCE to "1"),
)
val alarm = db.alarms().forTask(id).single()
assertThat(alarm.minutesBefore).isEqualTo(15)
assertThat(alarm.reference).isEqualTo(AlarmReference.DUE)
assertThat(task(id)!!.isDirty).isTrue()
assertThat(propertyId).isEqualTo(ProviderIds.alarm(alarm.id))
writer.update(ProviderTable.PROPERTIES, mapOf(Alarm.MINUTES_BEFORE to 60, Alarm.REFERENCE to 2), byId(ProviderTable.PROPERTIES, propertyId), null)
assertThat(db.alarms().forTask(id).single().minutesBefore).isEqualTo(60)
assertThat(db.alarms().forTask(id).single().reference).isEqualTo(AlarmReference.START)
writer.delete(ProviderTable.PROPERTIES, "${Properties.MIMETYPE} = ?", arrayOf(Alarm.MIMETYPE))
assertThat(db.alarms().forTask(id)).isEmpty()
}
@Test
fun tagsCanBeAddedRenamedAndRemoved() {
val id = insert(Tasks.TITLE to "t")
val propertyId = writer.insert(
ProviderTable.PROPERTIES,
mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to " errand "),
)
assertThat(task(id)!!.categories).containsExactly("errand")
writer.update(ProviderTable.PROPERTIES, mapOf(Category.NAME to "shopping"), byId(ProviderTable.PROPERTIES, propertyId), null)
assertThat(task(id)!!.categories).containsExactly("shopping")
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Category.MIMETYPE, Category.NAME to "shopping"))
assertThat(task(id)!!.categories).containsExactly("shopping")
writer.delete(ProviderTable.PROPERTIES, "${Category.NAME} = ?", arrayOf("shopping"))
assertThat(task(id)!!.categories).isEmpty()
}
@Test
fun aPropertyCannotChangeItsKindOrTask() {
val id = insert(Tasks.TITLE to "t")
val other = insert(Tasks.TITLE to "o")
db.alarms().insert(TaskAlarmEntity(taskId = id, minutesBefore = 5))
val propertyId = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), null, null).single().single() as Long
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.MIMETYPE to Category.MIMETYPE), byId(ProviderTable.PROPERTIES, propertyId), null)
}
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to other), byId(ProviderTable.PROPERTIES, propertyId), null)
}
}
@Test
fun onlyAlarmsAndTagsCanBeWritten() {
val id = insert(Tasks.TITLE to "t")
assertThrows(IllegalArgumentException::class.java) {
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to "vnd.android.cursor.item/comment"))
}
assertThrows(IllegalArgumentException::class.java) {
writer.insert(ProviderTable.PROPERTIES, mapOf(Properties.TASK_ID to id, Properties.MIMETYPE to Alarm.MIMETYPE))
}
}
// --- batches ----------------------------------------------------------------
@Test
fun aBatchIsAllOrNothing() {
assertThrows(IllegalArgumentException::class.java) {
writer.batch {
insert(Tasks.TITLE to "first")
insert(Tasks.TITLE to "second")
insert(Tasks.LIST_ID to 999L)
}
}
assertThat(db.tasks().live()).isEmpty()
assertThat(source.rows(ProviderTable.TASKS, listOf(Tasks.ID), null, null)).isEmpty()
}
@Test
fun aBatchSeesItsOwnWritesAndReArmsRemindersOnce() {
writer.batch {
insert(Tasks.TITLE to "first")
writer.update(ProviderTable.TASKS, mapOf(Tasks.STATUS to 2), "${Tasks.TITLE} = ?", arrayOf("first"))
}
assertThat(db.tasks().live().single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(afterWrites).isEqualTo(1)
}
// --- review regressions -----------------------------------------------------
@Test
fun aBatchDeletingTwoPropertiesDeletesThoseTwo() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
listOf(5, 10).forEach { db.alarms().insert(TaskAlarmEntity(taskId = a, minutesBefore = it)) }
db.alarms().insert(TaskAlarmEntity(taskId = b, minutesBefore = 15))
val ids = source.rows(ProviderTable.PROPERTIES, listOf(Properties.PROPERTY_ID), "${Properties.TASK_ID} = $a", null)
.map { it.single() as Long }
writer.batch { ids.forEach { writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, it), null) } }
assertThat(db.alarms().forTask(a)).isEmpty()
assertThat(db.alarms().forTask(b).map { it.minutesBefore }).containsExactly(15)
}
@Test
fun aBatchDeletingTwoOccurrencesDeletesThoseTwo() {
dailySeries()
val first = instanceId(today)
val second = instanceId(today + 1.days)
writer.batch {
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, first), null)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, second), null)
}
assertThat(instances().map { it.first }).containsExactly(
(today - 1.days).toEpochMilliseconds(),
(today + 2.days).toEpochMilliseconds(),
(today + 3.days).toEpochMilliseconds(),
)
}
@Test
fun anInstanceIdOutlivesAnotherOccurrencesDeletion() {
dailySeries()
val later = instanceId(today + 2.days)
writer.delete(ProviderTable.INSTANCES, byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(instanceId(today + 2.days)).isEqualTo(later)
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, later), null))
.isEqualTo(1)
assertThat(instances().single { it.third == TasksContract.STATUS_COMPLETED }.first)
.isEqualTo((today + 2.days).toEpochMilliseconds())
}
@Test
fun aRecurringSubtaskOutsideTheWindowGoesWithItsParent() {
val parent = insert(Tasks.TITLE to "parent")
val longAgo = today - 1000.days
val subtask = db.tasks().insert(
TaskEntity(listId = home, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2",
timezone = "UTC", parentId = parent),
)
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
assertThat(task(subtask)).isNull()
}
@Test
fun anOverriddenRecurringSubtaskGoesWholeWithItsParent() {
val parent = insert(Tasks.TITLE to "parent")
val subtask = dailySeries("sub")
db.tasks().update(task(subtask)!!.copy(parentId = parent))
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, parent), null)
assertThat(task(subtask)).isNull()
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun aModifiedOccurrenceCanBeWrittenBackAsRead() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
val row = readRow(ProviderTable.TASKS, override)
writer.update(ProviderTable.TASKS, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.TASKS, override), null)
assertThat(task(override)!!.title).isEqualTo("Renamed")
}
@Test
fun anInstanceCanBeWrittenBackAsReadToCompleteIt() {
val series = dailySeries()
val instance = instanceId(today)
val row = readRow(ProviderTable.INSTANCES, instance)
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instance), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(series)!!.dtstart).isEqualTo(today - 1.days)
}
@Test
fun movingASubtreeMovesItsSubtasks() {
val work = synced()
val parent = insert(Tasks.TITLE to "parent")
val child = insert(Tasks.TITLE to "child", Tasks.PARENT_ID to parent)
val grandchild = insert(Tasks.TITLE to "grandchild", Tasks.PARENT_ID to child)
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, parent), null)
assertThat(listOf(parent, child, grandchild).map { task(it)!!.listId }).containsExactly(work, work, work)
assertThat(task(child)!!.parentId).isEqualTo(parent)
assertThat(task(grandchild)!!.parentId).isEqualTo(child)
}
@Test
fun aMoveOntoATakenUidIsRejected() {
val work = synced()
insert(Tasks.LIST_ID to work, Tasks.UID to "same")
val id = insert(Tasks.UID to "same")
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), byId(ProviderTable.TASKS, id), null)
}
assertThat(task(id)!!.listId).isEqualTo(home)
}
@Test
fun contentOnAModifiedOccurrenceStillEditsTheSeries() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
val override = db.tasks().overrides(series).single().id
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(task(override)!!.title).isEqualTo("Renamed")
assertThat(task(override)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aWriteThatChangesNothingOwesNoSync() {
val id = insert(Tasks.TITLE to "t")
db.tasks().update(task(id)!!.copy(isDirty = false))
val modified = task(id)!!.lastModified
writer.update(ProviderTable.TASKS, readRow(ProviderTable.TASKS, id), byId(ProviderTable.TASKS, id), null)
writer.update(ProviderTable.TASKS, emptyMap(), byId(ProviderTable.TASKS, id), null)
assertThat(task(id)!!.isDirty).isFalse()
assertThat(task(id)!!.lastModified).isEqualTo(modified)
}
@Test
fun reopeningAnOpenOccurrenceForksNothing() {
val series = dailySeries()
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.STATUS to 0), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aWriteBeforeStartupSettlesIsRefused() {
val stuck = ProviderSnapshotSource(context, db, resolver, awaitStartup = { awaitCancellation() }, startupWait = 50.milliseconds)
val early = ProviderWriter(db, RoomTasksDataSource(db), stuck, resolver, afterWrite = {})
assertThrows(IllegalStateException::class.java) { early.insert(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to home)) }
assertThat(db.tasks().live()).isEmpty()
}
@Test
fun aSingleRowWriteNeedsNoSnapshot() {
val id = insert(Tasks.TITLE to "t")
val unknown = 987_654L
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, unknown), null)).isEqualTo(0)
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.TASKS, id), null)).isEqualTo(1)
assertThat(writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TITLE to "x"), byId(ProviderTable.INSTANCES, unknown), null)).isEqualTo(0)
assertThat(writer.delete(ProviderTable.PROPERTIES, byId(ProviderTable.PROPERTIES, unknown), null)).isEqualTo(0)
}
// --- second review regressions -----------------------------------------------
@Test
fun tickingOffAModifiedOccurrenceAsReadLeavesTheSeriesAlone() {
val series = dailySeries("Standup")
// Reopening forks an override, which then gets its own title as the app's "this occurrence" edit would.
store().setCompletedInstance(series, today, false)
val override = db.tasks().overrides(series).single()
db.tasks().update(override.copy(title = "Standup (moved room)"))
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Standup")
assertThat(task(override.id)!!.title).isEqualTo("Standup (moved room)")
assertThat(task(override.id)!!.status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun anInstanceWrittenBackWithOnlyProgressChangedIsCompleted() {
val series = dailySeries()
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.PERCENT_COMPLETE to 100), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
}
@Test
fun aBadZoneOnAnInstanceReportsTheZone() {
dailySeries()
val instance = instanceId(today)
val error = assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.INSTANCES, mapOf(Tasks.TZ to "Europe/Berln"), byId(ProviderTable.INSTANCES, instance), null)
}
assertThat(error).hasMessageThat().contains("Unknown time zone")
}
@Test
fun anInProcessSeriesCanBeWrittenBackAsRead() {
val series = dailySeries()
db.tasks().update(task(series)!!.copy(status = TaskStatus.IN_PROCESS, percentComplete = 50))
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.TITLE to "Renamed"), byId(ProviderTable.INSTANCES, instanceId(today)), null)
assertThat(task(series)!!.title).isEqualTo("Renamed")
assertThat(task(series)!!.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(db.tasks().overrides(series)).isEmpty()
}
@Test
fun aFloatingSeriesCanBeWrittenBackAsRead() {
val series = db.tasks().insert(
TaskEntity(listId = home, uid = "f", title = "Floating", due = today, rrule = "FREQ=DAILY;COUNT=3", timezone = null),
)
val row = readRow(ProviderTable.INSTANCES, instanceId(today))
writer.update(ProviderTable.INSTANCES, row + (Tasks.STATUS to 2), byId(ProviderTable.INSTANCES, instanceId(today)), null)
writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "Still floating"), byId(ProviderTable.TASKS, series), null)
assertThat(db.tasks().overrides(series).single().status).isEqualTo(TaskStatus.COMPLETED)
assertThat(task(series)!!.timezone).isNull()
}
@Test
fun aParentLoopDeletesInsteadOfRecursingForever() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b", Tasks.PARENT_ID to a)
db.tasks().update(task(a)!!.copy(parentId = b))
writer.delete(ProviderTable.TASKS, byId(ProviderTable.TASKS, a), null)
assertThat(task(a)).isNull()
assertThat(task(b)).isNull()
}
@Test
fun aBulkMoveWorksWhicheverOrderTheRowsComeIn() {
val work = synced()
// The subtask gets the lower id, so it comes first unless the writer reorders.
val child = insert(Tasks.TITLE to "child")
val parent = insert(Tasks.TITLE to "parent")
db.tasks().update(task(child)!!.copy(parentId = parent))
val count = writer.update(ProviderTable.TASKS, mapOf(Tasks.LIST_ID to work), "${Tasks.LIST_ID} = ?", arrayOf(home.toString()))
assertThat(count).isEqualTo(2)
assertThat(task(child)!!.listId).isEqualTo(work)
assertThat(task(parent)!!.listId).isEqualTo(work)
assertThat(task(child)!!.parentId).isEqualTo(parent)
}
@Test
fun aRowSelectedWithAnArgumentIsWritten() {
val a = insert(Tasks.TITLE to "a")
val b = insert(Tasks.TITLE to "b")
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID}=?", arrayOf(b.toString()))).isEqualTo(1)
assertThat(writer.update(ProviderTable.TASKS, mapOf(Tasks.TITLE to "x"), "${Tasks.ID} = ?", arrayOf("987654"))).isEqualTo(0)
assertThat(task(a)!!.title).isEqualTo("a")
assertThat(task(b)!!.title).isEqualTo("x")
}
@Test
fun garbageExceptionDatesAreRejected() {
val series = dailySeries()
assertThrows(IllegalArgumentException::class.java) {
writer.update(ProviderTable.TASKS, mapOf(Tasks.EXDATE to "tomorrow"), byId(ProviderTable.TASKS, series), null)
}
assertThat(task(series)!!.exdate).isNull()
}
private fun store() = RoomTasksDataSource(db)
/** Every column of one published row, as a client that read it would write it back. */
private fun readRow(table: ProviderTable, id: Long): Map<String, Any?> {
val columns = table.columns.map { it.first }
val row = source.rows(table, columns, byId(table, id), null).single()
return columns.zip(row).toMap() - table.idColumn
}
}
private object NoProvider : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = null
override fun isGranted(permission: String): Boolean = false
override fun appLabel(packageName: String): String? = null
}
@@ -0,0 +1,141 @@
package de.jeanlucmakiola.agendula.data.tasks.provider
import android.database.Cursor
import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Lists
import de.jeanlucmakiola.agendula.data.tasks.TasksContract.Tasks
import org.junit.Assert.assertThrows
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
/** The SQL a client sends runs against the snapshot as it would against OpenTasks. */
@RunWith(AndroidJUnit4::class)
class SnapshotDatabaseTest {
private val db = SnapshotDatabase()
private fun task(id: Long, title: String?, due: Long? = null): ProviderRow =
mapOf(Tasks.ID to id, Tasks.LIST_ID to 1L, Tasks.TITLE to title, Tasks.DUE to due, Tasks.PRIORITY to 0)
private fun load(vararg tasks: ProviderRow, lists: List<ProviderRow> = emptyList()) = db.load(
ProviderSnapshot(
ProviderTable.entries.associateWith {
when (it) {
ProviderTable.TASKS -> tasks.toList()
ProviderTable.LISTS -> lists
else -> emptyList()
}
},
),
)
private fun Cursor.strings(column: String): List<String?> = use {
val index = getColumnIndexOrThrow(column)
buildList { while (moveToNext()) add(if (isNull(index)) null else getString(index)) }
}
@Test
fun aNullProjectionReturnsEveryColumnInTableOrder() {
load(task(1, "a"))
db.query(ProviderTable.TASKS, null, null, null, null).use {
assertThat(it.columnNames.toList()).containsExactlyElementsIn(ProviderTable.TASKS.columns.map { c -> c.first })
.inOrder()
assertThat(it.count).isEqualTo(1)
}
}
@Test
fun selectionArgumentsAndSortOrderApply() {
load(task(1, "b", due = 30), task(2, "a", due = 10), task(3, "c", due = 20), task(4, "skip", due = 5))
val titles = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), "${Tasks.TITLE} != ?", arrayOf("skip"), "${Tasks.DUE} ASC")
.strings(Tasks.TITLE)
assertThat(titles).containsExactly("a", "c", "b").inOrder()
}
@Test
fun typesSurviveTheCopy() {
load(task(1, null, due = 1_700_000_000_000))
db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE, Tasks.DUE, Tasks.PRIORITY), null, null, null).use {
it.moveToFirst()
assertThat(it.isNull(0)).isTrue()
assertThat(it.getType(1)).isEqualTo(Cursor.FIELD_TYPE_INTEGER)
assertThat(it.getLong(1)).isEqualTo(1_700_000_000_000)
assertThat(it.getInt(2)).isEqualTo(0)
}
}
@Test
fun anUnsupportedColumnReadsAsNull() {
load(task(1, "a"))
assertThat(db.query(ProviderTable.TASKS, arrayOf("_sync_id"), null, null, null).strings("_sync_id"))
.containsExactly(null)
}
@Test
fun anUnknownProjectionColumnIsRejected() {
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, arrayOf("no_such_column"), null, null, null)
}
}
@Test
fun anUnknownSelectionColumnIsRejected() {
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, null, "sqlite_master.name IS NOT NULL", null, null)
}
}
@Test
fun aSelectionCannotBreakOutOfItsWhereClause() {
load(task(1, "a"))
assertThrows(IllegalArgumentException::class.java) {
db.query(ProviderTable.TASKS, null, "1) UNION SELECT name FROM sqlite_master --", null, null)
}
}
@Test
fun eachTableAnswersOnlyForItsOwnRows() {
load(task(1, "a"), lists = listOf(mapOf(Lists.ID to 7L, Lists.NAME to "Home")))
assertThat(db.query(ProviderTable.LISTS, arrayOf(Lists.NAME), null, null, null).strings(Lists.NAME))
.containsExactly("Home")
db.query(ProviderTable.INSTANCES, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
}
@Test
fun aReloadReplacesTheRowsButNotAnOpenCursor() {
load(task(1, "old"))
val before = db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null)
load(task(2, "new"))
assertThat(before.strings(Tasks.TITLE)).containsExactly("old")
assertThat(db.query(ProviderTable.TASKS, arrayOf(Tasks.TITLE), null, null, null).strings(Tasks.TITLE))
.containsExactly("new")
}
@Test
fun anEmptySnapshotEmptiesEveryTable() {
load(task(1, "a"))
db.load(ProviderSnapshot.EMPTY)
ProviderTable.entries.forEach { table ->
db.query(table, null, null, null, null).use { assertThat(it.count).isEqualTo(0) }
}
}
}
@@ -5,7 +5,7 @@ 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.TaskReminder
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.After
@@ -366,41 +366,23 @@ class RoomTasksDataSourceTest {
@Test
fun alarmsRoundTripAndReplaceRatherThanAccumulate() {
val id = source.insertTask(form(due = now + 1.days))
val start = TaskReminder(minutesBefore = 10, fromStart = true)
// 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)
// Int is what fired an imported START-referenced alarm off DUE.
source.setReminders(id, listOf(start, TaskReminder(30)))
assertThat(source.reminders()[id]).containsExactly(start, TaskReminder(30)).inOrder()
assertThat(source.alarms()[id]).isEqualTo(TaskReminder(30))
source.setReminders(id, listOf(TaskReminder(60)))
assertThat(db.alarms().forTask(id)).hasSize(1)
assertThat(source.reminders()[id]).containsExactly(TaskReminder(60))
}
@Test
fun forkingAnOccurrenceCarriesTheReminderOntoIt() {
val id = source.insertTask(form(due = now + 1.days))
makeRecurring(id, now)
source.setAlarm(id, 30)
source.setReminders(id, listOf(TaskReminder(30)))
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
@@ -417,7 +399,7 @@ class RoomTasksDataSourceTest {
val original = db.alarms().forTask(id).single()
db.alarms().update(original.copy(raw = "BEGIN:VALARM\r\nX-VENDOR:keep\r\nEND:VALARM\r\n"))
source.updateTask(id, form(title = "renamed", due = now + 2.days), dueReminders = listOf(15, 60))
source.updateTask(id, form(title = "renamed", due = now + 2.days), reminders = listOf(TaskReminder(15), TaskReminder(60)))
val rows = db.alarms().forTask(id)
val kept = rows.single { it.minutesBefore == 15 }
@@ -429,13 +411,13 @@ class RoomTasksDataSourceTest {
}
@Test
fun aSaveKeepsABeforeStartReminderTheEditorNeverShows() {
fun aSaveWritesBeforeStartAndBeforeDueRemindersAlike() {
val id = source.insertTask(form(due = now + 1.days))
source.setReminders(id, listOf(TaskReminder(10, fromStart = true), TaskReminder(30)))
source.updateTask(id, form(due = now + 1.days), dueReminders = listOf(45))
source.updateTask(id, form(due = now + 1.days), reminders = listOf(TaskReminder(5, fromStart = true), TaskReminder(45)))
assertThat(source.reminders()[id]).containsExactly(TaskReminder(10, fromStart = true), TaskReminder(45))
assertThat(source.reminders()[id]).containsExactly(TaskReminder(5, fromStart = true), TaskReminder(45))
}
@Test
@@ -448,7 +430,7 @@ class RoomTasksDataSourceTest {
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(60))
source.updateInstance(id, target.occurrenceStart!!, form(title = "moved").copy(listId = work), listOf(TaskReminder(60)))
// ⚠️ The master is never written, not even briefly: a sync landing in
// between would upload the occurrence's reminder for the whole series.
@@ -467,7 +449,7 @@ class RoomTasksDataSourceTest {
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(30))
val created = source.splitSeries(id, target.occurrenceStart!!, form(title = "later"), listOf(TaskReminder(30)))
assertThat(created).isNotEqualTo(id)
val copy = db.alarms().forTask(created).single()
@@ -500,12 +482,12 @@ class RoomTasksDataSourceTest {
val completed = occurrences.first { it.distanceFromCurrent == 1 }.occurrenceStart!!
val own = occurrences.first { it.distanceFromCurrent == 2 }.occurrenceStart!!
source.setCompletedInstance(id, completed, true)
source.updateInstance(id, own, form(title = "own"), listOf(5))
source.updateInstance(id, own, form(title = "own"), listOf(TaskReminder(5)))
// Same times and rule, so the series is not reshaped — that drops its overrides.
val first = occurrences.first()
val unchanged = form().copy(start = first.start, due = first.due, rrule = "FREQ=WEEKLY")
source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(60))
source.updateSeries(id, first.occurrenceStart!!, unchanged, listOf(TaskReminder(60)))
fun leads(anchor: Instant) = db.alarms().forTask(db.tasks().override(id, anchor)!!.id).map { it.minutesBefore }
assertThat(db.alarms().forTask(id).map { it.minutesBefore }).containsExactly(60)
@@ -529,12 +511,12 @@ class RoomTasksDataSourceTest {
}
@Test
fun settingTheEditableReminderOwesTheServerAPut() {
fun settingRemindersOwesTheServerAPut() {
val work = syncedList()
val id = source.insertTask(form(due = now + 1.days).copy(listId = work))
db.tasks().update(db.tasks().entity(id)!!.copy(isDirty = false))
source.setAlarm(id, 30)
source.setReminders(id, listOf(TaskReminder(30)))
assertThat(db.tasks().entity(id)!!.isDirty).isTrue()
}
@@ -567,7 +549,7 @@ class RoomTasksDataSourceTest {
fun aForkedOccurrenceCarriesTheSeriesReminder() {
val id = source.insertTask(form())
makeRecurring(id, now)
source.setAlarm(id, minutesBeforeDue = 30)
source.setReminders(id, listOf(TaskReminder(30)))
val target = source.tasks(TaskQuery(listId = listId))
.filter { it.taskId == id }
.first { it.distanceFromCurrent == 1 }
@@ -667,4 +649,47 @@ class RoomTasksDataSourceTest {
assertThat(thrown).isNotNull()
}
@Test
fun subtaskIdsNameRowsNotOccurrences() {
val parent = source.insertTask(form("parent"))
val longAgo = now - 1000.days
val outOfWindow = db.tasks().insert(
TaskEntity(listId = listId, uid = "old", title = "old", dtstart = longAgo, rrule = "FREQ=DAILY;COUNT=2", parentId = parent),
)
val series = db.tasks().insert(
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = now, rrule = "FREQ=DAILY;COUNT=3", parentId = parent),
)
source.setCompletedInstance(series, now, true)
val plain = source.insertTask(form("plain").copy(parentId = parent))
assertThat(source.subtaskIds(parent)).containsExactly(outOfWindow, series, plain)
}
@Test
fun deletingASubtreeEndsOnAParentLoop() {
val a = source.insertTask(form("a"))
val b = source.insertTask(form("b").copy(parentId = a))
val c = source.insertTask(form("c").copy(parentId = b))
db.tasks().update(db.tasks().entity(a)!!.copy(parentId = c))
source.deleteWithSubtasks(a)
assertThat(listOf(a, b, c).mapNotNull { db.tasks().entity(it) }).isEmpty()
}
@Test
fun deletingFollowingFromTheFirstOccurrenceTakesTheWholeSubtree() {
val start = now
val series = db.tasks().insert(
TaskEntity(listId = listId, uid = "s", title = "s", dtstart = start, rrule = "FREQ=DAILY;COUNT=3"),
)
val child = source.insertTask(form("child").copy(parentId = series))
val grandchild = source.insertTask(form("grandchild").copy(parentId = child))
source.deleteFollowing(series, start)
assertThat(listOf(series, child, grandchild).mapNotNull { db.tasks().entity(it) }).isEmpty()
}
}
@@ -11,7 +11,7 @@ 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.domain.TaskReminder
import de.jeanlucmakiola.agendula.data.tasks.TasksDataSource
import de.jeanlucmakiola.agendula.data.tasks.room.AlarmReference
import de.jeanlucmakiola.agendula.data.tasks.room.TasksDatabase
@@ -294,25 +294,24 @@ private class FakeExternalStore : TasksDataSource {
return tasks[listId].orEmpty()
}
override fun alarms(): Map<Long, TaskReminder> = alarms
override fun reminders(): Map<Long, List<TaskReminder>> = alarms.mapValues { listOf(it.value) }
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, reminders: List<TaskReminder>?): Long = unused()
override fun updateTask(taskId: Long, form: TaskForm, dueReminders: List<Int>?) = unused()
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?) =
override fun updateTask(taskId: Long, form: TaskForm, reminders: List<TaskReminder>?) = unused()
override fun updateInstance(taskId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
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, dueReminders: List<Int>?) =
override fun updateSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?) =
unused()
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, dueReminders: List<Int>?): Long =
override fun splitSeries(seriesId: Long, occurrenceStart: Instant, form: TaskForm, reminders: List<TaskReminder>?): Long =
unused()
override fun deleteOccurrence(seriesId: Long, occurrenceStart: Instant) = unused()
override fun deleteFollowing(seriesId: Long, occurrenceStart: Instant) = unused()