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:
Jean-Luc Makiola
2026-09-24 16:36:49 +02:00
co-authored by makiolaj
parent 7bbdbe60e3
commit ac01993d41
476 changed files with 53728 additions and 2919 deletions
@@ -0,0 +1,61 @@
package de.jeanlucmakiola.agendula.data.export
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
/**
* The export file name. The user picks the folder, so whatever comes out of here
* is what they will be looking at in a file manager a year from now.
*/
class TaskExporterTest {
private fun name(listName: String, id: Long = 3L) = TaskExporter.fileNameFor(listName, id)
@Test
fun `keeps a plain name readable`() {
assertThat(name("Groceries")).isEqualTo("Groceries-3.ics")
}
@Test
fun `replaces characters a filesystem would reject`() {
// SAF can land on FAT32 (an SD card), where these are simply illegal.
val result = name("Work / Home: notes?")
assertThat(result).doesNotContain("/")
assertThat(result).doesNotContain(":")
assertThat(result).doesNotContain("?")
assertThat(result).endsWith("-3.ics")
}
@Test
fun `keeps the id so same-named lists cannot collide`() {
// Two accounts may each have a list called "Personal"; without the id one
// export would silently overwrite the other.
assertThat(name("Personal", 1)).isNotEqualTo(name("Personal", 2))
}
@Test
fun `falls back when the name has nothing usable in it`() {
assertThat(name("///")).isEqualTo("list-3.ics")
assertThat(name("")).isEqualTo("list-3.ics")
}
@Test
fun `does not leave dangling separators`() {
assertThat(name(" Shopping ")).isEqualTo("Shopping-3.ics")
}
@Test
fun `caps the length`() {
// Many filesystems stop at 255 bytes for a name; a pathological list title
// should not be the thing that fails an export.
assertThat(name("x".repeat(500)).length).isAtMost(80)
}
@Test
fun `keeps non-latin names instead of blanking them`() {
// isLetterOrDigit is Unicode-aware, so these survive rather than collapsing
// to the "list" fallback.
assertThat(name("Einkäufe")).isEqualTo("Einkäufe-3.ics")
assertThat(name("買い物")).isEqualTo("買い物-3.ics")
}
}
@@ -0,0 +1,23 @@
package de.jeanlucmakiola.agendula.data.prefs
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.testTask
import org.junit.jupiter.api.Test
class SettingsVisibilityTest {
private val tasks = listOf(testTask(id = 1, listId = 1), testTask(id = 2, listId = 2))
private val settings = Settings(hiddenFromSmartLists = setOf(2L))
@Test
fun `a smart list leaves out a hidden list`() {
assertThat(settings.visibleIn(TaskFilter.Smart(SmartList.ALL), tasks).map { it.taskId }).containsExactly(1L)
}
@Test
fun `the hidden list still shows its own tasks`() {
assertThat(settings.visibleIn(TaskFilter.OfList(2), tasks)).isEqualTo(tasks)
}
}
@@ -0,0 +1,59 @@
package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ReminderDiffTest {
private val a = ScheduledReminder(1, 1_000)
private val b = ScheduledReminder(2, 2_000)
private val c = ScheduledReminder(3, 3_000)
@Test
fun `an armed alarm that is still wanted is left alone`() {
val diff = ReminderDiff.of(previous = setOf(a, b), desired = setOf(a, c)) { true }
assertThat(diff.keep).containsExactly(a)
assertThat(diff.cancel).containsExactly(b)
assertThat(diff.arm).containsExactly(c)
}
@Test
fun `alarms the system forgot are armed again, although the store still lists them`() {
// A reboot or force-stop: the store came through, the alarms did not.
val diff = ReminderDiff.of(previous = setOf(a, b), desired = setOf(a, b)) { false }
assertThat(diff.arm).containsExactly(a, b)
assertThat(diff.keep).isEmpty()
assertThat(diff.cancel).isEmpty()
}
@Test
fun `only the missing ones are re-armed`() {
val diff = ReminderDiff.of(previous = setOf(a, b), desired = setOf(a, b)) { it == a }
assertThat(diff.arm).containsExactly(b)
assertThat(diff.keep).containsExactly(a)
}
@Test
fun `a reminder that already fired is not armed again`() {
val diff = ReminderDiff.of(
previous = setOf(a),
desired = setOf(a),
fired = setOf(a),
) { false }
assertThat(diff.arm).isEmpty()
assertThat(diff.keep).containsExactly(a)
}
@Test
fun `store entries round-trip, and junk is skipped`() {
val encoded = ScheduledReminderStore.encode(setOf(a, b))
assertThat(encoded.mapNotNull(ScheduledReminderStore::decode)).containsExactly(a, b)
val withOccurrence = ScheduledReminder(4, 4_000, occurrenceStart = 3_000)
assertThat(ScheduledReminderStore.encode(setOf(withOccurrence)).map(ScheduledReminderStore::decode))
.containsExactly(withOccurrence)
// Written before occurrences were recorded.
assertThat(ScheduledReminderStore.decode("4|4000")).isEqualTo(ScheduledReminder(4, 4_000))
assertThat(ScheduledReminderStore.decode("x|1")).isNull()
assertThat(ScheduledReminderStore.decode("1")).isNull()
}
}
@@ -0,0 +1,189 @@
package de.jeanlucmakiola.agendula.data.reminders
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.prefs.Settings
import de.jeanlucmakiola.agendula.data.tasks.TaskReminder
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.testTask
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Locale
import kotlin.time.Instant
class ReminderPlannerTest {
private val berlin = ZoneId.of("Europe/Berlin")
private val newYork = ZoneId.of("America/New_York")
private val date = LocalDate.of(2026, 7, 20)
private fun millis(at: LocalDateTime, zone: ZoneId) = at.atZone(zone).toInstant().toEpochMilli()
@Test
fun `an all-day reminder fires at the configured local time, not UTC midnight`() {
val trigger = ReminderPlanner.triggerAt(allDayInstantOf(date), true, 0, 9 * 60, berlin)
assertThat(trigger).isEqualTo(millis(date.atTime(9, 0), berlin))
}
@Test
fun `an all-day reminder stays on its date west of Greenwich`() {
val trigger = ReminderPlanner.triggerAt(allDayInstantOf(date), true, 0, 9 * 60, newYork)
assertThat(trigger).isEqualTo(millis(date.atTime(9, 0), newYork))
}
@Test
fun `the lead counts back from the all-day time`() {
val trigger = ReminderPlanner.triggerAt(allDayInstantOf(date), true, 24 * 60, 8 * 60 + 30, berlin)
assertThat(trigger).isEqualTo(millis(date.minusDays(1).atTime(8, 30), berlin))
}
@Test
fun `a timed reminder counts back from the instant itself`() {
val due = Instant.fromEpochMilliseconds(1_000_000_000L)
assertThat(ReminderPlanner.triggerAt(due, false, 15, 9 * 60, berlin))
.isEqualTo(1_000_000_000L - 15 * 60_000L)
}
@Test
fun `plan uses the all-day setting and skips what is out of the window`() {
val now = millis(date.minusDays(1).atTime(12, 0), berlin)
val allDay = testTask(id = 1, due = allDayInstantOf(date)).copy(isAllDay = true)
val farAway = testTask(id = 2, due = allDayInstantOf(date.plusDays(60))).copy(isAllDay = true)
val plan = ReminderPlanner.plan(
tasks = listOf(allDay, farAway),
perTask = emptyMap(),
settings = Settings(allDayReminderMinuteOfDay = 7 * 60),
now = now,
zone = berlin,
)
assertThat(plan).containsExactly(ScheduledReminder(1, millis(date.atTime(7, 0), berlin)))
}
@Test
fun `an all-day task takes the all-day defaults, not the timed ones`() {
val task = testTask(id = 6, due = allDayInstantOf(date)).copy(isAllDay = true, listId = 7)
val plan = ReminderPlanner.plan(
tasks = listOf(task),
perTask = emptyMap(),
settings = Settings(
defaultReminderMinutes = listOf(5),
defaultAllDayReminderMinutes = listOf(0, 24 * 60),
),
now = millis(date.minusDays(2).atTime(12, 0), berlin),
zone = berlin,
)
assertThat(plan).containsExactly(
ScheduledReminder(6, millis(date.atTime(9, 0), berlin)),
ScheduledReminder(6, millis(date.minusDays(1).atTime(9, 0), berlin)),
)
}
@Test
fun `a list can override the all-day default`() {
val task = testTask(id = 7, due = allDayInstantOf(date)).copy(isAllDay = true, listId = 8)
val plan = ReminderPlanner.plan(
tasks = listOf(task),
perTask = emptyMap(),
settings = Settings(perListAllDayReminderOverride = mapOf(8L to emptyList())),
now = millis(date.minusDays(2).atTime(12, 0), berlin),
zone = berlin,
)
assertThat(plan).isEmpty()
}
@Test
fun `a start-relative reminder counts back from the start`() {
val start = Instant.fromEpochMilliseconds(millis(date.atTime(10, 0), berlin))
val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin))
val task = testTask(id = 3, due = due).copy(start = start)
val plan = ReminderPlanner.plan(
tasks = listOf(task),
perTask = mapOf(3L to listOf(TaskReminder(minutesBefore = 30, fromStart = true))),
settings = Settings(),
now = millis(date.atTime(0, 0), berlin),
zone = berlin,
)
assertThat(plan).containsExactly(ScheduledReminder(3, millis(date.atTime(9, 30), berlin)))
}
@Test
fun `an all-day due is shown as its own date`() {
val text = formatReminderDue(allDayInstantOf(date).toEpochMilliseconds(), true, newYork, Locale.US)
assertThat(text).isEqualTo("Jul 20, 2026")
}
private val words = RelativeDays(today = "today", tomorrow = "tomorrow", yesterday = "yesterday")
private fun relative(at: LocalDateTime, today: LocalDate = date) =
formatReminderDue(millis(at, berlin), false, berlin, Locale.US, is24Hour = true, relative = words, today = today)
@Test
fun `a due near today reads as a word`() {
assertThat(relative(date.atTime(9, 0))).isEqualTo("today, 09:00")
assertThat(relative(date.plusDays(1).atTime(9, 0))).isEqualTo("tomorrow, 09:00")
assertThat(relative(date.minusDays(1).atTime(9, 0))).isEqualTo("yesterday, 09:00")
}
@Test
fun `a due later this week reads as its weekday`() {
assertThat(relative(date.plusDays(4).atTime(18, 30))).isEqualTo("Friday, 18:30")
}
@Test
fun `a due a week out or further back keeps its date`() {
assertThat(relative(date.plusDays(7).atTime(9, 0))).isEqualTo("Jul 27, 2026, 09:00")
assertThat(relative(date.minusDays(2).atTime(9, 0))).isEqualTo("Jul 18, 2026, 09:00")
}
@Test
fun `an all-day due tomorrow reads as tomorrow in any zone`() {
val text = formatReminderDue(
allDayInstantOf(date.plusDays(1)).toEpochMilliseconds(), true, newYork, Locale.US,
relative = words, today = date,
)
assertThat(text).isEqualTo("tomorrow")
}
@Test
fun `every stored reminder of a task is armed`() {
val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin))
val task = testTask(id = 4, due = due)
val plan = ReminderPlanner.plan(
tasks = listOf(task),
perTask = mapOf(4L to listOf(TaskReminder(0), TaskReminder(60))),
settings = Settings(defaultReminderMinutes = listOf(15)),
now = millis(date.atTime(0, 0), berlin),
zone = berlin,
)
assertThat(plan).containsExactly(
ScheduledReminder(4, millis(date.atTime(18, 0), berlin)),
ScheduledReminder(4, millis(date.atTime(17, 0), berlin)),
)
}
@Test
fun `a task without its own reminders gets every default one`() {
val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin))
val task = testTask(id = 5, due = due)
val plan = ReminderPlanner.plan(
tasks = listOf(task),
perTask = emptyMap(),
settings = Settings(defaultReminderMinutes = listOf(5, 30)),
now = millis(date.atTime(0, 0), berlin),
zone = berlin,
)
assertThat(plan).containsExactly(
ScheduledReminder(5, millis(date.atTime(17, 55), berlin)),
ScheduledReminder(5, millis(date.atTime(17, 30), berlin)),
)
}
@Test
fun `a recurring occurrence carries its anchor`() {
val due = Instant.fromEpochMilliseconds(millis(date.atTime(18, 0), berlin))
val task = testTask(id = 5, due = due).copy(isRecurring = true, occurrenceStart = due)
val plan = ReminderPlanner.plan(listOf(task), emptyMap(), Settings(), millis(date.atTime(0, 0), berlin), berlin)
assertThat(plan.single().occurrenceStart).isEqualTo(due.toEpochMilliseconds())
}
}
@@ -0,0 +1,56 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
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.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
/** The sign-in prompt is posted once per stop, not on every refused run. */
class AccountStateStoreTest {
@TempDir lateinit var directory: File
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
private val dataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.create(scope = scope) { File(directory, "state.preferences_pb") }
}
private val store by lazy { AccountStateStore(dataStore) }
@AfterEach fun tearDown() = scope.cancel()
@Test
fun `a stopped account is announced once`() = runTest {
store.setNeedsSignIn(1, true)
assertThat(store.markSignInNotified(1)).isTrue()
assertThat(store.markSignInNotified(1)).isFalse()
}
@Test
fun `signing in again makes the next stop news again`() = runTest {
store.setNeedsSignIn(1, true)
store.markSignInNotified(1)
store.setNeedsSignIn(1, false)
store.setNeedsSignIn(1, true)
assertThat(store.markSignInNotified(1)).isTrue()
}
@Test
fun `accounts are announced independently`() = runTest {
store.markSignInNotified(1)
assertThat(store.markSignInNotified(2)).isTrue()
}
}
@@ -0,0 +1,70 @@
package de.jeanlucmakiola.agendula.data.sync
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.caldav.TaskCollection
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.jupiter.api.Test
/** Choosing which lists sync after setup. */
class CollectionChangeTest {
private val work = collection("work")
private val home = collection("home")
private val shared = collection("shared")
@Test
fun `a newly ticked collection is attached, an already synced one is not`() {
val change = AccountRepository.collectionChange(
offered = listOf(work, home),
selected = setOf(work.url, home.url),
synced = listOf(list(1, work)),
)
assertThat(change.attach).containsExactly(home)
assertThat(change.drop).isEmpty()
}
@Test
fun `an unticked synced list is dropped`() {
val change = AccountRepository.collectionChange(
offered = listOf(work, home),
selected = setOf(work.url),
synced = listOf(list(1, work), list(2, home)),
)
assertThat(change.attach).isEmpty()
assertThat(change.drop.map { it.id }).containsExactly(2L)
}
@Test
fun `a synced list the server did not offer this time is left alone`() {
// A flaky listing or a share revoked a moment ago is not the user
// unticking it.
val change = AccountRepository.collectionChange(
offered = listOf(work),
selected = setOf(work.url),
synced = listOf(list(1, work), list(3, shared)),
)
assertThat(change.drop).isEmpty()
}
private fun collection(name: String) = TaskCollection(
url = "https://cloud.example.com/dav/$name/".toHttpUrl(),
displayName = name,
color = null,
readOnly = false,
isShared = false,
supportsSyncCollection = true,
maxResourceSize = null,
)
private fun list(id: Long, collection: TaskCollection) = TaskListEntity(
id = id,
name = collection.displayName!!,
color = 0,
accountId = 1,
href = collection.url.toString(),
)
}
@@ -0,0 +1,68 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.caldav.CollectionSupport
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
/** The cache in front of OPTIONS, which exists so a picker can draw at once. */
class CollectionSupportStoreTest {
@TempDir lateinit var directory: File
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
private val dataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.create(scope = scope) { File(directory, "support.preferences_pb") }
}
private val store by lazy { CollectionSupportStore(dataStore) }
@AfterEach fun tearDown() = scope.cancel()
@Test
fun `an account nobody has asked about offers nothing`() = runTest {
// Unknown reads as "no": the cost of a missing affordance is one tap to
// get it back, and the cost of the other error is a 405 at the end of a
// form the user has already filled in.
assertThat(store.get(1)).isEqualTo(CollectionSupport.NONE)
}
@Test
fun `a refreshed answer is the one that comes back`() = runTest {
val answered = store.refresh(1) { CollectionSupport(mkCalendar = false, extendedMkCol = true) }
assertThat(answered.extendedMkCol).isTrue()
assertThat(store.get(1)).isEqualTo(answered)
}
@Test
fun `a server that loses the capability says so on the next ask`() = runTest {
store.refresh(1) { CollectionSupport(mkCalendar = true, extendedMkCol = false) }
store.refresh(1) { CollectionSupport.NONE }
// The whole reason this is a cache rather than the answer: a config
// change on the server has to be able to take the affordance away.
assertThat(store.get(1).canCreate).isFalse()
}
@Test
fun `one account's answer leaves another's alone`() = runTest {
store.refresh(1) { CollectionSupport(mkCalendar = true, extendedMkCol = false) }
store.refresh(2) { CollectionSupport(mkCalendar = false, extendedMkCol = true) }
store.forget(1)
assertThat(store.get(1)).isEqualTo(CollectionSupport.NONE)
assertThat(store.get(2).extendedMkCol).isTrue()
}
}
@@ -0,0 +1,990 @@
package de.jeanlucmakiola.agendula.data.sync
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskMove
import de.jeanlucmakiola.caldav.FetchFailure
import de.jeanlucmakiola.caldav.FetchResult
import de.jeanlucmakiola.caldav.PushSupport
import de.jeanlucmakiola.caldav.PutOutcome
import de.jeanlucmakiola.caldav.RemoteResource
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class CollectionSyncerTest {
private val store = FakeStore()
private val remote = FakeRemote()
private val quarantine = mutableMapOf<String, Int>()
private val list = TaskListEntity(
id = 1,
name = "Tasks",
color = 0,
accountId = 7,
href = "http://server/dav/tasks/",
)
private fun sync(fullDue: Boolean = true) =
CollectionSyncer(store) { NOW }.sync(list, remote, quarantine, fullDue)
// ------------------------------------------------------------------ push
@Test fun `push support read from the collection reaches the report`() {
remote.push = PushSupport(topic = "t1", vapidPublicKey = "key")
val report = sync()
assertThat(report.collectionRead).isTrue()
assertThat(report.pushSupport).isEqualTo(PushSupport(topic = "t1", vapidPublicKey = "key"))
}
@Test fun `an unreadable collection says nothing about push`() {
// Not "no support": a failed PROPFIND must not tear down a working
// subscription.
remote.push = PushSupport(topic = "t1", vapidPublicKey = null)
remote.stateFailure = "503"
val report = sync()
assertThat(report.collectionRead).isFalse()
assertThat(report.pushSupport).isNull()
}
// -------------------------------------------------------------- download
@Test fun `a new remote task is downloaded and is not dirty`() {
remote.put("one.ics", vtodo("a", "Buy milk"))
val report = sync()
val row = store.rows.single()
assertThat(row.uid).isEqualTo("a")
assertThat(row.title).isEqualTo("Buy milk")
assertThat(row.href).isEqualTo("http://server/dav/tasks/one.ics")
assertThat(row.etag).isEqualTo("e-one.ics")
// ⚠️ Explicit, not defaulted. A downstream write that leaves this set
// uploads what was just downloaded, and that is how a sync loop starts.
assertThat(row.isDirty).isFalse()
assertThat(report.downloaded).isEqualTo(1)
}
@Test fun `an unchanged etag is not downloaded again`() {
remote.put("one.ics", vtodo("a", "Buy milk"))
sync()
remote.log.clear()
sync()
assertThat(remote.log).containsExactly("LIST")
}
@Test fun `a changed etag is downloaded`() {
remote.put("one.ics", vtodo("a", "Buy milk"))
sync()
remote.put("one.ics", vtodo("a", "Buy oat milk"), eTag = "e-2")
remote.log.clear()
sync()
assertThat(store.rows.single().title).isEqualTo("Buy oat milk")
assertThat(remote.log).contains("FETCH one.ics")
}
@Test fun `a weak remote etag is never stored`() {
remote.put("one.ics", vtodo("a", "Buy milk"))
remote.resources.values.single().eTag =
de.jeanlucmakiola.caldav.ETag("weak", weak = true)
sync()
// Storing it would make the next conditional write look conditional
// while silently not being one.
assertThat(store.rows.single().etag).isNull()
}
@Test fun `local-only columns survive a download`() {
store.rows += task(id = 5, uid = "a", href = "http://server/dav/tasks/one.ics")
.copy(etag = "old", sortOrder = 42, color = 0xFF00FF00.toInt())
remote.put("one.ics", vtodo("a", "Renamed"), eTag = "new")
sync()
val row = store.rows.single()
assertThat(row.title).isEqualTo("Renamed")
// The server has no opinion about either, so taking the mapper's defaults
// would silently reset the user's ordering and colour on every sync.
assertThat(row.sortOrder).isEqualTo(42)
assertThat(row.color).isEqualTo(0xFF00FF00.toInt())
assertThat(row.id).isEqualTo(5)
}
@Test fun `RELATED-TO is resolved to a row id once both rows exist`() {
// The child arrives first, so an eager resolution would lose the link.
remote.put("child.ics", vtodo("child", "Sub", extra = "RELATED-TO;RELTYPE=PARENT:parent"))
remote.put("parent.ics", vtodo("parent", "Top"))
sync()
val parent = store.rows.single { it.uid == "parent" }
val child = store.rows.single { it.uid == "child" }
assertThat(child.parentId).isEqualTo(parent.id)
}
@Test fun `an unreadable body quarantines just that resource`() {
remote.put("bad.ics", "this is not iCalendar")
remote.put("good.ics", vtodo("a", "Fine"))
val report = sync()
assertThat(store.rows.map { it.uid }).containsExactly("a")
assertThat(report.quarantined.single().href).endsWith("bad.ics")
assertThat(quarantine.values.single()).isEqualTo(1)
}
// ---------------------------------------------------------------- upload
@Test fun `a new local task is created and its href recorded`() {
store.rows += task(id = 1, uid = "a", title = "Write it down").copy(isDirty = true)
val report = sync()
assertThat(remote.resources.keys.single()).endsWith("/a.ics")
val row = store.rows.single()
assertThat(row.href).isEqualTo("http://server/dav/tasks/a.ics")
assertThat(row.etag).isEqualTo("e-a.ics")
assertThat(row.isDirty).isFalse()
assertThat(report.uploaded).isEqualTo(1)
}
@Test fun `an edited task is updated with If-Match`() {
remote.put("one.ics", vtodo("a", "Old"))
store.rows += task(id = 1, uid = "a", title = "New")
.copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics", isDirty = true)
sync()
assertThat(remote.log).contains("UPDATE one.ics if-match=e-one.ics")
assertThat(remote.resources.values.single().body).contains("SUMMARY:New")
}
@Test fun `a failed validator fetch defers the update instead of writing blind`() {
remote.put("one.ics", vtodo("a", "Server"))
// No local etag: what StoredNeedsRefetch, and any server that serves none,
// leave behind.
store.rows += task(id = 1, uid = "a", title = "Mine")
.copy(href = "http://server/dav/tasks/one.ics", isDirty = true)
var failNext = true
remote.onFetch = {
if (failNext) {
failNext = false
Result.failure(IllegalStateException("timeout"))
} else {
null
}
}
val report = sync()
// Not knowing the validator is not the same as the server having none.
assertThat(remote.log.none { it.startsWith("UPDATE") }).isTrue()
assertThat(remote.resources.values.single().body).contains("SUMMARY:Server")
assertThat(report.unconditionalWrites).isEqualTo(0)
val row = store.rows.single()
assertThat(row.title).isEqualTo("Mine")
assertThat(row.isDirty).isTrue()
assertThat(quarantine).isEmpty()
assertThat(report.quarantined.single().failures).isEqualTo(0)
assertThat(report.quarantined.single().reason).contains("validator fetch failed")
remote.onFetch = null
sync()
assertThat(remote.log).contains("UPDATE one.ics if-match=e-one.ics")
assertThat(remote.resources.values.single().body).contains("SUMMARY:Mine")
assertThat(store.rows.single().isDirty).isFalse()
assertThat(quarantine).isEmpty()
}
@Test fun `a server with no validator to offer is still written to`() {
remote.put("one.ics", vtodo("a", "Server"), eTag = null)
store.rows += task(id = 1, uid = "a", title = "Mine")
.copy(href = "http://server/dav/tasks/one.ics", isDirty = true)
val report = sync()
// The fetch succeeded and there was simply no validator — the case
// unconditionalWrites exists to count. Deferring here instead would stop
// syncing to every etag-less server.
assertThat(remote.log).contains("UPDATE one.ics if-match=null")
assertThat(report.unconditionalWrites).isEqualTo(1)
assertThat(remote.resources.values.single().body).contains("SUMMARY:Mine")
}
@Test fun `a PUT that returns no usable etag triggers a refetch`() {
store.rows += task(id = 1, uid = "a", title = "New").copy(isDirty = true)
remote.onPut = { href -> PutOutcome.StoredNeedsRefetch(href) }
sync()
// Not an error: the resource is on the server, but without a usable
// validator and possibly not as the bytes we sent.
assertThat(remote.log).contains("FETCH a.ics")
assertThat(store.rows.single().etag).isNull()
}
@Test fun `a rejected upload quarantines and leaves the row dirty`() {
store.rows += task(id = 1, uid = "a", title = "Bad").copy(isDirty = true)
remote.onPut = { PutOutcome.Rejected(415, "Unsupported Media Type") }
val report = sync()
assertThat(report.quarantined.single().reason).contains("415")
// Retrying identical bytes cannot help, but the edit is not thrown away.
assertThat(store.rows.single().isDirty).isTrue()
}
@Test fun `a quarantined resource is skipped once it hits the threshold`() {
remote.put("one.ics", vtodo("a", "Server"))
store.rows += task(id = 1, uid = "a", title = "Mine")
.copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics", isDirty = true)
remote.onPut = { PutOutcome.Rejected(400, "Bad Request") }
repeat(QuarantineStore.THRESHOLD) { sync() }
remote.log.clear()
sync()
// ⚠️ A single 400 on one resource has halted all calendar sync in DAVx5
// for weeks. After the threshold this one is left alone and the rest of
// the collection still runs.
assertThat(remote.log).containsExactly("LIST")
}
@Test fun `an invalid body never leaves the device`() {
store.rows += task(id = 1, uid = "a", title = "Backwards").copy(
isDirty = true,
dtstart = Instant.parse("2026-03-01T10:00:00Z"),
due = Instant.parse("2026-03-01T09:00:00Z"),
)
val report = sync()
assertThat(remote.log).containsExactly("LIST")
assertThat(report.quarantined.single().reason).contains("DUE precedes DTSTART")
}
// -------------------------------------------------------------- conflict
@Test fun `a 412 on update discards the local edit and reports it`() {
remote.put("one.ics", vtodo("a", "Server won"), eTag = "newer")
store.rows += task(id = 1, uid = "a", title = "Mine")
.copy(href = "http://server/dav/tasks/one.ics", etag = "stale", isDirty = true)
val report = sync()
// Decision 2: server wins. The report is the other half of that policy —
// without it this is indistinguishable from data loss.
assertThat(report.discardedEdits.single().cause)
.isEqualTo(DiscardedEdit.Cause.SERVER_NEWER)
assertThat(report.discardedEdits.single().title).isEqualTo("Mine")
assertThat(store.rows.single().title).isEqualTo("Server won")
assertThat(store.rows.single().isDirty).isFalse()
}
@Test fun `a lost 412 conflict keeps the edit when the replacement never arrives`() {
remote.put("one.ics", vtodo("a", "Server won"), eTag = "newer")
store.rows += task(id = 1, uid = "a", title = "Mine")
.copy(href = "http://server/dav/tasks/one.ics", etag = "stale", isDirty = true)
// The 412 lands, then the replacement download dies.
remote.resources["http://server/dav/tasks/one.ics"]!!.body = "not iCalendar"
val report = sync()
// Nothing was replaced, so nothing was discarded — and the edit is still
// there to try again with.
assertThat(report.discardedEdits).isEmpty()
assertThat(store.rows.single().title).isEqualTo("Mine")
assertThat(store.rows.single().isDirty).isTrue()
}
@Test fun `an edit to a task deleted on the server loses`() {
store.rows += task(id = 1, uid = "a", title = "Mine")
.copy(href = "http://server/dav/tasks/gone.ics", etag = "e", isDirty = true)
val report = sync()
assertThat(store.rows).isEmpty()
assertThat(report.discardedEdits.single().cause)
.isEqualTo(DiscardedEdit.Cause.DELETED_ON_SERVER)
}
@Test fun `a delete that loses to a server edit is undone`() {
remote.put("one.ics", vtodo("a", "Server changed it"), eTag = "newer")
store.rows += task(id = 1, uid = "a", title = "Mine").copy(
href = "http://server/dav/tasks/one.ics",
etag = "stale",
isDeleted = true,
isDirty = true,
)
val report = sync()
assertThat(report.discardedEdits.single().cause)
.isEqualTo(DiscardedEdit.Cause.DELETE_LOST)
val row = store.rows.single()
assertThat(row.isDeleted).isFalse()
assertThat(row.title).isEqualTo("Server changed it")
}
// -------------------------------------------------------------- deletion
@Test fun `a tombstone with an href is deleted on the server`() {
remote.put("one.ics", vtodo("a", "Gone"))
store.rows += task(id = 1, uid = "a")
.copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics", isDeleted = true)
val report = sync()
assertThat(remote.resources).isEmpty()
assertThat(store.rows).isEmpty()
assertThat(report.deletedRemotely).isEqualTo(1)
}
@Test fun `a task deleted before it was ever uploaded is never DELETEd`() {
store.rows += task(id = 1, uid = "a").copy(isDeleted = true, isDirty = true)
sync()
// A DELETE here would 404 on every sync, forever.
assertThat(remote.log).containsExactly("LIST")
assertThat(store.rows).isEmpty()
}
@Test fun `a deleted occurrence is dropped from the store once the PUT lands`() {
remote.put("one.ics", vtodo("a", "Weekly"))
store.rows += task(id = 1, uid = "a", title = "Weekly")
.copy(
href = "http://server/dav/tasks/one.ics",
etag = "e-one.ics",
rrule = "FREQ=WEEKLY",
)
store.rows += task(id = 2, uid = "a", title = "Weekly")
.copy(
href = "http://server/dav/tasks/one.ics",
etag = "e-one.ics",
masterId = 1,
recurrenceId = NOW,
isDeleted = true,
isDirty = true,
)
sync()
// The occurrence left the body, so the PUT is its deletion. Marking the
// tombstone synced instead strands is_deleted = 1 with is_dirty = 0,
// which no phase can reach and which the unique index on
// (list_id, uid, recurrence_id) turns into a permanent failure to re-add.
assertThat(remote.log.any { it.startsWith("UPDATE one.ics") }).isTrue()
assertThat(remote.log.none { it.startsWith("DELETE") }).isTrue()
assertThat(store.rows.none { it.isDeleted }).isTrue()
assertThat(store.rows.map { it.id }).containsExactly(1L)
// FakeStore models neither the unique index on (list_id, uid,
// recurrence_id) nor the master_id cascade, so the permanent-failure-to-
// re-add consequence is reasoned about, not asserted here.
assertThat(store.rows.single().isDirty).isFalse()
}
@Test fun `a tombstoned master with a live override keeps its row`() {
remote.put("one.ics", vtodo("a", "Series"))
store.rows += task(id = 1, uid = "a", title = "Series").copy(
href = "http://server/dav/tasks/one.ics",
etag = "e-one.ics",
isDeleted = true,
isDirty = true,
)
store.rows += task(id = 2, uid = "a", title = "Series").copy(
href = "http://server/dav/tasks/one.ics",
etag = "e-one.ics",
masterId = 1,
recurrenceId = NOW,
)
sync()
// ⚠️ The shape an older version of markDeleted left behind: the master
// tombstoned, its overrides live. Read as rows.all it was "partly
// deleted", so it went to the upload phase, no DELETE was ever sent, and
// it ended is_deleted = 1, is_dirty = 0 with a matching ETag — beyond
// every phase's reach, gone here and still there for everyone else. The
// master's tombstone is the resource's, so the next sync finishes what
// the user asked for.
assertThat(remote.log).contains("DELETE one.ics if-match=e-one.ics")
assertThat(store.rows).isEmpty()
}
@Test fun `the sweep does not remove what this run just created`() {
store.rows += task(id = 1, uid = "a", title = "Fresh").copy(isDirty = true)
sync()
// The listing was taken before the upload, so the new href is not in it.
assertThat(store.rows).hasSize(1)
assertThat(remote.resources).hasSize(1)
}
@Test fun `an empty listing never sweeps`() {
store.rows += task(id = 1, uid = "a", title = "Mine")
.copy(href = "http://server/dav/tasks/one.ics", etag = "e")
store.rows += task(id = 2, uid = "b", title = "Also mine")
.copy(href = "http://server/dav/tasks/two.ics", etag = "e")
val report = sync()
// ⚠️ A server that mishandles the VTODO comp-filter answers with an empty
// *successful* multistatus, which is indistinguishable from an empty
// collection. Sweeping on that evidence hard-deletes every task at once.
assertThat(store.rows).hasSize(2)
assertThat(report.deletedLocally).isEqualTo(0)
assertThat(report.failure).contains("listed no tasks")
}
@Test fun `a resource missing from a non-empty listing is swept`() {
remote.put("one.ics", vtodo("a", "Still there"))
store.rows += task(id = 1, uid = "a")
.copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics")
store.rows += task(id = 2, uid = "b")
.copy(href = "http://server/dav/tasks/two.ics", etag = "e")
val report = sync()
assertThat(store.rows.map { it.uid }).containsExactly("a")
assertThat(report.deletedLocally).isEqualTo(1)
}
@Test fun `a task recreated under a new href is repointed, not swept`() {
// Another client deleted one.ics and recreated the same UID as two.ics.
remote.put("two.ics", vtodo("a", "Buy milk"))
store.rows += task(id = 5, uid = "a", title = "Buy milk")
.copy(
href = "http://server/dav/tasks/one.ics",
etag = "e-one.ics",
sortOrder = 42,
color = 0xFF0000,
)
val report = sync()
// The download re-points row 5; a sweep reading the pre-download list
// would purge it by id and lose the local-only columns for good.
val row = store.rows.single()
assertThat(row.id).isEqualTo(5)
assertThat(row.href).isEqualTo("http://server/dav/tasks/two.ics")
assertThat(row.sortOrder).isEqualTo(42)
assertThat(row.color).isEqualTo(0xFF0000)
assertThat(report.deletedLocally).isEqualTo(0)
assertThat(report.downloaded).isEqualTo(1)
}
@Test fun `the post-download sweep still removes a stale row`() {
remote.put("one.ics", vtodo("a", "New"))
store.rows += task(id = 1, uid = "b")
.copy(href = "http://server/dav/tasks/gone.ics", etag = "e")
val report = sync()
// Reading locals after the download widens what the sweep sees; the row
// just inserted must survive it, the stale one must not.
assertThat(store.rows.map { it.uid }).containsExactly("a")
assertThat(report.deletedLocally).isEqualTo(1)
}
@Test fun `a resource the server refuses is counted, and the batch survives`() {
remote.put("one.ics", vtodo("a", "Fine"))
remote.put("two.ics", vtodo("b", "Refused"))
remote.onFetch = { hrefs -> refuseTwoIcs(hrefs, code = 403) }
val report = sync()
// A refusal on one member says nothing about the others.
assertThat(store.rows.map { it.uid }).containsExactly("a")
assertThat(quarantine.values.single()).isEqualTo(1)
assertThat(report.quarantined.single().failures).isEqualTo(1)
assertThat(report.quarantined.single().reason).contains("403")
}
@Test fun `a resource the server says is gone is not counted`() {
remote.put("one.ics", vtodo("a", "Fine"))
remote.put("two.ics", vtodo("b", "Gone"))
remote.onFetch = { hrefs -> refuseTwoIcs(hrefs, code = 404) }
val report = sync()
// Counting it would quarantine the resource out of the sweep that is
// about to clean it up once the listing drops it.
assertThat(quarantine).isEmpty()
assertThat(report.quarantined.single().failures).isEqualTo(0)
}
@Test fun `a resource the server fails on is not counted`() {
remote.put("one.ics", vtodo("a", "Fine"))
remote.put("two.ics", vtodo("b", "Broken"))
remote.onFetch = { hrefs -> refuseTwoIcs(hrefs, code = 500) }
val report = sync()
// A multiget carries no body of ours, so there is nothing here a server
// could be permanently right to reject.
assertThat(quarantine).isEmpty()
assertThat(report.quarantined.single().failures).isEqualTo(0)
}
@Test fun `a missing href is not counted when the server also sent strays`() {
remote.put("one.ics", vtodo("a", "Fine"))
remote.onFetch = { hrefs ->
Result.success(
FetchResult(
resources = emptyList(),
missing = hrefs,
failed = emptyList(),
// The shape an href we failed to recognise takes: the same
// path, in both buckets.
unsolicited = listOf("http://elsewhere/dav/tasks/one.ics".toHttpUrl()),
),
)
}
val report = sync()
// ⚠️ Counting here is irreversible — at THRESHOLD the href is stripped
// before the fetch, so nothing can ever clear it again.
assertThat(quarantine).isEmpty()
assertThat(report.quarantined.single().failures).isEqualTo(0)
}
@Test fun `a stray for another path does not excuse a missing one`() {
remote.put("one.ics", vtodo("a", "Fine"))
remote.onFetch = { hrefs ->
Result.success(
FetchResult(
resources = emptyList(),
missing = hrefs,
failed = emptyList(),
// Servers volunteer siblings routinely. Excusing every
// missing href on that basis would mean nothing is ever
// counted, and the loop never breaks.
unsolicited = listOf("http://server/dav/tasks/somebody-else.ics".toHttpUrl()),
),
)
}
val report = sync()
assertThat(quarantine.values.single()).isEqualTo(1)
assertThat(report.quarantined.single().failures).isEqualTo(1)
}
@Test fun `a missing href is still counted when nothing was unmatched`() {
remote.put("one.ics", vtodo("a", "Fine"))
remote.onFetch = { hrefs ->
Result.success(
FetchResult(
resources = emptyList(),
missing = hrefs,
failed = emptyList(),
unsolicited = emptyList(),
),
)
}
val report = sync()
// A plain omission repeats identically every run and nothing else breaks
// the loop, which is what the counter is for.
assertThat(quarantine.values.single()).isEqualTo(1)
assertThat(report.quarantined.single().failures).isEqualTo(1)
}
@Test fun `a resource the server keeps refusing stops being requested`() {
remote.put("two.ics", vtodo("b", "Refused"))
remote.onFetch = { hrefs -> refuseTwoIcs(hrefs, code = 403) }
repeat(QuarantineStore.THRESHOLD) { sync() }
remote.log.clear()
sync(fullDue = false)
// The whole point. It is in the listing, so the sweep leaves it alone,
// and it never reaches apply, so without a count it is re-requested on
// every sync for ever. The daily reconciliation still probes it once —
// otherwise nothing on this side could ever clear the count.
assertThat(remote.log).containsExactly("LIST")
}
@Test fun `a create the server keeps refusing is eventually quarantined`() {
store.rows += task(id = 1, uid = "a", title = "Never acceptable").copy(isDirty = true)
remote.onPut = { PutOutcome.Rejected(415, "Unsupported Media Type") }
repeat(QuarantineStore.THRESHOLD) { sync() }
remote.log.clear()
sync()
// A resource with no href yet still has to be countable, or it is re-PUT
// on every sync forever.
assertThat(remote.log).containsExactly("LIST")
assertThat(quarantine.keys.single()).isEqualTo("${list.id}|uid:a")
}
@Test fun `a successful create clears the counter it accrued as a uid`() {
store.rows += task(id = 1, uid = "a", title = "Flaky").copy(isDirty = true)
remote.onPut = { PutOutcome.Failed("network") }
sync()
assertThat(quarantine).isNotEmpty()
remote.onPut = null
sync()
// Both keys, or the uid-keyed one leaks into the store forever.
assertThat(quarantine).isEmpty()
}
@Test fun `a RELATED-TO removed on the server unparents the task`() {
remote.put("parent.ics", vtodo("parent", "Top"))
remote.put("child.ics", vtodo("child", "Sub", extra = "RELATED-TO;RELTYPE=PARENT:parent"))
sync()
assertThat(store.rows.single { it.uid == "child" }.parentId).isNotNull()
remote.put("child.ics", vtodo("child", "Sub"), eTag = "e-2")
sync()
// Carrying the old parent_id forward would re-upload, on the next local
// edit, the relationship the user deleted elsewhere.
assertThat(store.rows.single { it.uid == "child" }.parentId).isNull()
}
// ---------------------------------------------------------- name clashes
@Test fun `a name taken by another task gets a fresh one`() {
remote.put("a.ics", vtodo("somebody-else", "Not ours"))
store.rows += task(id = 1, uid = "a", title = "Ours").copy(isDirty = true)
sync()
val ours = store.rows.single { it.uid == "a" }
assertThat(ours.href).isNotEqualTo("http://server/dav/tasks/a.ics")
assertThat(ours.href).endsWith(".ics")
assertThat(ours.isDirty).isFalse()
}
@Test fun `a name taken by the same task is adopted`() {
// A previous run's PUT whose answer we never saw.
remote.put("a.ics", vtodo("a", "Uploaded last time"))
store.rows += task(id = 1, uid = "a", title = "Ours").copy(isDirty = true)
sync()
assertThat(remote.resources).hasSize(1)
assertThat(store.rows.single().href).isEqualTo("http://server/dav/tasks/a.ics")
}
@Test fun `a create whose verification fetch fails is not duplicated`() {
// Our own PUT from a run whose answer never arrived, and an edit the user
// made afterwards that only exists here.
remote.put("a.ics", vtodo("a", "Uploaded last time"))
store.rows += task(id = 1, uid = "a", title = "Edited after the lost PUT")
.copy(isDirty = true)
// Only the verification fetch fails. A blanket failure would take the
// download phase's multiget with it and pass for the wrong reason.
var failNext = true
remote.onFetch = {
if (failNext) {
failNext = false
Result.failure(IllegalStateException("timeout"))
} else {
null
}
}
val report = sync()
// Renaming on a failed fetch would put one UID in two resources, which
// RFC 4791 §4.1 forbids and which no later sync can clean up.
assertThat(remote.resources).hasSize(1)
assertThat(remote.log.count { it.startsWith("CREATE") }).isEqualTo(1)
// Deferring the write is only worth anything if the read defers too: the
// row has no href, so downloadPhase cannot match it and would otherwise
// overwrite the edit by UID with nothing in discardedEdits.
val row = store.rows.single()
assertThat(row.title).isEqualTo("Edited after the lost PUT")
assertThat(row.isDirty).isTrue()
assertThat(row.href).isNull()
// Deferred, not charged: nothing can refund a UID-keyed count once the
// resource is quarantined out of uploadPhase.
assertThat(quarantine).isEmpty()
assertThat(report.quarantined.single().reason).contains("verification failed")
assertThat(report.quarantined.single().failures).isEqualTo(0)
// And the next run adopts the resource.
remote.onFetch = null
sync()
assertThat(remote.resources).hasSize(1)
val adopted = store.rows.single()
assertThat(adopted.href).isEqualTo("http://server/dav/tasks/a.ics")
assertThat(adopted.title).isEqualTo("Edited after the lost PUT")
assertThat(quarantine).isEmpty()
}
@Test fun `an adoption whose validator fetch fails keeps the local edit`() {
// Create 412s, the first fetch proves the resource is ours, and the
// validator fetch inside the adoption then fails.
remote.put("a.ics", vtodo("a", "Uploaded last time"))
store.rows += task(id = 1, uid = "a", title = "Edited after the lost PUT")
.copy(isDirty = true)
var fetches = 0
remote.onFetch = {
fetches += 1
if (fetches == 2) Result.failure(IllegalStateException("timeout")) else null
}
sync()
// The row still has no href here, so downloadPhase cannot match it and
// only `deferred` keeps apply from overwriting the edit by UID. This is
// the call site that makes updateResource's `deferred` load-bearing.
assertThat(remote.resources).hasSize(1)
val row = store.rows.single()
assertThat(row.title).isEqualTo("Edited after the lost PUT")
assertThat(row.isDirty).isTrue()
assertThat(row.href).isNull()
}
@Test fun `a refused verification fetch does not rename the create`() {
remote.put("a.ics", vtodo("a", "Uploaded last time"))
store.rows += task(id = 1, uid = "a", title = "Edited after the lost PUT")
.copy(isDirty = true)
// A per-resource refusal is a *successful* multiget. Without inspecting
// `failed`, the empty resources list reads as somebody else's resource.
remote.onFetch = { hrefs ->
Result.success(
FetchResult(
resources = emptyList(),
missing = emptyList(),
failed = hrefs.map { FetchFailure(it, 403) },
unsolicited = emptyList(),
),
)
}
sync()
assertThat(remote.resources).hasSize(1)
assertThat(remote.log.count { it.startsWith("CREATE") }).isEqualTo(1)
val row = store.rows.single()
assertThat(row.title).isEqualTo("Edited after the lost PUT")
assertThat(row.isDirty).isTrue()
}
@Test fun `a refused validator fetch does not write unconditionally`() {
remote.put("one.ics", vtodo("a", "Server"))
store.rows += task(id = 1, uid = "a", title = "Mine")
.copy(href = "http://server/dav/tasks/one.ics", isDirty = true)
remote.onFetch = { hrefs ->
Result.success(
FetchResult(
resources = emptyList(),
missing = emptyList(),
failed = hrefs.map { FetchFailure(it, 500) },
unsolicited = emptyList(),
),
)
}
val report = sync()
assertThat(remote.log.none { it.startsWith("UPDATE") }).isTrue()
assertThat(report.unconditionalWrites).isEqualTo(0)
assertThat(remote.resources.values.single().body).contains("SUMMARY:Server")
assertThat(store.rows.single().isDirty).isTrue()
}
// ------------------------------------------------------------ protection
@Test fun `a read-only collection is never written to`() {
remote.readOnly = true
store.rows += task(id = 1, uid = "a", title = "Mine").copy(isDirty = true)
val report = sync()
assertThat(remote.log).containsExactly("LIST")
assertThat(report.quarantined.single().reason).contains("read-only")
// ⚠️ ACL churn is silent, so the refreshed flag is written back.
assertThat(store.readOnlyWrites.single()).isEqualTo(list.id to true)
}
@Test fun `a confidential task in a shared collection is not written back`() {
remote.shared = true
remote.put("one.ics", vtodo("a", "Secret"))
store.rows += task(id = 1, uid = "a", title = "Secret").copy(
href = "http://server/dav/tasks/one.ics",
etag = "e-one.ics",
classification = 2,
isDirty = true,
)
val report = sync()
// ⚠️ Nextcloud's CalendarObject::get() serves a whitelist-reduced copy of
// a confidential object from a share while leaving the ETag untouched.
// Writing that back destroys the owner's task, and the ETag matches.
assertThat(remote.log).containsExactly("LIST")
assertThat(report.quarantined.single().reason).contains("reduced copy")
}
@Test fun `a confidential task the user owns is written normally`() {
remote.shared = false
remote.put("one.ics", vtodo("a", "Secret"))
store.rows += task(id = 1, uid = "a", title = "Secret").copy(
href = "http://server/dav/tasks/one.ics",
etag = "e-one.ics",
classification = 2,
isDirty = true,
)
sync()
assertThat(remote.log).contains("UPDATE one.ics if-match=e-one.ics")
}
// -------------------------------------------------------------- failures
@Test fun `a collection that cannot be listed reports rather than throws`() {
remote.listFailure = "boom"
val report = sync()
assertThat(report.failure).contains("boom")
}
@Test fun `a collection that is no longer readable reports rather than throws`() {
remote.stateFailure = "revoked"
val report = sync()
assertThat(report.failure).contains("revoked")
}
@Test fun `a rename into a name another task took keeps both rows`() {
store.rows += task(id = 1, uid = "a", title = "Moved")
.copy(href = "http://server/dav/tasks/one.ics", etag = "e-old")
// Fetched in insertion order, so the rename is applied first and leaves
// the index entry for `one.ics` behind.
remote.put("two.ics", vtodo("a", "Moved"))
remote.put("one.ics", vtodo("b", "A different task at the old name"))
sync()
// ⚠️ `one.ics` now holds somebody else, and row 1 no longer lives there
// — reading it out of a stale index entry deletes the task the rename
// just repaired.
assertThat(store.rows.map { it.uid }).containsExactly("a", "b")
}
@Test fun `a full reconciliation probes a resource the download gave up on`() {
remote.put("one.ics", vtodo("a", "Buy milk"))
quarantine[QuarantineStore.key(list.id, "http://server/dav/tasks/one.ics")] =
QuarantineStore.THRESHOLD
sync()
// ⚠️ The download side has no refund — at THRESHOLD the href is stripped
// before the fetch, so nothing can ever clear the count again. One probe
// per periodic reconciliation is the expiry.
assertThat(store.rows.single().uid).isEqualTo("a")
assertThat(quarantine).isEmpty()
}
// ----------------------------------------------------------------- setup
/** A multiget in which `two.ics` alone comes back refused. */
private fun refuseTwoIcs(hrefs: List<HttpUrl>, code: Int): Result<FetchResult> {
val refused = hrefs.filter { it.pathSegments.last() == "two.ics" }
val rest = hrefs - refused.toSet()
val served = remote.resources.filterKeys { key -> rest.any { it.toString() == key } }
return Result.success(
FetchResult(
resources = served.map { (href, stored) ->
RemoteResource(
href = href.toHttpUrl(),
eTag = stored.eTag,
iCalendar = stored.body,
)
},
missing = emptyList(),
failed = refused.map { FetchFailure(it, code) },
unsolicited = emptyList(),
),
)
}
// ------------------------------------------------------------------ move
@Test fun `a task moved to another collection leaves the old one and is created in the new`() {
remote.put("one.ics", vtodo("a", "Milk"))
sync()
val row = store.rows.single()
val other = TaskListEntity(id = 2, name = "Other", color = 0, accountId = 7, href = "http://server/dav/other/")
val otherRemote = FakeRemote("http://server/dav/other/".toHttpUrl())
val plan = TaskMove.plan(row.copy(listId = other.id), row, emptyList(), true, null)
plan.update.forEach(store::update)
plan.tombstone?.let(store::insert)
val syncer = CollectionSyncer(store) { NOW }
syncer.sync(list, remote, quarantine)
syncer.sync(other, otherRemote, quarantine)
// ⚠️ Keeping the href would have PUT the edit back into the old
// collection, and the new one would never have heard of it.
assertThat(remote.log).contains("DELETE one.ics if-match=e-one.ics")
assertThat(remote.resources).isEmpty()
assertThat(otherRemote.resources).hasSize(1)
val moved = store.rows.single()
assertThat(moved.id).isEqualTo(row.id)
assertThat(moved.listId).isEqualTo(other.id)
assertThat(moved.href).startsWith("http://server/dav/other/")
assertThat(moved.isDirty).isFalse()
}
@Test fun `a move that lost to a server edit brings the server's copy back`() {
remote.put("one.ics", vtodo("a", "Milk"))
sync()
val row = store.rows.single()
val plan = TaskMove.plan(row.copy(listId = 2), row, emptyList(), true, null)
plan.update.forEach(store::update)
plan.tombstone?.let(store::insert)
remote.put("one.ics", vtodo("a", "Oat milk"), eTag = "e-2")
val report = sync()
// The DELETE is conditional, so a newer copy on the server survives and
// is downloaded again rather than destroyed.
assertThat(remote.resources).hasSize(1)
assertThat(store.rowsIn(list.id).single().title).isEqualTo("Oat milk")
assertThat(report.discardedEdits.single().cause).isEqualTo(DiscardedEdit.Cause.DELETE_LOST)
}
private fun task(
id: Long,
uid: String,
title: String? = null,
href: String? = null,
) = TaskEntity(id = id, listId = list.id, uid = uid, title = title, href = href)
private fun vtodo(uid: String, summary: String, extra: String? = null) = buildString {
append("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\n")
append("UID:$uid\r\nSUMMARY:$summary\r\n")
extra?.let { append("$it\r\n") }
append("END:VTODO\r\nEND:VCALENDAR\r\n")
}
private companion object {
val NOW: Instant = Instant.parse("2026-03-01T12:00:00Z")
}
}
@@ -0,0 +1,376 @@
package de.jeanlucmakiola.agendula.data.sync
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.data.tasks.room.TaskListEntity
import de.jeanlucmakiola.caldav.ChangeSet
import de.jeanlucmakiola.caldav.ETag
import de.jeanlucmakiola.caldav.RemoteRef
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.jupiter.api.Test
import kotlin.time.Instant
/** RFC 6578, from the engine's side. */
class IncrementalSyncTest {
private val store = FakeStore()
private val remote = FakeRemote()
private val quarantine = mutableMapOf<String, Int>()
private var list = TaskListEntity(
id = 1,
name = "Tasks",
color = 0,
accountId = 7,
href = "http://server/dav/tasks/",
syncToken = "urn:x:1",
)
init {
remote.supportsSyncCollection = true
remote.syncToken = "urn:x:from-propfind"
}
private fun sync(fullDue: Boolean = false) =
CollectionSyncer(store) { NOW }.sync(list, remote, quarantine, fullDue)
// ------------------------------------------------------------ the cursor
@Test fun `the token is stored only after its bodies are applied`() {
remote.put("one.ics", vtodo("a", "New"))
remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2")
sync()
assertThat(store.rows.single().title).isEqualTo("New")
assertThat(store.tokenWrites).containsExactly("urn:x:2")
// The full path was not needed, so no listing was taken.
assertThat(remote.log).doesNotContain("LIST")
}
@Test fun `a truncated page is resumed and each page commits its own token`() {
remote.put("one.ics", vtodo("a", "First"))
remote.put("two.ics", vtodo("b", "Second"))
remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2", truncated = true)
remote.changePages += page(changed = listOf("two.ics"), token = "urn:x:3")
sync()
assertThat(store.rows.map { it.uid }).containsExactly("a", "b")
// ⚠️ Per page, in order. A token written ahead of its bodies is a
// permanent hole in the collection the moment the process dies.
assertThat(store.tokenWrites).containsExactly("urn:x:2", "urn:x:3").inOrder()
}
@Test fun `a server that truncates without advancing its token is not looped on`() {
remote.changePages += page(changed = emptyList(), token = "urn:x:1", truncated = true)
val report = sync()
// The RFC never requires the token to advance. The full path then
// reconciled the collection, so this is a note rather than a failure.
assertThat(report.incrementalNote).contains("without advancing")
assertThat(report.failure).isNull()
assertThat(remote.log.count { it.startsWith("CHANGES") }).isEqualTo(1)
}
@Test fun `a page with no token at all falls back to a full reconciliation`() {
remote.put("one.ics", vtodo("a", "New"))
remote.changePages += page(changed = listOf("one.ics"), token = null)
val report = sync()
assertThat(store.rows.single().uid).isEqualTo("a")
assertThat(report.reconciledInFull).isTrue()
assertThat(remote.log).contains("LIST")
}
// ------------------------------------------------------- invalidation
@Test fun `an invalidated token is cleared and the run reconciles in full`() {
remote.put("one.ics", vtodo("a", "From the listing"))
remote.changePages += ChangeSet.TokenInvalid
val report = sync()
// Cleared first, so a crash before the full run cannot leave behind a
// token we already know the server rejects.
assertThat(store.tokenWrites.first()).isNull()
assertThat(report.reconciledInFull).isTrue()
assertThat(store.rows.single().uid).isEqualTo("a")
// Adopted from the PROPFIND that preceded the listing, never after it.
assertThat(store.tokenWrites.last()).isEqualTo("urn:x:from-propfind")
}
@Test fun `an unsupported report falls back without clearing the token`() {
remote.put("one.ics", vtodo("a", "From the listing"))
remote.changePages += ChangeSet.Unsupported
val report = sync()
assertThat(report.reconciledInFull).isTrue()
assertThat(store.tokenWrites).doesNotContain(null)
}
// ---------------------------------------------------------- membership
@Test fun `a removal for an href we never had is a no-op`() {
remote.changePages += page(removed = listOf("stranger.ics"), token = "urn:x:2")
val report = sync()
// Created and deleted between two syncs: reported as removed without ever
// having been reported as added.
assertThat(report.failure).isNull()
assertThat(report.deletedLocally).isEqualTo(0)
}
@Test fun `a removal we do know about deletes the rows`() {
store.rows += task(id = 1, uid = "a")
.copy(href = "http://server/dav/tasks/one.ics", etag = "e")
remote.changePages += page(removed = listOf("one.ics"), token = "urn:x:2")
val report = sync()
assertThat(store.rows).isEmpty()
assertThat(report.deletedLocally).isEqualTo(1)
}
@Test fun `delete-then-recreate at the same href replaces the old task`() {
store.rows += task(id = 1, uid = "old")
.copy(href = "http://server/dav/tasks/one.ics", etag = "e-old")
remote.put("one.ics", vtodo("new", "A different task"), eTag = "e-new")
remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2")
sync()
// ⚠️ Reported as a *change*, not as a removal plus an addition — so the
// UID in the body is the only thing that says the old task is gone.
assertThat(store.rows.map { it.uid }).containsExactly("new")
}
@Test fun `a mass removal is refused and reconciled against a listing instead`() {
(1..20).forEach { n ->
store.rows += task(id = n.toLong(), uid = "u$n")
.copy(href = "http://server/dav/tasks/$n.ics", etag = "e")
remote.put("$n.ics", vtodo("u$n", "Task $n"))
}
remote.changePages += page(removed = (1..20).map { "$it.ics" }, token = "urn:x:2")
val report = sync()
// ⚠️ ACL churn arrives looking exactly like this. Acting on it deletes
// the user's data on the strength of a change log.
assertThat(report.incrementalNote).contains("removed 20 of 20")
assertThat(report.reconciledInFull).isTrue()
assertThat(store.rows).hasSize(20)
}
@Test fun `a delete-and-recreate in one page keeps the row's local columns`() {
store.rows += task(id = 1, uid = "a", title = "Old")
.copy(
href = "http://server/dav/tasks/one.ics",
etag = "e-old",
sortOrder = 5,
color = 0x00FF00,
)
remote.put("two.ics", vtodo("a", "Same task, new file"), eTag = "e-new")
remote.changePages += page(
changed = listOf("two.ics"),
removed = listOf("one.ics"),
token = "urn:x:2",
eTag = "e-new",
)
val report = sync()
// ⚠️ Same UID, new filename. Purging `one.ics` before `two.ics` is
// fetched re-inserts the task with sortOrder 0 and no colour, and
// reports a deletion nobody asked for.
val row = store.rows.single()
assertThat(row.id).isEqualTo(1)
assertThat(row.href).isEqualTo("http://server/dav/tasks/two.ics")
assertThat(row.sortOrder).isEqualTo(5)
assertThat(row.color).isEqualTo(0x00FF00)
assertThat(report.deletedLocally).isEqualTo(0)
}
@Test fun `an un-uploaded local edit is never downloaded over`() {
remote.readOnly = true
store.rows += task(id = 1, uid = "a", title = "Mine, unsent")
.copy(href = "http://server/dav/tasks/one.ics", etag = "e-old", isDirty = true)
remote.put("one.ics", vtodo("a", "Owner's version"), eTag = "e-new")
remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2")
sync()
// The upload was refused because the share was demoted, so the row is
// still dirty. Downloading over it destroys the edit with nothing in the
// report to say so.
assertThat(store.rows.single().title).isEqualTo("Mine, unsent")
assertThat(store.rows.single().isDirty).isTrue()
}
@Test fun `a stale removal never deletes what this run just wrote`() {
store.rows += task(id = 1, uid = "a", title = "Mine")
.copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics", isDirty = true)
remote.put("one.ics", vtodo("a", "Server copy"))
// The change log predates our own upload in this same run.
remote.changePages += page(removed = listOf("one.ics"), token = "urn:x:2")
val report = sync()
assertThat(remote.log).contains("UPDATE one.ics if-match=e-one.ics")
assertThat(store.rows).hasSize(1)
assertThat(report.deletedLocally).isEqualTo(0)
}
@Test fun `a removal that loses a local edit says so`() {
store.rows += task(id = 1, uid = "a", title = "Mine")
.copy(href = "http://server/dav/tasks/gone.ics", etag = "e", isDirty = true)
remote.onPut = { de.jeanlucmakiola.caldav.PutOutcome.Rejected(400, "no") }
remote.changePages += page(removed = listOf("gone.ics"), token = "urn:x:2")
val report = sync()
assertThat(store.rows).isEmpty()
assertThat(report.discardedEdits.single().cause)
.isEqualTo(DiscardedEdit.Cause.DELETED_ON_SERVER)
}
@Test fun `a fallback that succeeds is not reported as a failure`() {
remote.put("one.ics", vtodo("a", "New"))
remote.changePages += ChangeSet.Failed("HTTP 400")
val report = sync()
// The collection is reconciled and the user has nothing to act on, so
// this must not surface as "last sync didn't finish".
assertThat(report.failure).isNull()
assertThat(report.incrementalNote).contains("400")
assertThat(report.reconciledInFull).isTrue()
assertThat(store.tokenWrites.last()).isEqualTo("urn:x:from-propfind")
}
@Test fun `a refetch the change log never mentions still happens`() {
store.rows += task(id = 1, uid = "a", title = "Mine").copy(isDirty = true)
remote.onPut = { href -> de.jeanlucmakiola.caldav.PutOutcome.StoredNeedsRefetch(href) }
remote.put("a.ics", vtodo("a", "Server's canonical form"))
// A server need not echo our own write back to us.
remote.changePages += page(token = "urn:x:2")
sync()
// Otherwise the row keeps a null ETag and never gets the canonical body.
assertThat(store.rows.single().title).isEqualTo("Server's canonical form")
}
@Test fun `a failed download never advances the cursor`() {
remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2")
remote.fetchFailure = "connection reset"
sync()
// ⚠️ A cursor committed past bodies that never landed is a permanent hole
// in the collection, invisible until the next full reconciliation.
assertThat(store.tokenWrites).doesNotContain("urn:x:2")
}
@Test fun `a listed resource the server will not return is counted`() {
remote.changePages += page(changed = listOf("ghost.ics"), token = "urn:x:2")
val report = sync()
// In the listing, so the sweep leaves it; never applied, so nothing else
// would ever count it.
assertThat(report.quarantined.single().reason).contains("listed but not returned")
}
// ------------------------------------------------------------- cadence
@Test fun `a due full reconciliation ignores the token entirely`() {
remote.put("one.ics", vtodo("a", "New"))
val report = sync(fullDue = true)
// The pruned-change-log failure has no signal at all, so the full path is
// a permanent safety net rather than a fallback.
assertThat(remote.log).doesNotContain("CHANGES urn:x:1")
assertThat(report.reconciledInFull).isTrue()
}
@Test fun `a collection that does not advertise the report is never asked`() {
remote.supportsSyncCollection = false
remote.put("one.ics", vtodo("a", "New"))
sync()
assertThat(remote.log.none { it.startsWith("CHANGES") }).isTrue()
}
@Test fun `an unchanged etag in the change log is not downloaded`() {
store.rows += task(id = 1, uid = "a", title = "Known")
.copy(href = "http://server/dav/tasks/one.ics", etag = "e-one.ics")
remote.put("one.ics", vtodo("a", "Known"))
remote.changePages += page(changed = listOf("one.ics"), token = "urn:x:2", eTag = "e-one.ics")
sync()
assertThat(remote.log.none { it.startsWith("FETCH") }).isTrue()
}
// --------------------------------------------------------------- setup
@Test fun `a deferred create is not overwritten by the change log`() {
// A previous run's PUT landed but its answer was lost; the user has since
// edited the task, so the row is dirty with no href.
remote.put("a.ics", vtodo("a", "On the server"))
store.rows += task(id = 1, uid = "a", title = "Edited after the lost PUT")
.copy(isDirty = true)
remote.changePages += page(changed = listOf("a.ics"), token = "urn:x:2")
var failNext = true
remote.onFetch = {
if (failNext) {
failNext = false
Result.failure(IllegalStateException("timeout"))
} else {
null
}
}
sync()
// downloadChanged matches by href too, so a row with none is past its
// dirty-row guard: without honouring `deferred` here, apply matches by
// UID and the edit is gone with nothing in discardedEdits.
val row = store.rows.single()
assertThat(row.title).isEqualTo("Edited after the lost PUT")
assertThat(row.isDirty).isTrue()
assertThat(row.href).isNull()
}
private fun page(
changed: List<String> = emptyList(),
removed: List<String> = emptyList(),
token: String?,
truncated: Boolean = false,
eTag: String = "e-changed",
) = ChangeSet.Page(
changed = changed.map {
RemoteRef("http://server/dav/tasks/$it".toHttpUrl(), ETag(eTag, weak = false))
},
removed = removed.map { "http://server/dav/tasks/$it".toHttpUrl() },
token = token,
truncated = truncated,
)
private fun task(id: Long, uid: String, title: String? = null) =
TaskEntity(id = id, listId = list.id, uid = uid, title = title)
private fun vtodo(uid: String, summary: String) =
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:$uid\r\n" +
"SUMMARY:$summary\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
private companion object {
val NOW: Instant = Instant.parse("2026-03-01T12:00:00Z")
}
}
@@ -0,0 +1,83 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
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.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import kotlin.time.Instant
/** "Retry" on a quarantined task: one resource released, nothing else touched. */
class QuarantineRetryTest {
@TempDir lateinit var directory: File
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
private val dataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.create(scope = scope) { File(directory, "sync.preferences_pb") }
}
private val quarantine by lazy { QuarantineStore(dataStore) }
private val notices by lazy { SyncNoticeStore(dataStore) }
@AfterEach fun tearDown() = scope.cancel()
@Test
fun `releasing a resource clears only its count`() = runTest {
quarantine.merge(
updates = mapOf(
QuarantineStore.key(1, BAD) to 3,
QuarantineStore.key(1, OTHER) to 3,
QuarantineStore.key(9, BAD) to 3,
),
cleared = emptySet(),
)
quarantine.release(setOf(1, 2), BAD)
assertThat(quarantine.counts()).containsExactly(
QuarantineStore.key(1, OTHER), 3,
// Another account's list is not this retry's business.
QuarantineStore.key(9, BAD), 3,
)
}
@Test
fun `a retried quarantine notice goes, a discarded edit stays`() = runTest {
notices.record(
accountId = 1,
at = Instant.fromEpochMilliseconds(1),
reports = listOf(
SyncReport(
listId = 1,
listName = "Tasks",
discardedEdits = listOf(DiscardedEdit("u", "Milk", DiscardedEdit.Cause.SERVER_NEWER)),
quarantined = listOf(
QuarantinedResource(BAD, "415", QuarantineStore.THRESHOLD),
QuarantinedResource(OTHER, "415", QuarantineStore.THRESHOLD),
),
),
),
)
notices.forgetQuarantined(1, BAD)
val left = notices.observeAll().first()
assertThat(left.map { it.key }).containsExactly("u", OTHER)
}
private companion object {
const val BAD = "http://server/dav/tasks/bad.ics"
const val OTHER = "http://server/dav/tasks/other.ics"
}
}
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.agendula.data.sync
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.sync.SyncFailure.Kind
import org.junit.jupiter.api.Test
/** What the accounts screen says about `last_sync_error`, which is written for logs. */
class SyncFailureTest {
@Test
fun `a rejected credential asks for the password`() {
assertThat(kind("collection unavailable: at.bitfire.dav4jvm.exception.UnauthorizedException: HTTP 401 Unauthorized"))
.isEqualTo(Kind.SIGN_IN)
assertThat(kind("the server rejected the credentials")).isEqualTo(Kind.SIGN_IN)
}
@Test
fun `a network failure is unreachable`() {
assertThat(kind("collection unavailable: java.net.UnknownHostException: Unable to resolve host \"cloud\""))
.isEqualTo(Kind.UNREACHABLE)
assertThat(kind("listing failed: java.net.SocketTimeoutException: timeout")).isEqualTo(Kind.UNREACHABLE)
assertThat(kind("download failed: java.net.ConnectException: Failed to connect")).isEqualTo(Kind.UNREACHABLE)
}
@Test
fun `a TLS failure is about the certificate`() {
assertThat(
kind("collection unavailable: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException"),
).isEqualTo(Kind.CERTIFICATE)
}
@Test
fun `a server error carries its code`() {
val failure = SyncFailure.of("listing failed: at.bitfire.dav4jvm.exception.HttpException: HTTP 500 Internal Server Error")
assertThat(failure.kind).isEqualTo(Kind.SERVER)
assertThat(failure.httpCode).isEqualTo(500)
}
@Test
fun `a missing piece of the account is misconfiguration`() {
assertThat(kind("account has no principal URL")).isEqualTo(Kind.MISCONFIGURED)
assertThat(kind("list has no collection URL")).isEqualTo(Kind.MISCONFIGURED)
}
@Test
fun `anything else is not guessed at`() {
assertThat(kind("the server listed no tasks while 4 are known here — nothing was deleted"))
.isEqualTo(Kind.OTHER)
}
private fun kind(error: String) = SyncFailure.of(error).kind
}
@@ -0,0 +1,197 @@
package de.jeanlucmakiola.agendula.data.sync
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.caldav.CalendarCollection
import de.jeanlucmakiola.caldav.ChangeSet
import de.jeanlucmakiola.caldav.DeleteOutcome
import de.jeanlucmakiola.caldav.ETag
import de.jeanlucmakiola.caldav.FetchResult
import de.jeanlucmakiola.caldav.PushSupport
import de.jeanlucmakiola.caldav.PutOutcome
import de.jeanlucmakiola.caldav.RemoteCalendar
import de.jeanlucmakiola.caldav.RemoteRef
import de.jeanlucmakiola.caldav.RemoteResource
import de.jeanlucmakiola.caldav.TaskCollection
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
/** An in-memory [SyncStore], standing in for Room. */
class FakeStore(rows: List<TaskEntity> = emptyList()) : SyncStore {
val rows = rows.toMutableList()
val readOnlyWrites = mutableListOf<Pair<Long, Boolean>>()
/** In order, so a test can prove the token was stored *after* its bodies. */
val tokenWrites = mutableListOf<String?>()
private var nextId = 1L
override fun rowsIn(listId: Long) = this.rows.filter { it.listId == listId }
override fun insert(row: TaskEntity): Long {
// Computed against the rows as they stand, not at construction: tests
// seed `rows` directly after building this, and an id fixed up front
// collides with the first seeded row — which then makes a delete of one
// take the other with it.
nextId = maxOf(nextId, (rows.maxOfOrNull { it.id } ?: 0L) + 1)
val id = nextId++
rows += row.copy(id = id)
return id
}
override fun update(row: TaskEntity) {
val index = rows.indexOfFirst { it.id == row.id }
if (index >= 0) rows[index] = row
}
override fun deleteAll(taskIds: List<Long>) {
rows.removeAll { it.id in taskIds }
}
override fun markSynced(taskIds: List<Long>, href: String?, eTag: String?) {
taskIds.forEach { id ->
val index = rows.indexOfFirst { it.id == id }
if (index >= 0) {
rows[index] = rows[index].copy(href = href, etag = eTag, isDirty = false)
}
}
}
override fun setParent(taskId: Long, parentId: Long?) {
val index = rows.indexOfFirst { it.id == taskId }
if (index >= 0) rows[index] = rows[index].copy(parentId = parentId)
}
override fun masterByUid(listId: Long, uid: String) =
rows.firstOrNull { it.listId == listId && it.uid == uid && it.recurrenceId == null }
override fun row(taskId: Long) = rows.firstOrNull { it.id == taskId }
override fun setListReadOnly(listId: Long, readOnly: Boolean) {
readOnlyWrites += listId to readOnly
}
override fun setSyncToken(listId: Long, token: String?) {
tokenWrites += token
}
}
/** An in-memory CalDAV collection that answers like a server rather than a mock. */
class FakeRemote(override val url: HttpUrl = "http://server/dav/tasks/".toHttpUrl()) :
RemoteCalendar {
data class Stored(var eTag: ETag?, var body: String)
val resources = linkedMapOf<String, Stored>()
val log = mutableListOf<String>()
var readOnly = false
var shared = false
var supportsSyncCollection = false
var syncToken: String? = null
var push: PushSupport? = null
/** Pages the next `changes()` calls will return, in order. */
val changePages = ArrayDeque<ChangeSet>()
var stateFailure: String? = null
var listFailure: String? = null
var fetchFailure: String? = null
/** Returns non-null to pre-empt the default behaviour for that href. */
var onPut: ((HttpUrl) -> PutOutcome?)? = null
var onDelete: ((HttpUrl) -> DeleteOutcome?)? = null
/**
* Returns non-null to pre-empt the default behaviour for that fetch.
*
* Unlike [fetchFailure], which breaks every fetch in the run, this can fail
* one and leave the rest working — the difference between "the server is
* down" and "this one request timed out".
*/
var onFetch: ((List<HttpUrl>) -> Result<FetchResult>?)? = null
fun put(name: String, body: String, eTag: String? = "e-$name") {
resources[url.newBuilder().addPathSegment(name).build().toString()] =
Stored(eTag?.let { ETag(it, weak = false) }, body)
}
override fun state(): Result<CalendarCollection.State> {
stateFailure?.let { return Result.failure(IllegalStateException(it)) }
return Result.success(
CalendarCollection.State(
collection = TaskCollection(
url = url,
displayName = "Tasks",
color = null,
readOnly = readOnly,
isShared = shared,
supportsSyncCollection = supportsSyncCollection,
maxResourceSize = null,
push = push,
),
ctag = "ctag",
syncToken = syncToken,
),
)
}
override fun changes(token: String?): ChangeSet {
log += "CHANGES $token"
return changePages.removeFirstOrNull()
?: ChangeSet.Failed("the test queued no page for token $token")
}
override fun list(): Result<List<RemoteRef>> {
listFailure?.let { return Result.failure(IllegalStateException(it)) }
log += "LIST"
return Result.success(
resources.map { (href, stored) -> RemoteRef(href.toHttpUrl(), stored.eTag) },
)
}
override fun fetch(hrefs: List<HttpUrl>): Result<FetchResult> {
log += "FETCH ${hrefs.joinToString(",") { it.pathSegments.last() }}"
onFetch?.invoke(hrefs)?.let { return it }
fetchFailure?.let { return Result.failure(IllegalStateException(it)) }
val found = hrefs.mapNotNull { href ->
resources[href.toString()]?.let { RemoteResource(href, it.eTag, it.body) }
}
return Result.success(
FetchResult(
resources = found,
missing = hrefs.filterNot { it.toString() in resources },
failed = emptyList(),
unsolicited = emptyList(),
),
)
}
override fun create(name: String, iCalendar: String): PutOutcome {
val href = url.newBuilder().addPathSegment(name).build()
log += "CREATE $name"
onPut?.invoke(href)?.let { return it }
if (href.toString() in resources) return PutOutcome.NameTaken(href)
val eTag = ETag("e-$name", weak = false)
resources[href.toString()] = Stored(eTag, iCalendar)
return PutOutcome.Stored(href, eTag)
}
override fun update(href: HttpUrl, eTag: String?, iCalendar: String): PutOutcome {
log += "UPDATE ${href.pathSegments.last()} if-match=$eTag"
onPut?.invoke(href)?.let { return it }
val stored = resources[href.toString()] ?: return PutOutcome.Vanished
if (eTag != null && stored.eTag?.value != eTag) return PutOutcome.ServerNewer
val fresh = ETag("e-${resources.size}-${iCalendar.hashCode()}", weak = false)
stored.eTag = fresh
stored.body = iCalendar
return PutOutcome.Stored(href, fresh)
}
override fun delete(href: HttpUrl, eTag: String?): DeleteOutcome {
log += "DELETE ${href.pathSegments.last()} if-match=$eTag"
onDelete?.invoke(href)?.let { return it }
val stored = resources[href.toString()] ?: return DeleteOutcome.Deleted
if (eTag != null && stored.eTag?.value != eTag) return DeleteOutcome.ServerNewer
resources.remove(href.toString())
return DeleteOutcome.Deleted
}
}
@@ -0,0 +1,190 @@
package de.jeanlucmakiola.agendula.data.sync
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
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.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import kotlin.time.Instant
/**
* The two halves of "tell the user what the sync did".
*
* They are stored differently on purpose — one is news, the other a standing
* condition — and getting that backwards either loses the news to the next quiet
* sync or re-announces the same broken task every four hours for ever.
*/
class SyncNoticeStoreTest {
@TempDir lateinit var directory: File
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
private val dataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.create(scope = scope) { File(directory, "notices.preferences_pb") }
}
private val store by lazy { SyncNoticeStore(dataStore) }
@AfterEach fun tearDown() = scope.cancel()
@Test
fun `a discarded edit is kept when a later sync finds nothing`() = runTest {
store.record(ACCOUNT, at(1), listOf(report(discarded = listOf(edit("Milk")))))
store.record(ACCOUNT, at(2), listOf(report()))
// ⚠️ The edit is gone from the device either way. A clean sync an hour
// later does not make that untrue, and replacing the set would erase the
// one thing worth saying before anyone saw it.
val kept = store.observeAll().first()
assertThat(kept.map { it.subject }).containsExactly("Milk")
}
@Test
fun `a resource that starts syncing again clears its own notice`() = runTest {
store.record(ACCOUNT, at(1), listOf(report(quarantined = listOf(stuck("a.ics")))))
store.record(ACCOUNT, at(2), listOf(report()))
// A standing condition, not news: it is re-reported for as long as it
// holds, so the absence of a report *is* the recovery.
assertThat(store.observeAll().first()).isEmpty()
}
@Test
fun `a resource still failing is not announced twice`() = runTest {
val first = store.record(ACCOUNT, at(1), listOf(report(quarantined = listOf(stuck("a.ics")))))
val second = store.record(ACCOUNT, at(2), listOf(report(quarantined = listOf(stuck("a.ics")))))
assertThat(first).hasSize(1)
// Announcing it every four hours is how a user learns to ignore the one
// that matters. It stays on the account screen throughout.
assertThat(second).isEmpty()
assertThat(store.observeAll().first()).hasSize(1)
}
@Test
fun `a resource below the threshold says nothing`() = runTest {
val fresh = store.record(
ACCOUNT,
at(1),
listOf(report(quarantined = listOf(stuck("a.ics", failures = 1)))),
)
// Still being retried. "One of your tasks has stopped syncing" would be
// untrue of a single 502 from a proxy mid-restart.
assertThat(fresh).isEmpty()
assertThat(store.observeAll().first()).isEmpty()
}
@Test
fun `two untitled edits in one run are both reported`() = runTest {
store.record(
ACCOUNT,
at(1),
listOf(report(discarded = listOf(edit(null, "uid-a"), edit(null, "uid-b")))),
)
// ⚠️ These are stored as a Set<String>. Two discards from one run share
// an account, a list, a cause and a timestamp, so without the UID in the
// key they encoded identically and one silently vanished — the
// notification counted two and the screen listed one.
assertThat(store.observeAll().first()).hasSize(2)
}
@Test
fun `two edits with the same title are both reported`() = runTest {
store.record(
ACCOUNT,
at(1),
listOf(report(discarded = listOf(edit("Milk", "uid-a"), edit("Milk", "uid-b")))),
)
assertThat(store.observeAll().first()).hasSize(2)
}
@Test
fun `a quarantined task is named by its title where one is known`() = runTest {
val href = "https://server/dav/tasks/a1f9c3e2.ics"
store.record(
ACCOUNT,
at(1),
listOf(report(quarantined = listOf(stuck("a1f9c3e2.ics")))),
titles = mapOf(href to "Renew the passport"),
)
// "A task has stopped syncing" over a row reading `a1f9c3e2.ics` names
// nothing the user can act on — the same objection this file makes to
// showing a UID.
assertThat(store.observeAll().first().single().subject).isEqualTo("Renew the passport")
}
@Test
fun `a quarantined resource we never stored falls back to its filename`() = runTest {
store.record(ACCOUNT, at(1), listOf(report(quarantined = listOf(stuck("a1f9c3e2.ics")))))
// Nothing local to name it by, so the filename is genuinely all there is.
assertThat(store.observeAll().first().single().subject).isEqualTo("a1f9c3e2.ics")
}
@Test
fun `titles and list names survive whatever characters they contain`() = runTest {
store.record(
ACCOUNT,
at(1),
listOf(
report(
listName = "Work | Home",
discarded = listOf(edit("Pay the bill | urgent")),
),
),
)
// A list is named by its owner and a task titled by its author, so both
// can hold the separator this store writes with.
val notice = store.observeAll().first().single()
assertThat(notice.subject).isEqualTo("Pay the bill | urgent")
assertThat(notice.listName).isEqualTo("Work | Home")
}
@Test
fun `one account's notices leave another's alone`() = runTest {
store.record(ACCOUNT, at(1), listOf(report(discarded = listOf(edit("Milk")))))
store.record(OTHER, at(1), listOf(report(discarded = listOf(edit("Bread")))))
store.dismiss(ACCOUNT)
assertThat(store.observeAll().first().map { it.subject }).containsExactly("Bread")
}
private fun report(
listName: String = "Tasks",
discarded: List<DiscardedEdit> = emptyList(),
quarantined: List<QuarantinedResource> = emptyList(),
) = SyncReport(
listId = 1,
listName = listName,
discardedEdits = discarded,
quarantined = quarantined,
)
private fun edit(title: String?, uid: String = "uid-$title") =
DiscardedEdit(uid = uid, title = title, cause = DiscardedEdit.Cause.SERVER_NEWER)
private fun stuck(href: String, failures: Int = QuarantineStore.THRESHOLD) =
QuarantinedResource(href = "https://server/dav/tasks/$href", reason = "415", failures = failures)
private fun at(seconds: Long) = Instant.fromEpochMilliseconds(seconds * 1_000)
private companion object {
const val ACCOUNT = 1L
const val OTHER = 2L
}
}
@@ -0,0 +1,96 @@
package de.jeanlucmakiola.agendula.data.sync.push
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.caldav.PushSupport
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import java.io.File
import kotlin.time.Instant
class PushStoreTest {
@TempDir lateinit var directory: File
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
private val dataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.create(scope = scope) { File(directory, "push.preferences_pb") }
}
private val store by lazy { PushStore(dataStore) }
@AfterEach fun tearDown() = scope.cancel()
@Test
fun `support and subscription survive a round trip, separators and all`() = runTest {
// Server-chosen strings: nothing stops a topic or a URL carrying the
// separator the entries are joined with.
val support = PushSupport(topic = "a|b c", vapidPublicKey = "BA1H_x-y")
store.recordSupport(mapOf(1L to support))
store.recordSubscription(
listId = 1,
subscription = "https://dav.example.com/subs/1?x=|",
endpoint = "https://up.example.net/abc",
expires = Instant.fromEpochSeconds(1_800_000_000),
)
assertThat(store.all()[1]).isEqualTo(
PushStore.ListPush(
listId = 1,
support = support,
subscription = "https://dav.example.com/subs/1?x=|",
endpoint = "https://up.example.net/abc",
expires = Instant.fromEpochSeconds(1_800_000_000),
),
)
}
@Test
fun `only a change in support is reported as one`() = runTest {
val support = PushSupport(topic = "t", vapidPublicKey = null)
assertThat(store.recordSupport(mapOf(1L to support))).isTrue()
assertThat(store.recordSupport(mapOf(1L to support))).isFalse()
// A rotated VAPID key is a change: it needs a new registration.
assertThat(store.recordSupport(mapOf(1L to support.copy(vapidPublicKey = "new")))).isTrue()
// Nothing known, nothing offered: no change either.
assertThat(store.recordSupport(mapOf(2L to null))).isFalse()
}
@Test
fun `losing support keeps the subscription until it is removed`() = runTest {
store.recordSupport(mapOf(1L to PushSupport(topic = "t", vapidPublicKey = null)))
store.recordSubscription(1, "https://dav/subs/1", "https://up/1", Instant.fromEpochSeconds(1))
store.recordSupport(mapOf(1L to null))
// Still here, so the registrar can find it and tell the server.
assertThat(store.all()[1]?.subscription).isEqualTo("https://dav/subs/1")
store.clearSubscription(1)
assertThat(store.all()).doesNotContainKey(1L)
}
@Test
fun `forgetting a list leaves the others alone`() = runTest {
store.recordSupport(
mapOf(
1L to PushSupport(topic = "one", vapidPublicKey = null),
2L to PushSupport(topic = "two", vapidPublicKey = null),
),
)
store.forget(setOf(1L))
assertThat(store.all().keys).containsExactly(2L)
}
}
@@ -0,0 +1,198 @@
package de.jeanlucmakiola.agendula.data.tasks
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
/**
* The storage-mode decision, which is the part of [ProviderResolver] with real
* consequences: pick wrong for a returning user and the app opens on an empty
* store where their tasks used to be.
*/
class ProviderResolverTest {
/**
* A [ProviderEnvironment] with no Android in it.
*
* @param installed authority -> declaring package, i.e. what is on the device.
* @param granted permissions this app currently holds.
*/
private class FakeEnvironment(
val installed: Map<String, String> = emptyMap(),
val granted: Set<String> = emptySet(),
) : ProviderEnvironment {
override fun packageDeclaring(authority: String): String? = installed[authority]
override fun isGranted(permission: String): Boolean = permission in granted
override fun appLabel(packageName: String): String? = packageName
}
private val openTasks = ProviderResolver.EXTERNAL_CANDIDATES.first { it.authority == "org.dmfs.tasks" }
private fun resolver(
installed: Map<String, String> = emptyMap(),
granted: Set<String> = emptySet(),
mode: StorageMode? = null,
) = ProviderResolver(FakeEnvironment(installed, granted)).apply { storageMode = mode }
private val openTasksInstalled = mapOf("org.dmfs.tasks" to "org.dmfs.tasks")
private val openTasksGranted = setOf(openTasks.readPermission, openTasks.writePermission)
@Nested
inner class OwnStore {
@Test
fun `is readable without anything installed or granted`() {
// Guards a real regression: the reminder engine used to gate on
// resolve() != null, which is exactly what OWN returns, so every
// reminder was cleared the moment our own store became the default.
assertThat(resolver(mode = StorageMode.OWN).canReadStore()).isTrue()
}
@Test
fun `resolves to no provider at all`() {
// Room has no authority and no ContentResolver, so there is nothing
// here to resolve — which is the point. Callers that need to tell this
// apart from "External, none installed" ask mode().
val resolver = resolver(mode = StorageMode.OWN)
assertThat(resolver.resolve()).isNull()
assertThat(resolver.mode()).isEqualTo(StorageMode.OWN)
}
}
@Nested
inner class AutoMode {
@Test
fun `a fresh install with nothing else present gets our own store`() {
assertThat(resolver().autoMode()).isEqualTo(StorageMode.OWN)
}
@Test
fun `an upgrading user who already granted OpenTasks stays on it`() {
// Holding a dangerous permission means a previous version asked and they
// agreed — the signature of an existing Posture A user. Sending them to
// our empty bundled store would read as data loss.
val resolver = resolver(installed = openTasksInstalled, granted = openTasksGranted)
assertThat(resolver.autoMode()).isEqualTo(StorageMode.EXTERNAL)
assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks")
}
@Test
fun `OpenTasks merely installed is not enough`() {
// Someone who has OpenTasks for unrelated reasons, and never granted us
// anything, has no data with us there. Our own store is right for them.
assertThat(resolver(installed = openTasksInstalled).autoMode()).isEqualTo(StorageMode.OWN)
}
@Test
fun `a half-granted external provider does not count`() {
val resolver = resolver(
installed = openTasksInstalled,
granted = setOf(openTasks.readPermission),
)
assertThat(resolver.autoMode()).isEqualTo(StorageMode.OWN)
}
}
@Nested
inner class ExplicitChoice {
@Test
fun `overrides the automatic answer in both directions`() {
val wouldBeExternal = FakeEnvironment(openTasksInstalled, openTasksGranted)
val forcedOwn = ProviderResolver(wouldBeExternal).apply { storageMode = StorageMode.OWN }
assertThat(forcedOwn.mode()).isEqualTo(StorageMode.OWN)
assertThat(forcedOwn.resolve()).isNull()
val forcedExternal = ProviderResolver(FakeEnvironment()).apply { storageMode = StorageMode.EXTERNAL }
assertThat(forcedExternal.mode()).isEqualTo(StorageMode.EXTERNAL)
assertThat(forcedExternal.resolve()).isNull()
}
@Test
fun `external with no provider installed resolves to nothing`() {
// Drives the "install a tasks provider" gate rather than silently
// falling back to our own store behind the user's back.
assertThat(resolver(mode = StorageMode.EXTERNAL).resolve()).isNull()
}
@Test
fun `external is unreadable until a provider is installed and granted`() {
assertThat(resolver(mode = StorageMode.EXTERNAL).canReadStore()).isFalse()
assertThat(
resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL).canReadStore(),
).isFalse()
assertThat(
resolver(
installed = openTasksInstalled,
granted = openTasksGranted,
mode = StorageMode.EXTERNAL,
).canReadStore(),
).isTrue()
}
@Test
fun `external still requires the runtime permission`() {
val resolver = resolver(installed = openTasksInstalled, mode = StorageMode.EXTERNAL)
val provider = resolver.resolve()
assertThat(provider).isNotNull()
assertThat(resolver.hasPermission(provider!!)).isFalse()
}
}
@Nested
inner class ExternalCandidates {
@Test
fun `prefer OpenTasks over tasks_org when both are installed`() {
val resolver = resolver(
installed = mapOf(
"org.dmfs.tasks" to "org.dmfs.tasks",
"org.tasks.opentasks" to "org.tasks",
),
mode = StorageMode.EXTERNAL,
)
assertThat(resolver.resolve()?.authority).isEqualTo("org.dmfs.tasks")
}
@Test
fun `fall through to tasks_org when OpenTasks is absent`() {
val resolver = resolver(
installed = mapOf("org.tasks.opentasks" to "org.tasks"),
mode = StorageMode.EXTERNAL,
)
assertThat(resolver.resolve()?.packageName).isEqualTo("org.tasks")
}
@Test
fun `a mode change notifies listeners once, and only on a real change`() {
// What store observers hang off: a live flow is bound to one store, so
// it has to be told when the store underneath it is swapped.
val resolver = resolver(mode = StorageMode.OWN)
var fired = 0
val handle = resolver.onModeChanged { fired++ }
resolver.storageMode = StorageMode.OWN
assertThat(fired).isEqualTo(0)
resolver.storageMode = StorageMode.EXTERNAL
assertThat(fired).isEqualTo(1)
handle.close()
resolver.storageMode = StorageMode.OWN
assertThat(fired).isEqualTo(1)
}
@Test
fun `never name an authority of ours`() {
// EXTERNAL must mean "somebody else's store", and Agendula publishes no
// provider at all any more.
assertThat(
ProviderResolver.EXTERNAL_CANDIDATES.none {
it.authority.startsWith("de.jeanlucmakiola")
},
).isTrue()
}
}
}
@@ -36,7 +36,6 @@ class TaskMapperTest {
val task = TaskMapper.task(reader)
assertThat(task.id).isEqualTo(42L)
assertThat(task.taskId).isEqualTo(7L)
assertThat(task.listId).isEqualTo(3L)
assertThat(task.title).isEqualTo("Buy milk")
@@ -50,6 +49,65 @@ class TaskMapperTest {
assertThat(task.isSubtask).isTrue()
}
@Test
fun `an occurrence is identified by its recurrence-id anchor`() {
fun occurrence(columns: Map<String, Any?>) =
TaskMapper.task(MapColumnReader(columns + (Tasks.RRULE to "FREQ=DAILY")))
// instance_original_time is the provider's own RECURRENCE-ID and wins.
val anchored = occurrence(
mapOf(
Instances.TASK_ID to 7L,
Instances.INSTANCE_ORIGINAL_TIME to 500L,
Instances.INSTANCE_START to 900L,
),
)
assertThat(anchored.occurrenceStart?.toEpochMilliseconds()).isEqualTo(500L)
assertThat(anchored.occurrenceKey).isEqualTo("7@500")
// Older provider schemas omit it; the occurrence's start reconstructs it.
val byStart = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_START to 900L))
assertThat(byStart.occurrenceStart?.toEpochMilliseconds()).isEqualTo(900L)
// A series carrying only DUE anchors on the due date instead.
val byDue = occurrence(mapOf(Instances.TASK_ID to 7L, Instances.INSTANCE_DUE to 1_200L))
assertThat(byDue.occurrenceStart?.toEpochMilliseconds()).isEqualTo(1_200L)
}
@Test
fun `a non-recurring task has no occurrence anchor and keys by task id`() {
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 4L, Instances.INSTANCE_START to 500L)),
)
assertThat(task.occurrenceStart).isNull()
assertThat(task.occurrenceKey).isEqualTo("4")
}
@Test
fun `recurrence is detected from rrule when is_recurring is absent`() {
// tasks.org's bundled provider is DB 22 and has no `is_recurring` column;
// reading it alone would report the series as one-off and send its edits
// to the master row, re-anchoring the whole thing.
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RRULE to "FREQ=WEEKLY;BYDAY=MO")),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `recurrence is detected from rdate alone`() {
val task = TaskMapper.task(
MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.RDATE to "20260720T090000Z")),
)
assertThat(task.isRecurring).isTrue()
}
@Test
fun `a plain task is not recurring`() {
val task = TaskMapper.task(MapColumnReader(mapOf(Tasks.ID to 1L, Tasks.TITLE to "One-off")))
assertThat(task.isRecurring).isFalse()
}
@Test
fun `falls back to instance id when task_id missing, and list color when no task color`() {
val task = TaskMapper.task(
@@ -85,6 +85,57 @@ class TaskWriteMapperTest {
assertThat(values[Tasks.TZ]).isNull()
}
@Test
fun `all-day timestamps are pinned to UTC midnight`() {
// 2026-07-20T22:00Z — i.e. local midnight on the 21st in Berlin (UTC+2).
// The provider resolves all-day dates against UTC, so storing this as-is
// would land the task on the 20th for anyone reading it back.
val berlinMidnight = Instant.fromEpochMilliseconds(1_784_412_000_000L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Holiday", listId = 1L, start = berlinMidnight, due = berlinMidnight, isAllDay = true),
tzId = "Europe/Berlin",
)
val dayMs = 24L * 60 * 60 * 1000
assertThat(values[Tasks.DUE] as Long % dayMs).isEqualTo(0L)
assertThat(values[Tasks.DTSTART] as Long % dayMs).isEqualTo(0L)
}
@Test
fun `timed timestamps are written untouched`() {
val at = Instant.fromEpochMilliseconds(1_784_412_345_678L)
val values = TaskWriteMapper.taskValues(
TaskForm(title = "Standup", listId = 1L, start = at, due = at),
tzId = "Europe/Berlin",
)
assertThat(values[Tasks.DTSTART]).isEqualTo(1_784_412_345_678L)
assertThat(values[Tasks.DUE]).isEqualTo(1_784_412_345_678L)
}
@Test
fun `duration is always cleared so it cannot collide with due`() {
// The provider validates the *merged* row and throws "Only one of DUE or
// DURATION must be supplied" if the stored row still carries a duration.
val values = TaskWriteMapper.taskValues(
TaskForm(title = "x", listId = 1L, due = Instant.fromEpochMilliseconds(5_000L)),
tzId = "UTC",
)
assertThat(values.containsKey(Tasks.DURATION)).isTrue()
assertThat(values[Tasks.DURATION]).isNull()
}
@Test
fun `instance values drop list and parent, which an override cannot express`() {
val form = TaskForm(title = "x", listId = 4L, parentId = 7L, due = Instant.fromEpochMilliseconds(1_000L))
val values = TaskWriteMapper.instanceValues(form, tzId = "UTC")
assertThat(values.containsKey(Tasks.LIST_ID)).isFalse()
assertThat(values.containsKey(Tasks.PARENT_ID)).isFalse()
// …but still carries the edit itself.
assertThat(values[Tasks.TITLE]).isEqualTo("x")
assertThat(values[Tasks.DUE]).isEqualTo(1_000L)
}
@Test
fun `completion sets status, percent and timestamp, un-completion clears them`() {
val done = TaskWriteMapper.completionValues(completed = true, nowMillis = 999L)
@@ -97,6 +148,22 @@ class TaskWriteMapperTest {
assertThat(undone[Tasks.COMPLETED]).isNull()
}
@Test
fun `alarm carries every column the provider's validator demands`() {
val values = TaskWriteMapper.alarmValues(taskId = 12L, minutesBeforeDue = 30)
assertThat(values[TasksContract.Properties.TASK_ID]).isEqualTo(12L)
assertThat(values[TasksContract.Properties.MIMETYPE])
.isEqualTo("vnd.android.cursor.item/alarm")
assertThat(values[TasksContract.Alarm.MINUTES_BEFORE]).isEqualTo(30)
// REFERENCE must be present and non-negative, ALARM_TYPE present and
// non-zero (0 is excluded from the provider's has_alarms count).
assertThat(values[TasksContract.Alarm.REFERENCE]).isEqualTo(TasksContract.Alarm.REFERENCE_DUE)
assertThat(values[TasksContract.Alarm.ALARM_TYPE]).isEqualTo(TasksContract.Alarm.TYPE_MESSAGE)
// property_id must be absent or the insert is rejected.
assertThat(values.containsKey(TasksContract.Properties.PROPERTY_ID)).isFalse()
}
@Test
fun `local list uses the LOCAL account`() {
val values = TaskWriteMapper.localListValues("Inbox", 0x123)
@@ -0,0 +1,83 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.ical.ICalParam
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalProperty
import org.junit.jupiter.api.Test
class CalendarResourceTest {
@Test fun `a zoned task carries the VTIMEZONE it references`() {
val todo = todo(ICalParam("TZID", "Europe/Berlin"))
val text = CalendarResource.serialize(listOf(todo))
// ⚠️ RFC 5545 §3.2.19: a TZID without a leading solidus must reference a
// VTIMEZONE in the same object. Omitting it makes the resource malformed,
// and `Prefer: handling=strict` turns malformed into rejected.
assertThat(text).contains("BEGIN:VTIMEZONE")
assertThat(text).contains("TZID:Europe/Berlin")
// Definition before reference, for a server that parses as it streams.
assertThat(text.indexOf("BEGIN:VTIMEZONE")).isLessThan(text.indexOf("BEGIN:VTODO"))
}
@Test fun `a UTC task carries no timezone at all`() {
val text = CalendarResource.serialize(listOf(todo()))
assertThat(text).doesNotContain("VTIMEZONE")
}
@Test fun `one definition serves every task that shares a zone`() {
val text = CalendarResource.serialize(
listOf(todo(ICalParam("TZID", "Europe/Berlin")), todo(ICalParam("TZID", "Europe/Berlin"))),
)
assertThat(text.split("BEGIN:VTIMEZONE")).hasSize(2)
}
@Test fun `the wrapper is a parseable VCALENDAR`() {
val text = CalendarResource.serialize(listOf(todo()))
val parsed = CalendarResource.parse(text)
assertThat(parsed).hasSize(1)
assertThat(parsed.single().property("VERSION")!!.value).isEqualTo("2.0")
assertThat(parsed.single().property("PRODID")!!.value)
.isEqualTo(CalendarResource.PRODUCT_ID)
assertThat(CalendarResource.todosIn(parsed)).hasSize(1)
}
@Test fun `a body that is not a calendar yields no calendars`() {
assertThat(CalendarResource.parse("BEGIN:VCARD\r\nEND:VCARD\r\n")).isEmpty()
}
@Test fun `an unparseable scalar is never emitted twice`() {
// ⚠️ Every one of these reaches the residue verbatim because it could not
// be parsed, and `write` authors its column form unconditionally. Without
// suppression the resource carries both — and `Prefer: handling=strict`
// means sabre will not quietly repair the duplicate.
val source = javaClass.classLoader!!
.getResourceAsStream("vtodo/malformed-scalars.ics")!!
.readBytes().decodeToString()
val vtodo = CalendarResource.todosIn(CalendarResource.parse(source)).single()
val written = CalendarResource.serialize(
listOf(VTodoMapper.write(VTodoMapper.read(vtodo).entity)),
)
listOf("SEQUENCE", "PRIORITY", "PERCENT-COMPLETE", "CLASS", "STATUS").forEach { name ->
assertThat(Regex("(?m)^$name[;:]").findAll(written).count())
.isEqualTo(1)
}
// The residue's copy is the one that survives, verbatim.
assertThat(written).contains("SEQUENCE:x")
assertThat(written).contains("PRIORITY:11")
}
private fun todo(vararg params: ICalParam) = ICalComponent(
name = "VTODO",
properties = listOf(
ICalProperty("UID", emptyList(), "a"),
ICalProperty("DTSTART", params.toList(), "20260301T090000"),
),
components = emptyList(),
)
}
@@ -0,0 +1,79 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalValues
/**
* Reduces a component tree to the multiset of facts a faithful round-trip must
* preserve, so two serialisations can be compared for *meaning* rather than for
* bytes.
*
* Byte-stability is unachievable, and
* asserting it produces a corpus which gets normalised until it tests nothing.
* Six things legitimately differ across a round-trip — `PRODID` must change,
* fold position carries no information, parameter quoting is optional, property
* order within a component is unconstrained, `DTSTAMP` is regenerated, and
* `VTIMEZONE` is re-emitted — so those are enumerated here and **nothing else
* may differ**. In particular the unfolded value octets of every property are
* compared exactly.
*/
object ICalCanonical {
/** Properties whose value is allowed to differ across a round-trip. */
private val ALLOWED_TO_DIFFER = setOf("PRODID", "DTSTAMP", "LAST-MODIFIED", "SEQUENCE")
/** Re-emitted from its own rules rather than preserved verbatim. */
private val OPAQUE_COMPONENTS = setOf("VTIMEZONE")
/**
* The one value-level equivalence, and it is an equivalence rather than a
* concession: RFC 5545 section 3.8.1.11 gives a to-do no default `STATUS`,
* and an absent one is universally read as needing action. Normalising
* between the two directions loses nothing, so both are dropped before
* comparison. A `STATUS` that is *not* `NEEDS-ACTION` is compared normally,
* so losing a completion still fails.
*/
private fun isNeutralStatus(name: String, value: String) =
name == "STATUS" && value == "NEEDS-ACTION"
data class Fact(
val path: String,
val name: String,
val params: Map<String, List<String>>,
val value: String,
)
fun facts(component: ICalComponent, path: String = ""): List<Fact> {
if (component.name in OPAQUE_COMPONENTS) return emptyList()
val here = if (path.isEmpty()) component.name else "$path/${component.name}"
val own = component.properties
.filterNot { it.name.uppercase() in ALLOWED_TO_DIFFER }
.filterNot { isNeutralStatus(it.name.uppercase(), it.value.trim()) }
.map { property ->
Fact(
path = here,
name = property.name.uppercase(),
params = property.params
.associate { it.name.uppercase() to it.values }
.toSortedMap()
.toMap(),
// The comparison unit: the unfolded,
// **unescaped** value. `\N` and `\n` are the same escape, and a
// bare comma in a TEXT value is malformed input that we repair
// on write — both are equivalences, not losses. Anything that
// actually changes the value still fails.
value = ICalValues.unescapeText(property.value),
)
}
return own + component.components.flatMap { facts(it, here) }
}
/** Facts present in [before] but missing from [after] — i.e. what was lost. */
fun lost(before: ICalComponent, after: ICalComponent): List<Fact> {
val remaining = facts(after).toMutableList()
return facts(before).filterNot { remaining.remove(it) }
}
/** Facts [after] gained that [before] never had. */
fun invented(before: ICalComponent, after: ICalComponent): List<Fact> = lost(after, before)
}
@@ -0,0 +1,218 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import org.junit.jupiter.api.Test
/**
* Every case here is a real sabre rejection reachable from the UI, not a
* defensive check. A 415 is permanent: the row stays dirty and the next sync
* sends the same bytes forever.
*/
class ResourceValidatorTest {
@Test fun `an ordinary task passes`() {
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
SUMMARY:Buy milk
END:VTODO
"""))).isNull()
}
@Test fun `DUE before DTSTART is refused`() {
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
DTSTART:20260301T100000Z
DUE:20260301T090000Z
END:VTODO
"""))).contains("DUE precedes DTSTART")
}
@Test fun `equal DUE and DTSTART is allowed`() {
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
DTSTART:20260301T100000Z
DUE:20260301T100000Z
END:VTODO
"""))).isNull()
}
@Test fun `a value-type mismatch is refused`() {
// Reachable: set an all-day due date on a task that already has a timed
// start.
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
DTSTART:20260301T100000Z
DUE;VALUE=DATE:20260302
END:VTODO
"""))).contains("disagree on value type")
}
@Test fun `two DATE values are not a mismatch`() {
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
DTSTART;VALUE=DATE:20260301
DUE;VALUE=DATE:20260302
END:VTODO
"""))).isNull()
}
@Test fun `METHOD makes it a scheduling message`() {
assertThat(reject("""
BEGIN:VCALENDAR
VERSION:2.0
METHOD:REQUEST
BEGIN:VTODO
UID:a
END:VTODO
END:VCALENDAR
""")).contains("METHOD")
}
@Test fun `DUE with DURATION is refused`() {
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
DTSTART:20260301T100000Z
DUE:20260301T110000Z
DURATION:PT1H
END:VTODO
"""))).contains("DUE and DURATION")
}
@Test fun `two UIDs in one resource are refused`() {
// RFC 4791 §4.1 — this is the constraint that makes forking a conflicting
// edit into the same resource impossible.
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
END:VTODO
BEGIN:VTODO
UID:b
END:VTODO
"""))).contains("different UIDs")
}
@Test fun `overrides sharing a UID are fine`() {
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
RRULE:FREQ=DAILY
END:VTODO
BEGIN:VTODO
UID:a
RECURRENCE-ID:20260302T090000Z
END:VTODO
"""))).isNull()
}
@Test fun `a mixed component type is refused`() {
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
END:VTODO
BEGIN:VEVENT
UID:a
END:VEVENT
"""))).contains("VEVENT")
}
@Test fun `a VTIMEZONE alongside the task is not foreign`() {
assertThat(reject(vcalendar("""
BEGIN:VTIMEZONE
TZID:Europe/Berlin
END:VTIMEZONE
BEGIN:VTODO
UID:a
END:VTODO
"""))).isNull()
}
@Test fun `a missing UID is refused`() {
assertThat(reject(vcalendar("""
BEGIN:VTODO
SUMMARY:no identity
END:VTODO
"""))).contains("no UID")
}
@Test fun `an implicit DATE does not falsely disagree`() {
// Two value-type tests that disagree block a legitimate PUT: the mapper
// reads a bare eight-digit value as a DATE, and so must this.
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
DTSTART:20260301
DUE;VALUE=DATE:20260302
END:VTODO
"""))).isNull()
}
@Test fun `an end-relative reminder with nothing to anchor it is refused`() {
// Reachable from the UI: clear the due date on a task that has an
// end-relative reminder.
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
BEGIN:VALARM
TRIGGER;RELATED=END:-PT15M
END:VALARM
END:VTODO
"""))).contains("RELATED=END")
}
@Test fun `a TZID with no definition is refused`() {
// A Windows zone name from another client survives in the residue, and
// `java.time` cannot regenerate a VTIMEZONE for it.
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
DTSTART;TZID=W. Europe Standard Time:20260301T090000
END:VTODO
"""))).contains("unknown time zone")
}
@Test fun `a solidus-prefixed TZID needs no local definition`() {
// Thunderbird writes this on every zoned task. §3.2.19 exempts the
// solidus form, and rejecting it would quarantine those tasks forever.
assertThat(reject(vcalendar("""
BEGIN:VTODO
UID:a
DTSTART;TZID=/mozilla.org/20050126_1/Europe/Berlin:20260301T090000
END:VTODO
"""))).isNull()
}
@Test fun `a TZID with its definition present is fine`() {
assertThat(reject(vcalendar("""
BEGIN:VTIMEZONE
TZID:Europe/Berlin
END:VTIMEZONE
BEGIN:VTODO
UID:a
DTSTART;TZID=Europe/Berlin:20260301T090000
END:VTODO
"""))).isNull()
}
@Test fun `a calendar with no task is refused`() {
assertThat(reject(vcalendar(""))).contains("no VTODO")
}
private fun reject(text: String): String? =
ResourceValidator.validate(ICalParser.parse(crlf(text)))?.reason
private fun vcalendar(body: String) =
"BEGIN:VCALENDAR\nVERSION:2.0\n$body\nEND:VCALENDAR"
/** Indentation-insensitive, so the fixtures can be written inline. */
private fun crlf(text: String) = text.lines()
.map { it.trim() }
.filter { it.isNotEmpty() }
.joinToString("\r\n") + "\r\n"
}
@@ -0,0 +1,386 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import de.jeanlucmakiola.agendula.domain.PRIORITY_NONE
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class VTodoMapperTest {
private fun vtodo(vararg lines: String): ICalComponent =
ICalParser.parse((listOf("BEGIN:VTODO", "UID:t-1") + lines + "END:VTODO").joinToString("\r\n"))
@Nested
inner class Reading {
@Test
fun `PRIORITY is stored raw, because bucketing on the way in loses it`() {
// Priority.HIGH folds 1-4 into one bucket, so rewriting a server's
// PRIORITY:3 as 1 would be a silent edit on the next write-back.
assertThat(VTodoMapper.read(vtodo("PRIORITY:3")).entity.priority).isEqualTo(3)
}
@Test
fun `an out-of-range PRIORITY falls back to none`() {
assertThat(VTodoMapper.read(vtodo("PRIORITY:42")).entity.priority).isEqualTo(PRIORITY_NONE)
}
@Test
fun `an unrecognised STATUS is not flattened to NEEDS-ACTION`() {
val mapped = VTodoMapper.read(vtodo("STATUS:X-DEFERRED"))
assertThat(mapped.entity.status).isEqualTo(TaskStatus.NEEDS_ACTION)
// It survives in the residue instead of being rewritten.
assertThat(mapped.entity.unknownProperties).contains("STATUS:X-DEFERRED")
}
@Test
fun `RELATED-TO points up - the referencing component is the subordinate one`() {
val mapped = VTodoMapper.read(vtodo("RELATED-TO;RELTYPE=PARENT:parent-uid"))
assertThat(mapped.parentUid).isEqualTo("parent-uid")
}
@Test
fun `RELTYPE defaults to PARENT when absent`() {
assertThat(VTodoMapper.read(vtodo("RELATED-TO:parent-uid")).parentUid).isEqualTo("parent-uid")
}
@Test
fun `a RELATED-TO with another RELTYPE is somebody else's relationship`() {
val mapped = VTodoMapper.read(vtodo("RELATED-TO;RELTYPE=SIBLING:other-uid"))
assertThat(mapped.parentUid).isNull()
assertThat(mapped.entity.unknownProperties).contains("RELTYPE=SIBLING")
}
@Test
fun `a parent link is reported but left in the residue`() {
// TaskEntity holds only a local parent_id, so a parent that is not in
// this store would leave nothing to write back. Keeping the property
// means the relationship survives a parent we have not fetched.
val mapped = VTodoMapper.read(
vtodo("RELATED-TO:parent-uid", "RELATED-TO;RELTYPE=SIBLING:other-uid"),
)
assertThat(mapped.parentUid).isEqualTo("parent-uid")
assertThat(mapped.entity.unknownProperties).contains("parent-uid")
assertThat(mapped.entity.unknownProperties).contains("other-uid")
}
@Test
fun `a malformed scalar stays in the residue instead of being clamped`() {
val mapped = VTodoMapper.read(
vtodo("PRIORITY:11", "PERCENT-COMPLETE:150", "SEQUENCE:x"),
)
assertThat(mapped.entity.priority).isEqualTo(PRIORITY_NONE)
assertThat(mapped.entity.percentComplete).isNull()
assertThat(mapped.entity.sequence).isEqualTo(0)
val residue = mapped.entity.unknownProperties.orEmpty()
assertThat(residue).contains("PRIORITY:11")
assertThat(residue).contains("PERCENT-COMPLETE:150")
assertThat(residue).contains("SEQUENCE:x")
}
@Test
fun `a mixed value-type pair keeps the odd one out in the residue`() {
// One is_all_day flag cannot author a DATE start and a timed due, and
// flattening both to dates destroys the 09:00.
val mapped = VTodoMapper.read(
vtodo("DTSTART;VALUE=DATE:20260901", "DUE;TZID=Europe/Berlin:20260901T090000"),
)
assertThat(mapped.entity.isAllDay).isFalse()
assertThat(mapped.entity.unknownProperties).contains("DTSTART;VALUE=DATE:20260901")
}
@Test
fun `a second timezone stays in the residue rather than being collapsed`() {
val mapped = VTodoMapper.read(
vtodo(
"DTSTART;TZID=Europe/Berlin:20260901T080000",
"DUE;TZID=America/New_York:20260901T140000",
),
)
assertThat(mapped.entity.timezone).isEqualTo("Europe/Berlin")
assertThat(mapped.entity.unknownProperties).contains("America/New_York")
}
@Test
fun `DTSTAMP is dropped, LAST-MODIFIED is kept`() {
val mapped = VTodoMapper.read(
vtodo("DTSTAMP:20260901T120000Z", "LAST-MODIFIED:20260801T090000Z"),
)
assertThat(mapped.entity.unknownProperties.orEmpty()).doesNotContain("DTSTAMP")
assertThat(mapped.entity.lastModified).isNotNull()
}
@Test
fun `a missing UID is reported rather than invented`() {
val bare = ICalParser.parse("BEGIN:VTODO\r\nSUMMARY:no uid\r\nEND:VTODO")
assertThat(VTodoMapper.read(bare).uidWasMissing).isTrue()
}
@Test
fun `a DUE date with no DTSTART still reads as all-day`() {
val mapped = VTodoMapper.read(vtodo("DUE;VALUE=DATE:20260901"))
assertThat(mapped.entity.isAllDay).isTrue()
assertThat(mapped.entity.due).isNotNull()
}
@Test
fun `every EXDATE line reaches the column the expander reads`() {
// ⚠️ Cardinality-many, and Apple Calendar writes one line per
// excluded occurrence. Reading only the first left the rest out of
// expansion, so a deleted occurrence came back in the list and in
// its reminders — while round-tripping to the server perfectly out
// of the residue, which is what made it invisible.
val mapped = VTodoMapper.read(
vtodo(
"DTSTART:20260101T090000Z",
"RRULE:FREQ=DAILY",
"EXDATE:20260102T090000Z",
"EXDATE:20260103T090000Z",
),
)
assertThat(mapped.entity.exdate)
.isEqualTo("20260102T090000Z,20260103T090000Z")
}
@Test
fun `a parameterised EXDATE keeps its parameters by staying in the residue`() {
// Claiming it would drop the TZID for good: the property never
// reaches the residue and write re-emits it naked, so the exclusion
// stops matching the instance it excluded.
val mapped = VTodoMapper.read(
vtodo(
"DTSTART;TZID=Europe/Berlin:20260101T090000",
"RRULE:FREQ=DAILY",
"EXDATE;TZID=Europe/Berlin:20260102T090000",
),
)
assertThat(mapped.entity.exdate).isEqualTo("20260102T090000")
assertThat(mapped.entity.unknownProperties)
.contains("EXDATE;TZID=Europe/Berlin:20260102T090000")
}
}
@Nested
inner class Writing {
@Test
fun `an unchanged parameterised EXDATE is written once, from the residue`() {
val mapped = VTodoMapper.read(
vtodo(
"DTSTART;TZID=Europe/Berlin:20260101T090000",
"RRULE:FREQ=DAILY",
"EXDATE;TZID=Europe/Berlin:20260102T090000",
),
)
val written = VTodoMapper.write(mapped.entity)
val exdates = written.properties("EXDATE")
assertThat(exdates).hasSize(1)
assertThat(exdates.single().param("TZID")).isEqualTo("Europe/Berlin")
}
@Test
fun `a changed EXDATE is authored with the parameters the residue gives up`() {
val mapped = VTodoMapper.read(
vtodo(
"DTSTART;TZID=Europe/Berlin:20260101T090000",
"RRULE:FREQ=DAILY",
"EXDATE;TZID=Europe/Berlin:20260102T090000",
),
)
// What deleting a second occurrence leaves behind.
val excepted = mapped.entity.copy(
exdate = "${mapped.entity.exdate},20260103T090000",
)
val written = VTodoMapper.write(excepted)
val exdate = written.properties("EXDATE").single()
assertThat(exdate.value).isEqualTo("20260102T090000,20260103T090000")
assertThat(exdate.param("TZID")).isEqualTo("Europe/Berlin")
}
@Test
fun `a date-shaped EXDATE we author carries VALUE=DATE`() {
// §3.3.5's default is DATE-TIME, so a bare eight-digit value is
// malformed — a permanent 415 under Prefer: handling=strict.
val entity = entity().copy(isAllDay = true, exdate = "20260102")
val exdate = VTodoMapper.write(entity).properties("EXDATE").single()
assertThat(exdate.param("VALUE")).isEqualTo("DATE")
}
@Test
fun `SEQUENCE is preserved verbatim and never bumped`() {
// It is the Organizer's revision counter (RFC 5545 section 3.8.7.4);
// a client that increments it confuses scheduling-aware peers.
val entity = entity(sequence = 7)
assertThat(VTodoMapper.write(entity).property("SEQUENCE")?.value).isEqualTo("7")
}
@Test
fun `COMPLETED is written in UTC, with no TZID and no DATE form`() {
val entity = entity(
completedAt = Instant.parse("2026-09-01T19:00:00Z"),
isAllDay = true,
timezone = "Europe/Berlin",
)
val completed = VTodoMapper.write(entity).property("COMPLETED")
assertThat(completed?.value).isEqualTo("20260901T190000Z")
assertThat(completed?.params).isEmpty()
}
@Test
fun `an all-day task writes VALUE=DATE`() {
val entity = entity(due = Instant.parse("2026-09-01T00:00:00Z"), isAllDay = true)
val due = VTodoMapper.write(entity).property("DUE")
assertThat(due?.param("VALUE")).isEqualTo("DATE")
assertThat(due?.value).isEqualTo("20260901")
}
@Test
fun `an unedited residue property suppresses the column it came from`() {
val entity = entity(
// What read() would have stored: the best-effort reading of the
// unknown-zone value it could not reproduce.
due = Instant.parse("2026-09-10T17:00:00Z"),
unknownProperties = "DUE;TZID=Custom/HQ:20260910T170000\r\n",
)
val written = VTodoMapper.write(entity)
assertThat(written.properties("DUE")).hasSize(1)
assertThat(written.property("DUE")?.param("TZID")).isEqualTo("Custom/HQ")
}
@Test
fun `an edited column evicts the stale residue copy`() {
// Without eviction, changing the due date of a task imported with an
// unreproducible stamp would silently do nothing on the server.
val entity = entity(
due = Instant.parse("2026-09-20T17:00:00Z"),
unknownProperties = "DUE;TZID=Custom/HQ:20260910T170000\r\n",
)
val written = VTodoMapper.write(entity)
assertThat(written.properties("DUE")).hasSize(1)
assertThat(written.property("DUE")?.value).isEqualTo("20260920T170000Z")
}
@Test
fun `an unreadable STATUS in the residue is not doubled by the default`() {
// STATUS has cardinality one; authoring NEEDS-ACTION next to the
// residue copy produces a resource with two of them.
val entity = entity(unknownProperties = "STATUS:X-DEFERRED\r\n")
val written = VTodoMapper.write(entity)
assertThat(written.properties("STATUS")).hasSize(1)
assertThat(written.property("STATUS")?.value).isEqualTo("X-DEFERRED")
}
@Test
fun `a locally set status evicts the unreadable one`() {
val entity = entity(unknownProperties = "STATUS:X-DEFERRED\r\n")
.copy(status = TaskStatus.COMPLETED)
val written = VTodoMapper.write(entity)
assertThat(written.properties("STATUS")).hasSize(1)
assertThat(written.property("STATUS")?.value).isEqualTo("COMPLETED")
}
@Test
fun `a timezone the device cannot resolve never becomes TZID on a UTC value`() {
// RFC 5545 section 3.3.5 forbids TZID on a `...Z` value, and sabre
// answers 415 for it.
val entity = entity(due = Instant.parse("2026-09-05T05:30:00Z"), timezone = "Custom/Nope")
val due = VTodoMapper.write(entity).property("DUE")
assertThat(due?.params).isEmpty()
assertThat(due?.value).isEqualTo("20260905T053000Z")
}
@Test
fun `PRIORITY 0 is omitted, because 0 means undefined`() {
assertThat(VTodoMapper.write(entity()).property("PRIORITY")).isNull()
}
}
@Nested
inner class Validation {
@Test
fun `DUE before DTSTART is caught here, not as a 415 from sabre`() {
val problems = VTodoMapper.validate(
vtodo("DTSTART:20260910T100000Z", "DUE:20260901T100000Z"),
)
assertThat(problems).contains("DUE precedes DTSTART")
}
@Test
fun `DTSTART and DUE must agree on value type`() {
val problems = VTodoMapper.validate(
vtodo("DTSTART;VALUE=DATE:20260901", "DUE:20260910T100000Z"),
)
assertThat(problems).contains("DTSTART and DUE disagree on value type")
}
@Test
fun `an end-relative alarm with no DUE is invalid, and reachable from the UI`() {
// Clearing the due date on a task that has an end-relative reminder is
// an ordinary UI action that produces a permanently rejected resource.
val withAlarm = ICalParser.parse(
"""
BEGIN:VTODO
UID:t-1
SUMMARY:x
BEGIN:VALARM
ACTION:DISPLAY
TRIGGER;RELATED=END:-PT15M
END:VALARM
END:VTODO
""".trimIndent(),
)
assertThat(VTodoMapper.validate(withAlarm))
.contains("TRIGGER;RELATED=END with neither DUE nor DTSTART+DURATION")
}
@Test
fun `METHOD is rejected, because sabre answers 415 for it`() {
assertThat(VTodoMapper.validate(vtodo("METHOD:REQUEST")))
.contains("METHOD is not allowed on a stored resource")
}
@Test
fun `an implicit DATE is recognised as one, so value types do not falsely disagree`() {
// A bare 8-digit value with no T is a DATE whether or not VALUE=DATE
// says so. Two different date tests would block a legitimate PUT.
assertThat(
VTodoMapper.validate(vtodo("DTSTART:20260101", "DUE;VALUE=DATE:20260102")),
).isEmpty()
}
@Test
fun `a well-formed task has no problems`() {
assertThat(
VTodoMapper.validate(vtodo("DTSTART:20260901T100000Z", "DUE:20260910T100000Z")),
).isEmpty()
}
}
private fun entity(
sequence: Int = 0,
completedAt: Instant? = null,
due: Instant? = null,
isAllDay: Boolean = false,
timezone: String? = null,
unknownProperties: String? = null,
) = TaskEntity(
listId = 1L,
uid = "t-1",
title = "A task",
sequence = sequence,
completedAt = completedAt,
due = due,
isAllDay = isAllDay,
timezone = timezone,
unknownProperties = unknownProperties,
)
}
@@ -0,0 +1,161 @@
package de.jeanlucmakiola.agendula.data.tasks.ical
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.ical.ICalComponent
import de.jeanlucmakiola.agendula.domain.ical.ICalParser
import de.jeanlucmakiola.agendula.domain.ical.ICalSerializer
import org.junit.jupiter.api.DynamicTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestFactory
import kotlin.time.Instant
/**
* The corpus in `app/src/test/resources/vtodo/` is the mapper's specification.
*
* The requirement being encoded is RFC 5545 §3.1: *"Applications MUST preserve
* the value data for x-name and iana-token values that they don't recognize."*
* Failing it destroys other people's data invisibly — invisible in our own UI
* precisely because we are the client that does not understand the property.
*/
class VTodoRoundTripTest {
private val fixtures = listOf(
"nextcloud-shared-confidential",
"recurring-master-overrides",
"valarm-unknown-nested",
"unknown-component",
"unknown-tzid",
"uid-special-chars",
"rrule-due-no-dtstart",
"moz-alarm-props",
"apple-sort-order",
"floating-time",
"completion-model-a",
"completion-model-b",
"completion-model-c",
"completion-model-d",
"quoted-param-colon",
"folded-utf8",
"mixed-value-types",
"malformed-scalars",
"two-timezones",
)
@TestFactory
fun `every fixture round-trips without losing a property`(): List<DynamicTest> =
fixtures.map { name ->
DynamicTest.dynamicTest(name) {
val todos = vtodosOf(name)
assertThat(todos).isNotEmpty()
todos.forEach { original ->
val roundTripped = roundTrip(original)
assertThat(ICalCanonical.lost(original, roundTripped)).isEmpty()
assertThat(ICalCanonical.invented(original, roundTripped)).isEmpty()
}
}
}
@Test
fun `an unknown property nested inside a VALARM survives`() {
val original = vtodosOf("valarm-unknown-nested").single()
val roundTripped = roundTrip(original)
val alarm = roundTripped.components("VALARM").single()
assertThat(alarm.property("X-WR-ALARMUID")?.value)
.isEqualTo("8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11")
// RFC 9074's alarm properties are what other clients now write; a flat
// property-per-row model would have dropped this one with the nesting.
assertThat(alarm.property("ACKNOWLEDGED")?.value).isEqualTo("20260901T113000Z")
}
@Test
fun `an entirely unknown sub-component survives`() {
val roundTripped = roundTrip(vtodosOf("unknown-component").single())
val vendor = roundTripped.components("X-VENDOR-METADATA").single()
assertThat(vendor.property("X-VENDOR-KEY")?.value).isEqualTo("project-alpha")
}
@Test
fun `a TZID the device does not know is re-emitted verbatim, not guessed`() {
val original = vtodosOf("unknown-tzid").single()
val mapped = VTodoMapper.read(original)
val roundTripped = roundTrip(original)
val due = roundTripped.property("DUE")
assertThat(due?.param("TZID")).isEqualTo("Custom/Company-HQ")
assertThat(due?.value).isEqualTo("20260910T170000")
// Exactly one DUE: the residue owns it, so the mapper must not also
// author one from its column.
assertThat(roundTripped.properties("DUE")).hasSize(1)
// The column still gets a best-effort instant so the UI has something.
assertThat(mapped.entity.due).isNotNull()
}
@Test
fun `a floating time is re-emitted verbatim, not pinned to a zone`() {
val roundTripped = roundTrip(vtodosOf("floating-time").single())
assertThat(roundTripped.property("DTSTART")?.value).isEqualTo("20260905T070000")
assertThat(roundTripped.property("DTSTART")?.params).isEmpty()
assertThat(roundTripped.properties("DTSTART")).hasSize(1)
}
@Test
fun `X-MOZ alarm bookkeeping survives, because dropping it causes alarm storms`() {
val roundTripped = roundTrip(vtodosOf("moz-alarm-props").single())
assertThat(roundTripped.property("X-MOZ-LASTACK")?.value).isEqualTo("20260904T090000Z")
assertThat(roundTripped.property("X-MOZ-SNOOZE-TIME")?.value).isEqualTo("20260905T093000Z")
}
@Test
fun `X-APPLE-SORT-ORDER is preserved and never interpreted`() {
val original = vtodosOf("apple-sort-order").single()
val mapped = VTodoMapper.read(original)
assertThat(roundTrip(original).property("X-APPLE-SORT-ORDER")?.value).isEqualTo("734829163")
// Ours is local ordering and has nothing to do with Apple's.
assertThat(mapped.entity.sortOrder).isEqualTo(0)
}
@Test
fun `a quoted parameter containing a colon and a comma survives`() {
val roundTripped = roundTrip(vtodosOf("quoted-param-colon").single())
val attendee = roundTripped.property("ATTENDEE")
assertThat(attendee?.param("CN")).isEqualTo("Smith, J:r")
assertThat(attendee?.value).isEqualTo("mailto:jr@example.com")
// And it is re-quoted on the way out, or nothing can read it back.
assertThat(ICalSerializer.render(attendee!!)).contains("CN=\"Smith, J:r\"")
}
@Test
fun `residue past the size cap is dropped loudly rather than truncated`() {
val huge = "X-BULK:" + "a".repeat(VTodoMapper.MAX_RESIDUE_BYTES + 1)
val vtodo = ICalParser.parse(
"BEGIN:VTODO\r\nUID:big-001\r\nSUMMARY:Big\r\n$huge\r\nEND:VTODO\r\n",
)
val mapped = VTodoMapper.read(vtodo)
assertThat(mapped.droppedResidue).isTrue()
assertThat(mapped.entity.unknownProperties).isNull()
}
private fun roundTrip(vtodo: ICalComponent): ICalComponent {
val mapped = VTodoMapper.read(vtodo)
val written = VTodoMapper.write(
entity = mapped.entity,
parentUid = mapped.parentUid,
now = Instant.fromEpochMilliseconds(0),
)
// Serialise and re-parse rather than comparing the tree directly: folding
// and parameter quoting are where a serialiser loses data, and skipping
// the text would skip exactly that.
return ICalParser.parse(
"BEGIN:VCALENDAR\r\n" + ICalSerializer.serialize(written) + "END:VCALENDAR\r\n",
).components("VTODO").single()
}
private fun vtodosOf(name: String): List<ICalComponent> {
val text = checkNotNull(javaClass.getResourceAsStream("/vtodo/$name.ics")) {
"missing fixture $name.ics"
}.bufferedReader().readText()
return ICalParser.parse(text).components("VTODO")
}
}
@@ -0,0 +1,53 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.priorityFromICal
import de.jeanlucmakiola.agendula.domain.toICal
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class ConvertersTest {
@Test
fun `round-trips an instant through epoch millis`() {
val value = Instant.fromEpochMilliseconds(1_700_000_000_123)
val stored = Converters.instantToMillis(value)
assertThat(stored).isEqualTo(1_700_000_000_123)
assertThat(Converters.instantFromMillis(stored)).isEqualTo(value)
}
@Test
fun `maps null time both ways`() {
assertThat(Converters.instantToMillis(null)).isNull()
assertThat(Converters.instantFromMillis(null)).isNull()
}
@Test
fun `round-trips every status through the domain encoding`() {
TaskStatus.entries.forEach { status ->
assertThat(Converters.statusFrom(Converters.statusToInt(status))).isEqualTo(status)
}
}
@Test
fun `priority is stored raw, so an off-bucket value survives`() {
// There is no priority converter on purpose: PRIORITY:3 is a legitimate
// value a server can send, and Priority buckets 1..4 into HIGH. Bucketing
// on the way in would rewrite it as 1 and lose it on the next round-trip.
assertThat(priorityFromICal(3)).isEqualTo(Priority.HIGH)
assertThat(Priority.HIGH.toICal()).isEqualTo(1)
}
@Test
fun `round-trips an alarm reference and falls back on an unknown one`() {
AlarmReference.entries.forEach { reference ->
assertThat(Converters.alarmReferenceFrom(Converters.alarmReferenceToString(reference)))
.isEqualTo(reference)
}
assertThat(Converters.alarmReferenceFrom("NONSENSE")).isEqualTo(AlarmReference.DUE)
}
}
@@ -0,0 +1,30 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class RoomTaskMapperTest {
@Test
fun `a task in a read-only share says so`() {
val row = TaskRow(
task = TaskEntity(id = 1, listId = 2, uid = "u"),
listName = "Shared",
listColor = 0,
accountDisplayName = "me@host",
listReadOnly = true,
)
assertThat(RoomTaskMapper.task(row).isReadOnly).isTrue()
}
@Test
fun `a read-only list does not accept writes`() {
val list = TaskListRow(
list = TaskListEntity(id = 2, name = "Shared", color = 0, accountId = 1, isReadOnly = true),
accountDisplayName = "me@host",
)
assertThat(RoomTaskMapper.taskList(list).acceptsWrites).isFalse()
}
}
@@ -0,0 +1,121 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.TaskForm
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours
import kotlin.time.Instant
class TaskFormWriterSeriesTest {
private val now = Instant.parse("2026-09-23T10:00:00Z")
private val anchor = Instant.parse("2026-09-01T09:00:00Z")
private val master = TaskEntity(
id = 7,
listId = 1,
uid = "series",
title = "Water plants",
dtstart = anchor,
due = anchor + 1.hours,
rrule = "FREQ=DAILY;COUNT=30",
timezone = "Europe/Berlin",
)
@Test
fun `ending a series before an occurrence swaps COUNT for UNTIL`() {
val ended = TaskFormWriter.endedBefore(master, anchor + 10.days, now, "Europe/Berlin")
assertThat(ended.rrule).isEqualTo("FREQ=DAILY;UNTIL=20260911T085959Z")
assertThat(ended.isDirty).isTrue()
}
@Test
fun `an all-day series ends on the day before, as a DATE`() {
val allDay = master.copy(isAllDay = true, dtstart = Instant.parse("2026-09-01T00:00:00Z"), due = null)
val ended = TaskFormWriter.endedBefore(allDay, Instant.parse("2026-09-11T00:00:00Z"), now, "Europe/Berlin")
assertThat(ended.rrule).isEqualTo("FREQ=DAILY;UNTIL=20260910")
}
@Test
fun `the continuing series only counts what is left`() {
assertThat(TaskFormWriter.remainingCount("FREQ=DAILY;COUNT=30", 10)).isEqualTo("FREQ=DAILY;COUNT=20")
assertThat(TaskFormWriter.remainingCount("FREQ=WEEKLY", 10)).isEqualTo("FREQ=WEEKLY")
}
@Test
fun `moving one occurrence for the whole series moves the anchor by as much`() {
val occurrenceStart = anchor + 5.days
val form = TaskForm(
title = "Water all plants",
listId = 1,
start = occurrenceStart + 2.hours,
due = occurrenceStart + 3.hours,
rrule = master.rrule,
)
val edited = TaskFormWriter.seriesEdit(master, occurrenceStart, occurrenceStart + 1.hours, form, now, "Europe/Berlin")
assertThat(edited.title).isEqualTo("Water all plants")
assertThat(edited.dtstart).isEqualTo(anchor + 2.hours)
assertThat(edited.due).isEqualTo(anchor + 3.hours)
assertThat(edited.rrule).isEqualTo(master.rrule)
}
@Test
fun `dropping the rule ends the series and its exceptions`() {
val withExceptions = master.copy(exdate = "20260905T090000Z")
val plain = TaskFormWriter.withRule(withExceptions, null)
assertThat(plain.rrule).isNull()
assertThat(plain.exdate).isNull()
}
@Test
fun `an override never takes a rule`() {
val override = master.copy(id = 9, masterId = 7, rrule = null)
assertThat(TaskFormWriter.withRule(override, "FREQ=DAILY").rrule).isNull()
}
@Test
fun `a floating series ends on a floating UNTIL and drops later RDATEs`() {
val floating = master.copy(timezone = null, rdate = "20260905T110000,20260920T110000")
val ended = TaskFormWriter.endedBefore(floating, Instant.parse("2026-09-11T07:00:00Z"), now, "Europe/Berlin")
assertThat(ended.rrule).isEqualTo("FREQ=DAILY;UNTIL=20260911T085959")
assertThat(ended.rdate).isEqualTo("20260905T110000")
}
@Test
fun `a series moved in time takes its exceptions along`() {
val withException = master.copy(exdate = "20260905T090000Z")
val occurrenceStart = anchor + 1.days
val form = TaskForm(
title = master.title!!,
listId = 1,
start = occurrenceStart + 1.hours,
due = occurrenceStart + 2.hours,
rrule = master.rrule,
)
val edited = TaskFormWriter.seriesEdit(withException, occurrenceStart, occurrenceStart + 1.hours, form, now, "Europe/Berlin")
assertThat(edited.exdate).isEqualTo("20260905T100000Z")
}
@Test
fun `editing the series through a completed occurrence leaves the series open`() {
val occurrenceStart = anchor + 1.days
val form = TaskForm(title = "x", listId = 1, start = occurrenceStart, due = occurrenceStart + 1.hours, percentComplete = 100, rrule = master.rrule)
val edited = TaskFormWriter.seriesEdit(master, occurrenceStart, occurrenceStart + 1.hours, form, now, "Europe/Berlin")
assertThat(edited.status).isEqualTo(master.status)
assertThat(edited.completedAt).isNull()
}
}
@@ -0,0 +1,218 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskForm
import de.jeanlucmakiola.agendula.domain.TaskStatus
import org.junit.jupiter.api.Test
import kotlin.time.Instant
private val NOW = Instant.fromEpochMilliseconds(1_768_467_600_000)
private const val ZONE = "Europe/Berlin"
private fun task(
status: TaskStatus = TaskStatus.NEEDS_ACTION,
percentComplete: Int? = null,
completedAt: Instant? = null,
) = TaskEntity(
id = 1,
listId = 1,
uid = "uid-1",
status = status,
percentComplete = percentComplete,
completedAt = completedAt,
)
private fun form(
title: String = "task",
percentComplete: Int? = null,
start: Instant? = null,
due: Instant? = null,
isAllDay: Boolean = false,
) = TaskForm(
title = title,
listId = 1,
percentComplete = percentComplete,
start = start,
due = due,
isAllDay = isAllDay,
)
class TaskFormWriterTest {
@Test
fun `mints the task with its uid and creation time`() {
val entity = TaskFormWriter.newTask(form(title = " Buy milk "), "uid-9", NOW, ZONE)
assertThat(entity.uid).isEqualTo("uid-9")
assertThat(entity.title).isEqualTo("Buy milk")
assertThat(entity.createdAt).isEqualTo(NOW)
assertThat(entity.lastModified).isEqualTo(NOW)
assertThat(entity.isDirty).isTrue()
}
@Test
fun `progress and status move together in both directions`() {
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 100), NOW, ZONE).status)
.isEqualTo(TaskStatus.COMPLETED)
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 40), NOW, ZONE).status)
.isEqualTo(TaskStatus.IN_PROCESS)
assertThat(TaskFormWriter.apply(task(), form(percentComplete = 0), NOW, ZONE).status)
.isEqualTo(TaskStatus.NEEDS_ACTION)
}
@Test
fun `dropping below 100 percent reopens the task`() {
// The provider auto-completed at 100% but would not reopen below it, which
// stranded a task "done at 75%". TaskWriteMapper works around that for
// External mode; on our own store the rule is simply symmetric.
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW)
val reopened = TaskFormWriter.apply(completed, form(percentComplete = 75), NOW, ZONE)
assertThat(reopened.status).isEqualTo(TaskStatus.IN_PROCESS)
assertThat(reopened.completedAt).isNull()
}
@Test
fun `a form with no percent leaves the completion state alone`() {
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = NOW)
val saved = TaskFormWriter.apply(completed, form(title = "renamed"), NOW, ZONE)
assertThat(saved.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(saved.completedAt).isEqualTo(NOW)
}
@Test
fun `re-saving a finished task keeps its original completion time`() {
val earlier = Instant.fromEpochMilliseconds(1_000_000)
val completed = task(status = TaskStatus.COMPLETED, percentComplete = 100, completedAt = earlier)
val saved = TaskFormWriter.apply(completed, form(percentComplete = 100), NOW, ZONE)
assertThat(saved.completedAt).isEqualTo(earlier)
}
@Test
fun `all-day times are pinned to UTC midnight`() {
// Date-only in iCalendar. Storing a local-midnight instant would land on the
// previous day for anyone west of UTC.
val midMorning = Instant.fromEpochMilliseconds(1_768_467_600_000)
val saved = TaskFormWriter.apply(
task(),
form(start = midMorning, due = midMorning, isAllDay = true),
NOW,
ZONE,
)
assertThat(saved.dtstart!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0)
assertThat(saved.due!!.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0)
assertThat(saved.timezone).isNull()
}
@Test
fun `a timed task records the zone, an undated one does not`() {
val timed = TaskFormWriter.apply(task(), form(due = NOW), NOW, ZONE)
assertThat(timed.timezone).isEqualTo(ZONE)
val undated = TaskFormWriter.apply(task(), form(), NOW, ZONE)
assertThat(undated.timezone).isNull()
}
@Test
fun `writing a due date clears any duration`() {
// RFC 5545 §3.6.2: DUE and DURATION are mutually exclusive.
val withDuration = task().copy(duration = "PT1H")
assertThat(TaskFormWriter.apply(withDuration, form(due = NOW), NOW, ZONE).duration).isNull()
}
@Test
fun `the complete toggle sets and clears the whole triple`() {
val done = TaskFormWriter.completed(task(), completed = true, now = NOW)
assertThat(done.status).isEqualTo(TaskStatus.COMPLETED)
assertThat(done.percentComplete).isEqualTo(100)
assertThat(done.completedAt).isEqualTo(NOW)
val reopened = TaskFormWriter.completed(done, completed = false, now = NOW)
assertThat(reopened.status).isEqualTo(TaskStatus.NEEDS_ACTION)
assertThat(reopened.percentComplete).isNull()
assertThat(reopened.completedAt).isNull()
}
@Test
fun `priority is written as the raw iCalendar integer`() {
assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.HIGH), NOW, ZONE).priority)
.isEqualTo(1)
assertThat(TaskFormWriter.apply(task(), form().copy(priority = Priority.NONE), NOW, ZONE).priority)
.isEqualTo(0)
}
@Test
fun `deleting one occurrence writes the exception onto the master`() {
val master = task().copy(rrule = "FREQ=DAILY", timezone = ZONE)
val occurrence = Instant.parse("2026-03-01T09:00:00Z")
val excepted = TaskFormWriter.excepting(master, occurrence, NOW, ZONE, dirty = true)
// ⚠️ Dropping the override row un-overrides the occurrence; the RRULE
// then regenerates it. Only an EXDATE takes it out of the set.
assertThat(excepted.exdate).isEqualTo("20260301T090000Z")
assertThat(excepted.isDirty).isTrue()
assertThat(excepted.lastModified).isEqualTo(NOW)
}
@Test
fun `a device-only list gets the exception without being made dirty`() {
val master = task().copy(rrule = "FREQ=DAILY")
val excepted = TaskFormWriter.excepting(
master,
Instant.parse("2026-03-01T09:00:00Z"),
NOW,
ZONE,
dirty = false,
)
// The occurrence still has to be hidden — there is no tombstone doing it.
assertThat(excepted.exdate).isNotNull()
assertThat(excepted.isDirty).isFalse()
}
@Test
fun `an exception is written in the shape the list already has`() {
val allDay = task().copy(isAllDay = true, exdate = "20260228")
val occurrence = Instant.parse("2026-03-01T00:00:00Z")
// ⚠️ lib-recur parses the whole EXDATE list or none of it, so a UTC
// date-time appended to a run of DATEs drops every exception the series
// had, this one included.
assertThat(TaskFormWriter.excepting(allDay, occurrence, NOW, ZONE, dirty = true).exdate)
.isEqualTo("20260228,20260301")
val floating = task().copy(timezone = ZONE, exdate = "20260228T100000")
assertThat(
TaskFormWriter.excepting(
floating,
Instant.parse("2026-03-01T09:00:00Z"),
NOW,
ZONE,
dirty = true,
).exdate,
).isEqualTo("20260228T100000,20260301T100000")
}
@Test
fun `an occurrence already excepted is not added twice`() {
val master = task().copy(exdate = "20260301T090000Z")
val same = TaskFormWriter.excepting(
master,
Instant.parse("2026-03-01T09:00:00Z"),
NOW,
ZONE,
dirty = true,
)
assertThat(same).isSameInstanceAs(master)
}
}
@@ -0,0 +1,127 @@
package de.jeanlucmakiola.agendula.data.tasks.room
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class TaskMoveTest {
private val synced = TaskEntity(
id = 10,
listId = SOURCE,
uid = "u",
title = "Milk",
href = "https://s/a/u.ics",
etag = "e1",
parentId = 3,
)
@Test
fun `a synced task leaves a tombstone and arrives with no href`() {
val plan = TaskMove.plan(
edited = synced.copy(listId = TARGET),
previous = synced,
overrides = emptyList(),
sourceSynced = true,
targetTombstone = null,
)
val moved = plan.update.single()
assertThat(moved.id).isEqualTo(10)
assertThat(moved.listId).isEqualTo(TARGET)
assertThat(moved.href).isNull()
assertThat(moved.etag).isNull()
assertThat(moved.isDirty).isTrue()
// The old collection is owed a conditional DELETE of the old resource.
val tombstone = plan.tombstone!!
assertThat(tombstone.id).isEqualTo(0)
assertThat(tombstone.listId).isEqualTo(SOURCE)
assertThat(tombstone.href).isEqualTo("https://s/a/u.ics")
assertThat(tombstone.etag).isEqualTo("e1")
assertThat(tombstone.isDeleted).isTrue()
assertThat(tombstone.isDirty).isTrue()
assertThat(tombstone.parentId).isNull()
}
@Test
fun `overrides move with their master and tombstoned ones are dropped`() {
val live = override(11, deleted = false)
val buried = override(12, deleted = true)
val plan = TaskMove.plan(
edited = synced.copy(listId = TARGET),
previous = synced,
overrides = listOf(live, buried),
sourceSynced = true,
targetTombstone = null,
)
val movedOverride = plan.update.single { it.id == 11L }
assertThat(movedOverride.listId).isEqualTo(TARGET)
assertThat(movedOverride.href).isNull()
assertThat(movedOverride.isDirty).isTrue()
assertThat(plan.delete).containsExactly(12L)
}
@Test
fun `a task that never reached the server owes nothing`() {
val local = synced.copy(href = null, etag = null)
val plan = TaskMove.plan(local.copy(listId = TARGET), local, emptyList(), true, null)
assertThat(plan.tombstone).isNull()
}
@Test
fun `leaving a device-only list owes nothing`() {
// A list detached from a removed account keeps its hrefs, but no server
// will ever be asked to delete anything for it.
val plan = TaskMove.plan(synced.copy(listId = TARGET), synced, emptyList(), false, null)
assertThat(plan.tombstone).isNull()
assertThat(plan.update.single().href).isNull()
}
@Test
fun `moving back before a sync takes the old resource back`() {
val tombstone = synced.copy(id = 20, listId = TARGET, isDeleted = true, isDirty = true)
val elsewhere = synced.copy(listId = SOURCE, href = null, etag = null)
val plan = TaskMove.plan(
edited = elsewhere.copy(listId = TARGET),
previous = elsewhere,
overrides = emptyList(),
sourceSynced = true,
targetTombstone = tombstone,
)
val moved = plan.update.single()
assertThat(moved.href).isEqualTo("https://s/a/u.ics")
assertThat(moved.etag).isEqualTo("e1")
assertThat(plan.delete).containsExactly(20L)
assertThat(plan.tombstone).isNull()
}
@Test
fun `a live row with the same UID is not a tombstone to revive`() {
val live = synced.copy(id = 20, listId = TARGET)
val plan = TaskMove.plan(synced.copy(listId = TARGET), synced, emptyList(), true, live)
assertThat(plan.delete).isEmpty()
assertThat(plan.update.single().href).isNull()
}
private fun override(id: Long, deleted: Boolean) = synced.copy(
id = id,
masterId = synced.id,
recurrenceId = Instant.parse("2026-03-0${id - 9}T12:00:00Z"),
isDeleted = deleted,
)
private companion object {
const val SOURCE = 1L
const val TARGET = 2L
}
}
@@ -0,0 +1,206 @@
package de.jeanlucmakiola.agendula.data.tasks.transfer
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.data.tasks.room.TaskEntity
import org.junit.jupiter.api.Test
import java.io.ByteArrayOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class IcsImportTest {
private class FakeImportStore(seed: List<TaskEntity> = emptyList()) : IcsImportStore {
val rows = seed.toMutableList()
val lists = mutableMapOf<Long, String>()
private var nextId = 100L
override fun <T> transaction(block: () -> T): T = block()
override fun createList(name: String, color: Int): Long {
val id = 50L + lists.size
lists[id] = name
return id
}
override fun hasUid(listId: Long, uid: String) = rows.any { it.listId == listId && it.uid == uid }
override fun insert(row: TaskEntity): Long {
val id = nextId++
rows += row.copy(id = id)
return id
}
override fun masterByUid(listId: Long, uid: String) =
rows.firstOrNull { it.listId == listId && it.uid == uid && it.recurrenceId == null }
override fun setParent(taskId: Long, parentId: Long?) {
val index = rows.indexOfFirst { it.id == taskId }
rows[index] = rows[index].copy(parentId = parentId)
}
fun byUid(uid: String) = rows.filter { it.uid == uid }
}
private fun calendar(vararg components: String, name: String? = null) = buildString {
append("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n")
if (name != null) append("X-WR-CALNAME:$name\r\n")
components.forEach { append(it) }
append("END:VCALENDAR\r\n")
}
private fun vtodo(uid: String?, summary: String, vararg extra: String) = buildString {
append("BEGIN:VTODO\r\n")
if (uid != null) append("UID:$uid\r\n")
append("SUMMARY:$summary\r\n")
extra.forEach { append(it).append("\r\n") }
append("END:VTODO\r\n")
}
private val series = vtodo(
"series", "Water plants",
"DTSTART:20260601T080000Z",
"DUE:20260601T090000Z",
"RRULE:FREQ=WEEKLY;BYDAY=MO,TH",
"X-APPLE-SORT-ORDER:7",
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Water\r\nTRIGGER:-PT15M\r\nEND:VALARM",
)
private val override = vtodo(
"series", "Water plants (balcony)",
"RECURRENCE-ID:20260604T080000Z",
"DTSTART:20260604T100000Z",
"DUE:20260604T110000Z",
)
private val event = "BEGIN:VEVENT\r\nUID:ev\r\nSUMMARY:Meeting\r\nDTSTART:20260601T080000Z\r\nEND:VEVENT\r\n"
@Test
fun `a series lands with its override, rule, alarm and unknown properties`() {
val parsed = IcsImport.parse(listOf(IcsSource("plants.ics", calendar(series, override, event))))
val store = FakeImportStore()
val result = IcsImport.write(parsed, ImportTarget.Existing(1L), store)
assertThat(result.imported).isEqualTo(1)
assertThat(parsed.skippedComponents).isEqualTo(1)
val rows = store.byUid("series")
assertThat(rows).hasSize(2)
val master = rows.single { it.recurrenceId == null }
val child = rows.single { it.recurrenceId != null }
assertThat(master.rrule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,TH")
assertThat(master.unknownProperties).contains("X-APPLE-SORT-ORDER:7")
assertThat(master.unknownProperties).contains("BEGIN:VALARM")
assertThat(child.masterId).isEqualTo(master.id)
assertThat(child.title).isEqualTo("Water plants (balcony)")
assertThat(rows.all { it.listId == 1L && it.isDirty && it.href == null }).isTrue()
}
@Test
fun `a parent later in the file is still linked`() {
val child = vtodo("child", "Buy soil", "RELATED-TO;RELTYPE=PARENT:parent")
val parent = vtodo("parent", "Repot")
val store = FakeImportStore()
IcsImport.write(IcsImport.parse(listOf(IcsSource(null, calendar(child, parent)))), ImportTarget.Existing(1L), store)
val parentRow = store.byUid("parent").single()
assertThat(store.byUid("child").single().parentId).isEqualTo(parentRow.id)
}
@Test
fun `a parent already in the target list is linked too`() {
val existing = TaskEntity(id = 7L, listId = 1L, uid = "parent", title = "Repot")
val store = FakeImportStore(listOf(existing))
val child = vtodo("child", "Buy soil", "RELATED-TO:parent")
IcsImport.write(IcsImport.parse(listOf(IcsSource(null, calendar(child)))), ImportTarget.Existing(1L), store)
assertThat(store.byUid("child").single().parentId).isEqualTo(7L)
}
@Test
fun `UIDs the target list already holds are skipped`() {
val store = FakeImportStore(listOf(TaskEntity(id = 1L, listId = 1L, uid = "a", title = "Old")))
val parsed = IcsImport.parse(listOf(IcsSource(null, calendar(vtodo("a", "New"), vtodo("b", "Other")))))
val result = IcsImport.write(parsed, ImportTarget.Existing(1L), store)
assertThat(result.imported).isEqualTo(1)
assertThat(result.skippedExisting).isEqualTo(1)
assertThat(store.byUid("a").single().title).isEqualTo("Old")
}
@Test
fun `the same UID in another list is not a duplicate`() {
val store = FakeImportStore(listOf(TaskEntity(id = 1L, listId = 2L, uid = "a")))
val parsed = IcsImport.parse(listOf(IcsSource(null, calendar(vtodo("a", "New")))))
assertThat(IcsImport.write(parsed, ImportTarget.Existing(1L), store).imported).isEqualTo(1)
}
@Test
fun `a component without a UID gets one of its own`() {
val parsed = IcsImport.parse(listOf(IcsSource(null, calendar(vtodo(null, "One"), vtodo(null, "Two")))))
val store = FakeImportStore()
IcsImport.write(parsed, ImportTarget.Existing(1L), store)
assertThat(store.rows.map { it.uid }.distinct()).hasSize(2)
assertThat(store.rows.none { it.uid.isBlank() }).isTrue()
}
@Test
fun `a new list is created and named by the caller`() {
val parsed = IcsImport.parse(listOf(IcsSource(null, calendar(vtodo("a", "Task")))))
val store = FakeImportStore()
val result = IcsImport.write(parsed, ImportTarget.NewList(" Garden ", 0), store)
assertThat(store.lists[result.listId]).isEqualTo("Garden")
assertThat(store.rows.single().listId).isEqualTo(result.listId)
}
@Test
fun `a zip splits into its ics entries and repeats land once`() {
val zip = ByteArrayOutputStream().also { bytes ->
ZipOutputStream(bytes).use { out ->
out.putNextEntry(ZipEntry("Home-1.ics"))
out.write(calendar(vtodo("a", "A"), name = "Home").toByteArray())
out.putNextEntry(ZipEntry("Work-2.ics"))
out.write(calendar(vtodo("a", "A"), vtodo("b", "B"), name = "Work").toByteArray())
out.putNextEntry(ZipEntry("notes.txt"))
out.write("ignored".toByteArray())
}
}.toByteArray()
val sources = IcsImport.sourcesOf("agendula-tasks.zip", zip)
val parsed = IcsImport.parse(sources, "agendula-tasks.zip")
assertThat(sources.map { it.name }).containsExactly("Home-1.ics", "Work-2.ics")
assertThat(parsed.tasks.map { it.uid }).containsExactly("a", "b")
assertThat(parsed.duplicatesInFile).isEqualTo(1)
// The calendars disagree on a name, so the bundle's own name stands in.
assertThat(parsed.suggestedListName).isEqualTo("agendula-tasks")
}
@Test
fun `the calendar name wins over the file name`() {
val parsed = IcsImport.parse(
listOf(IcsSource("export.ics", calendar(vtodo("a", "A"), name = "Groceries"))),
)
assertThat(parsed.suggestedListName).isEqualTo("Groceries")
}
@Test
fun `a plain file with a byte-order mark still parses`() {
val bytes = ("" + calendar(vtodo("a", "A"))).toByteArray()
val parsed = IcsImport.parse(IcsImport.sourcesOf("a.ics", bytes))
assertThat(parsed.tasks).hasSize(1)
assertThat(parsed.suggestedListName).isEqualTo("a")
}
@Test
fun `garbage counts as unreadable rather than throwing`() {
val parsed = IcsImport.parse(listOf(IcsSource("x.ics", "not a calendar")))
assertThat(parsed.tasks).isEmpty()
assertThat(parsed.unreadableDocuments).isEqualTo(1)
}
}
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.ZoneId
import kotlin.time.Instant
class AllDayTimeTest {
private val berlin = ZoneId.of("Europe/Berlin") // UTC+2 in July
private val newYork = ZoneId.of("America/New_York") // UTC-4 in July
private val julyTwentieth = LocalDate.of(2026, 7, 20)
@Test
fun `an all-day instant is UTC midnight of its date`() {
val instant = allDayInstantOf(julyTwentieth)
assertThat(instant.toEpochMilliseconds() % (24L * 60 * 60 * 1000)).isEqualTo(0L)
assertThat(instant.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
}
@Test
fun `an all-day date reads the same everywhere, unlike a timed one`() {
val allDay = allDayInstantOf(julyTwentieth)
// The whole point: zone must not change which day an all-day value denotes.
assertThat(allDay.calendarDate(allDay = true, zone = berlin)).isEqualTo(julyTwentieth)
assertThat(allDay.calendarDate(allDay = true, zone = newYork)).isEqualTo(julyTwentieth)
// Read as a timed value in New York it would slip to the 19th — the bug.
assertThat(allDay.calendarDate(allDay = false, zone = newYork)).isEqualTo(julyTwentieth.minusDays(1))
}
@Test
fun `toggling all-day off keeps the day and lands on local midnight`() {
val allDay = allDayInstantOf(julyTwentieth)
val timed = allDay.rebasedForAllDay(allDay = false, zone = berlin)
assertThat(timed.calendarDate(allDay = false, zone = berlin)).isEqualTo(julyTwentieth)
val local = java.time.Instant.ofEpochMilli(timed.toEpochMilliseconds()).atZone(berlin)
assertThat(local.toLocalTime()).isEqualTo(java.time.LocalTime.MIDNIGHT)
}
@Test
fun `toggling all-day on keeps the day the user was looking at`() {
// 2026-07-20T23:30 in Berlin — late enough that a naive UTC read slips a day.
val lateEvening = Instant.fromEpochMilliseconds(
julyTwentieth.atTime(23, 30).atZone(berlin).toInstant().toEpochMilli(),
)
val allDay = lateEvening.rebasedForAllDay(allDay = true, zone = berlin)
assertThat(allDay.calendarDate(allDay = true)).isEqualTo(julyTwentieth)
}
@Test
fun `round-tripping the toggle is stable`() {
val original = allDayInstantOf(julyTwentieth)
val there = original.rebasedForAllDay(allDay = false, zone = newYork)
val back = there.rebasedForAllDay(allDay = true, zone = newYork)
assertThat(back).isEqualTo(original)
}
}
@@ -0,0 +1,58 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class HtmlTextTest {
@Test
fun `plain text with angle brackets is not html`() {
assertThat("a < b and c > d".looksLikeHtml()).isFalse()
assertThat("x <3".readableDescription()).isEqualTo("x <3")
assertThat("if x<b and y>c".looksLikeHtml()).isFalse()
assertThat("Mail <b.smith@example.com>".looksLikeHtml()).isFalse()
}
@Test
fun `text that only resembles a tag survives inside real html`() {
assertThat("<p>Mail <b.smith@example.com></p>".htmlToPlainText()).isEqualTo("Mail <b.smith@example.com>")
}
@Test
fun `paragraphs and breaks become lines`() {
val html = "<p>First line<br>second</p>\n<p>Next &amp; last</p>"
assertThat(html.htmlToPlainText()).isEqualTo("First line\nsecond\nNext & last")
}
@Test
fun `list items become bullets`() {
val html = "<ul>\n <li>Milk</li>\n <li><b>Eggs</b></li>\n</ul>"
assertThat(html.htmlToPlainText()).isEqualTo("• Milk\n• Eggs")
}
@Test
fun `a link keeps its address`() {
assertThat("""See <a href="https://example.org">the docs</a>""".htmlToPlainText())
.isEqualTo("See the docs (https://example.org)")
assertThat("""<a href="https://example.org">https://example.org</a>""".htmlToPlainText())
.isEqualTo("https://example.org")
assertThat("""<a href="mailto:a@b.de">a@b.de</a>""".htmlToPlainText()).isEqualTo("a@b.de")
}
@Test
fun `inline markup keeps the source line breaks`() {
assertThat("one <b>bold</b>\ntwo".htmlToPlainText()).isEqualTo("one bold\ntwo")
}
@Test
fun `entities decode and bogus ones stay`() {
assertThat("<span>&lt;tag&gt; &#228; &#xE9; &#99999999; &bogus;</span>".htmlToPlainText())
.isEqualTo("<tag> ä é &#99999999; &bogus;")
}
@Test
fun `head and style are dropped`() {
val html = "<html><head><style>p { color: red }</style></head><body><p>Hi</p></body></html>"
assertThat(html.htmlToPlainText()).isEqualTo("Hi")
}
}
@@ -0,0 +1,60 @@
package de.jeanlucmakiola.agendula.domain
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class SeriesCollapseTest {
private fun occurrence(
day: Int,
distance: Int,
status: TaskStatus = TaskStatus.NEEDS_ACTION,
rowId: Long = 7,
): Task {
val at = Instant.fromEpochMilliseconds(day * 86_400_000L)
return testTask(id = rowId, status = status, due = at).copy(
isRecurring = rowId == 7L,
occurrenceStart = at,
distanceFromCurrent = distance,
seriesId = 7,
)
}
@Test
fun `a daily series shows one open occurrence, not a year of them`() {
val series = (-5..5).map { occurrence(day = 100 + it, distance = it) }
val visible = SeriesCollapse.visible(series)
assertThat(visible.map { it.distanceFromCurrent }).containsExactly(-1)
}
@Test
fun `yesterday done leaves today as the one to do`() {
val series = listOf(
occurrence(day = 99, distance = -1, status = TaskStatus.COMPLETED, rowId = 20),
occurrence(day = 100, distance = 0),
occurrence(day = 101, distance = 1),
)
val visible = SeriesCollapse.visible(series)
assertThat(visible.map { it.taskId to it.distanceFromCurrent })
.containsExactly(20L to -1, 7L to 0)
}
@Test
fun `a closed series shows once`() {
val series = (-2..2).map { occurrence(day = 100 + it, distance = it, status = TaskStatus.COMPLETED) }
assertThat(SeriesCollapse.visible(series)).hasSize(1)
}
@Test
fun `plain tasks pass through untouched`() {
val plain = testTask(id = 1)
assertThat(SeriesCollapse.visible(listOf(plain))).containsExactly(plain)
}
}
@@ -32,7 +32,7 @@ class TaskFormTest {
@Test
fun `a reminder requires a due date`() {
val errors = TaskForm(title = "x", listId = 1, reminderMinutesBeforeDue = 10).validate()
val errors = TaskForm(title = "x", listId = 1, reminders = listOf(10)).validate()
assertThat(errors).contains(TaskFormError.REMINDER_WITHOUT_DUE)
}
@@ -17,7 +17,7 @@ class TaskSortingTest {
val sorted = listOf(completed, noDate, dueLater, dueSooner).sortedWith(TaskSorting.DEFAULT)
assertThat(sorted.map { it.id }).containsExactly(3L, 2L, 4L, 1L).inOrder()
assertThat(sorted.map { it.taskId }).containsExactly(3L, 2L, 4L, 1L).inOrder()
}
@Test
@@ -27,6 +27,6 @@ class TaskSortingTest {
val sorted = listOf(low, high).sortedWith(TaskSorting.DEFAULT)
assertThat(sorted.map { it.id }).containsExactly(2L, 1L).inOrder()
assertThat(sorted.map { it.taskId }).containsExactly(2L, 1L).inOrder()
}
}
@@ -11,7 +11,6 @@ fun testTask(
priority: Priority = Priority.NONE,
due: Instant? = null,
): Task = Task(
id = id,
taskId = id,
listId = listId,
title = title,
@@ -32,6 +31,7 @@ fun testTask(
accountName = null,
parentId = null,
isRecurring = false,
occurrenceStart = null,
distanceFromCurrent = 0,
created = null,
lastModified = null,
@@ -0,0 +1,313 @@
package de.jeanlucmakiola.agendula.domain.export
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.Priority
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.time.LocalDate
import kotlin.time.Instant
/**
* The export format. Worth testing closely: an export is only as good as its
* ability to be read back, and nothing about a malformed `.ics` is obvious until
* someone actually needs the backup.
*/
class ICalendarWriterTest {
private fun task(
taskId: Long = 1L,
uid: String? = null,
title: String = "Buy oat milk",
description: String? = null,
location: String? = null,
url: String? = null,
priority: Priority = Priority.NONE,
status: TaskStatus = TaskStatus.NEEDS_ACTION,
percentComplete: Int? = null,
start: Instant? = null,
due: Instant? = null,
isAllDay: Boolean = false,
completedAt: Instant? = null,
created: Instant? = null,
lastModified: Instant? = null,
rrule: String? = null,
rdate: String? = null,
parentId: Long? = null,
) = ExportTask(
taskId, uid, title, description, location, url, priority, status, percentComplete,
start, due, isAllDay, completedAt, created, lastModified, rrule, rdate, parentId,
)
private fun write(vararg tasks: ExportTask, name: String = "Groceries"): String =
ICalendarWriter.write(ExportList(1L, name, "local", tasks.toList()))
/** Unfolds the way an importer does, so assertions can read logical lines. */
private fun String.unfolded(): String = replace("\r\n ", "")
private fun linesOf(ics: String): List<String> = ics.unfolded().split("\r\n").filter { it.isNotEmpty() }
@Nested
inner class Structure {
@Test
fun `wraps the todos in a calendar`() {
val lines = linesOf(write(task()))
assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR")
assertThat(lines.last()).isEqualTo("END:VCALENDAR")
assertThat(lines).containsAtLeast("VERSION:2.0", "BEGIN:VTODO", "END:VTODO")
}
@Test
fun `uses CRLF line endings`() {
// RFC 5545 requires CRLF. Bare LF is the classic way an .ics is rejected
// by a strict importer while looking perfectly fine in an editor.
val ics = write(task())
assertThat(ics).contains("\r\n")
assertThat(ics.replace("\r\n", "")).doesNotContain("\n")
}
@Test
fun `carries the list name`() {
assertThat(linesOf(write(task(), name = "Shopping"))).contains("X-WR-CALNAME:Shopping")
}
@Test
fun `every todo has a UID and a DTSTAMP`() {
// Both are mandatory; a VTODO missing either is invalid.
val lines = linesOf(write(task(), task(taskId = 2)))
assertThat(lines.count { it.startsWith("UID:") }).isEqualTo(2)
assertThat(lines.count { it.startsWith("DTSTAMP:") }).isEqualTo(2)
}
}
@Nested
inner class Identity {
@Test
fun `prefers the synced UID`() {
assertThat(linesOf(write(task(uid = "abc-123@example.org"))))
.contains("UID:abc-123@example.org")
}
@Test
fun `synthesises a stable UID for a local task`() {
// Local tasks never get a UID from the provider (only a sync adapter may
// assign one), and re-importing UID-less todos would duplicate rather
// than match them.
val first = ICalendarWriter.uidFor(task(taskId = 42))
val second = ICalendarWriter.uidFor(task(taskId = 42))
assertThat(first).isEqualTo(second)
assertThat(first).contains("42")
}
@Test
fun `distinct tasks get distinct UIDs`() {
assertThat(ICalendarWriter.uidFor(task(taskId = 1)))
.isNotEqualTo(ICalendarWriter.uidFor(task(taskId = 2)))
}
@Test
fun `blank stored UID falls back to the synthesised one`() {
assertThat(ICalendarWriter.uidFor(task(taskId = 7, uid = " "))).contains("7")
}
}
@Nested
inner class Dates {
private val noon = Instant.fromEpochMilliseconds(1_754_136_000_000L) // 2025-08-02T12:00:00Z
@Test
fun `timed values are written in UTC`() {
assertThat(linesOf(write(task(due = noon)))).contains("DUE:20250802T120000Z")
}
@Test
fun `all-day values are date-only`() {
// A DATE-TIME here would drift by a day for anyone east or west of UTC —
// the exact bug the AllDayTime convention exists to prevent.
val due = allDayInstantOf(LocalDate.of(2026, 8, 2))
val lines = linesOf(write(task(due = due, isAllDay = true)))
assertThat(lines).contains("DUE;VALUE=DATE:20260802")
}
@Test
fun `completion is always a UTC date-time even when all-day`() {
val lines = linesOf(
write(task(isAllDay = true, status = TaskStatus.COMPLETED, completedAt = noon)),
)
assertThat(lines).contains("COMPLETED:20250802T120000Z")
}
@Test
fun `absent dates emit no property at all`() {
val lines = linesOf(write(task()))
assertThat(lines.none { it.startsWith("DUE") }).isTrue()
assertThat(lines.none { it.startsWith("DTSTART") }).isTrue()
}
}
@Nested
inner class Fields {
@Test
fun `maps every status to its iCalendar name`() {
fun statusLine(status: TaskStatus) =
linesOf(write(task(status = status))).first { it.startsWith("STATUS:") }
assertThat(statusLine(TaskStatus.NEEDS_ACTION)).isEqualTo("STATUS:NEEDS-ACTION")
assertThat(statusLine(TaskStatus.IN_PROCESS)).isEqualTo("STATUS:IN-PROCESS")
assertThat(statusLine(TaskStatus.COMPLETED)).isEqualTo("STATUS:COMPLETED")
assertThat(statusLine(TaskStatus.CANCELLED)).isEqualTo("STATUS:CANCELLED")
}
@Test
fun `omits priority when there is none`() {
// PRIORITY:0 means "undefined" but reads as a real value to some
// importers; leaving it out is unambiguous.
assertThat(linesOf(write(task())).none { it.startsWith("PRIORITY") }).isTrue()
assertThat(linesOf(write(task(priority = Priority.HIGH)))).contains("PRIORITY:1")
}
@Test
fun `clamps percent complete into range`() {
assertThat(linesOf(write(task(percentComplete = 140)))).contains("PERCENT-COMPLETE:100")
assertThat(linesOf(write(task(percentComplete = -5)))).contains("PERCENT-COMPLETE:0")
}
@Test
fun `passes recurrence through unchanged`() {
val lines = linesOf(write(task(rrule = "FREQ=WEEKLY;BYDAY=MO,WE")))
assertThat(lines).contains("RRULE:FREQ=WEEKLY;BYDAY=MO,WE")
}
@Test
fun `does not escape a URL`() {
// URL is a URI, not TEXT. Escaping its commas would corrupt the address.
val lines = linesOf(write(task(url = "https://example.org/a,b;c")))
assertThat(lines).contains("URL:https://example.org/a,b;c")
}
@Test
fun `skips blank optional fields`() {
val lines = linesOf(write(task(description = " ", location = "", url = "")))
assertThat(lines.none { it.startsWith("DESCRIPTION") }).isTrue()
assertThat(lines.none { it.startsWith("LOCATION") }).isTrue()
assertThat(lines.none { it.startsWith("URL") }).isTrue()
}
}
@Nested
inner class Subtasks {
@Test
fun `links a child to its parent by UID`() {
val parent = task(taskId = 1, uid = "parent@example.org")
val child = task(taskId = 2, parentId = 1)
assertThat(linesOf(write(parent, child)))
.contains("RELATED-TO;RELTYPE=PARENT:parent@example.org")
}
@Test
fun `resolves a parent that appears after the child`() {
// Nothing guarantees provider order, and a forward reference must still
// resolve or half the hierarchy silently disappears.
val child = task(taskId = 2, parentId = 1)
val parent = task(taskId = 1, uid = "parent@example.org")
assertThat(linesOf(write(child, parent)))
.contains("RELATED-TO;RELTYPE=PARENT:parent@example.org")
}
@Test
fun `drops a link to a parent outside this list`() {
// A RELATED-TO pointing at a UID not in the file would dangle on import.
val orphan = task(taskId = 2, parentId = 999)
assertThat(linesOf(write(orphan)).none { it.startsWith("RELATED-TO") }).isTrue()
}
}
@Nested
inner class Escaping {
@Test
fun `escapes the special characters`() {
assertThat(ICalendarWriter.escapeText("a;b,c")).isEqualTo("a\\;b\\,c")
assertThat(ICalendarWriter.escapeText("line\nbreak")).isEqualTo("line\\nbreak")
assertThat(ICalendarWriter.escapeText("CRLF\r\nhere")).isEqualTo("CRLF\\nhere")
}
@Test
fun `escapes backslashes first`() {
// Doing it later would re-escape the backslashes the other rules add,
// turning "a;b" into "a\\;b".
assertThat(ICalendarWriter.escapeText("back\\slash")).isEqualTo("back\\\\slash")
assertThat(ICalendarWriter.escapeText("a\\;b")).isEqualTo("a\\\\\\;b")
}
@Test
fun `a multiline description stays one logical line`() {
val ics = write(task(description = "first\nsecond"))
assertThat(ics.unfolded()).contains("DESCRIPTION:first\\nsecond")
}
}
@Nested
inner class Folding {
@Test
fun `short lines are untouched`() {
assertThat(ICalendarWriter.fold("SUMMARY:short")).isEqualTo("SUMMARY:short")
}
@Test
fun `long lines are folded to 75 octets`() {
val folded = ICalendarWriter.fold("SUMMARY:" + "a".repeat(200))
folded.split("\r\n").forEachIndexed { index, segment ->
val octets = segment.toByteArray(Charsets.UTF_8).size
assertThat(octets).isAtMost(if (index == 0) 75 else 76) // 75 + the leading space
}
}
@Test
fun `folding round-trips`() {
val original = "DESCRIPTION:" + "long text ".repeat(40)
assertThat(ICalendarWriter.fold(original).replace("\r\n ", "")).isEqualTo(original)
}
@Test
fun `never splits a multi-byte character`() {
// The limit is in octets but an emoji is four of them; splitting mid
// sequence would emit invalid UTF-8 and mangle the title.
val emoji = "SUMMARY:" + "🌼".repeat(40)
val folded = ICalendarWriter.fold(emoji)
assertThat(folded.replace("\r\n ", "")).isEqualTo(emoji)
folded.split("\r\n").forEach { segment ->
// A broken surrogate pair round-trips through UTF-8 as U+FFFD.
assertThat(segment.toByteArray(Charsets.UTF_8).toString(Charsets.UTF_8))
.isEqualTo(segment)
}
}
@Test
fun `a long title survives the full write`() {
val title = "Remember to ".repeat(20)
assertThat(write(task(title = title)).unfolded()).contains("SUMMARY:$title")
}
}
@Nested
inner class EmptyList {
@Test
fun `still produces a valid calendar`() {
// An empty list is a real answer; a missing file is indistinguishable
// from a failed export.
val lines = linesOf(ICalendarWriter.write(ExportList(1L, "Empty", "local", emptyList())))
assertThat(lines.first()).isEqualTo("BEGIN:VCALENDAR")
assertThat(lines.last()).isEqualTo("END:VCALENDAR")
assertThat(lines.none { it == "BEGIN:VTODO" }).isTrue()
}
}
}
@@ -0,0 +1,154 @@
package de.jeanlucmakiola.agendula.domain.ical
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
/**
* The lexer. Worth testing closely because every correctness guarantee above it
* assumes a content line was split where RFC 5545 says it splits — and the
* places it does not split are where other clients' data goes missing.
*/
class ICalParserTest {
@Nested
inner class Unfolding {
@Test
fun `unfolding removes the CRLF and the whitespace, not just the CRLF`() {
// RFC 5545 section 3.1: a fold is CRLF + one WSP and *both* go. A
// folder that keeps the space silently inserts one into every long
// value it ever wrote.
val lines = ICalParser.unfold("SUMMARY:Hello\r\n world\r\n")
assertThat(lines).containsExactly("SUMMARY:Helloworld")
}
@Test
fun `a tab folds too`() {
assertThat(ICalParser.unfold("SUMMARY:Hello\r\n\tworld")).containsExactly("SUMMARY:Helloworld")
}
@Test
fun `bare LF is accepted, because real files carry it`() {
assertThat(ICalParser.unfold("A:1\nB:2")).containsExactly("A:1", "B:2")
}
@Test
fun `a leading BOM is stripped rather than glued onto the first name`() {
val component = ICalParser.parse("\uFEFFBEGIN:VTODO\r\nUID:x\r\nEND:VTODO\r\n")
assertThat(component.name).isEqualTo("VTODO")
}
}
@Nested
inner class ContentLines {
@Test
fun `the value starts at the first colon outside a quoted parameter`() {
val property = ICalParser.parseLine("""ATTENDEE;CN="Smith, J:r":mailto:jr@example.com""")
assertThat(property?.name).isEqualTo("ATTENDEE")
assertThat(property?.param("CN")).isEqualTo("Smith, J:r")
assertThat(property?.value).isEqualTo("mailto:jr@example.com")
}
@Test
fun `a multi-valued parameter splits only on unquoted commas`() {
val property = ICalParser.parseLine("""X-THING;MEMBER="a,b",c:v""")
assertThat(property?.params?.single()?.values).containsExactly("a,b", "c").inOrder()
}
@Test
fun `a value may contain colons`() {
assertThat(ICalParser.parseLine("URL:https://example.com/a:b")?.value)
.isEqualTo("https://example.com/a:b")
}
@Test
fun `the value is kept escaped, because that is what makes it reproducible`() {
assertThat(ICalParser.parseLine("""DESCRIPTION:a\, b\; c""")?.value)
.isEqualTo("""a\, b\; c""")
}
@Test
fun `a parameter with no equals sign is kept rather than dropped`() {
val property = ICalParser.parseLine("X-THING;BROKEN:v")
assertThat(property?.params?.single()?.name).isEqualTo("BROKEN")
assertThat(property?.params?.single()?.values).isEmpty()
}
@Test
fun `a line with no colon is not a content line`() {
assertThat(ICalParser.parseLine("GARBAGE")).isNull()
}
}
@Nested
inner class Structure {
@Test
fun `unknown components nest like any other`() {
val component = ICalParser.parse(
"""
BEGIN:VTODO
UID:x
BEGIN:X-VENDOR
X-KEY:v
END:X-VENDOR
END:VTODO
""".trimIndent(),
)
assertThat(component.components("X-VENDOR").single().property("X-KEY")?.value).isEqualTo("v")
}
@Test
fun `a stray END is ignored rather than fatal`() {
val component = ICalParser.parse("END:VALARM\nBEGIN:VTODO\nUID:x\nEND:VTODO\n")
assertThat(component.name).isEqualTo("VTODO")
assertThat(component.property("UID")?.value).isEqualTo("x")
}
@Test
fun `component names are upper-cased so lookups are not case-dependent`() {
assertThat(ICalParser.parse("begin:vtodo\nuid:x\nend:vtodo\n").name).isEqualTo("VTODO")
}
@Test
fun `text with no component at all is fatal`() {
runCatching { ICalParser.parse("SUMMARY:orphan\r\n") }
.onSuccess { error("expected ICalParseException") }
.onFailure { assertThat(it).isInstanceOf(ICalParseException::class.java) }
}
@Test
fun `a missing END does not swallow the enclosing component`() {
// Closing by position would let END:VCALENDAR close the VTODO, leave
// VCALENDAR open, and end with no component at all — discarding a
// whole multiget response over one malformed task.
val roots = ICalParser.parseAll(
"BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:x\r\nEND:VCALENDAR\r\n",
)
val calendar = roots.single()
assertThat(calendar.name).isEqualTo("VCALENDAR")
assertThat(calendar.components("VTODO").single().property("UID")?.value).isEqualTo("x")
}
@Test
fun `a component left open at end of input is still returned`() {
val calendar = ICalParser.parse("BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:x\r\n")
assertThat(calendar.components("VTODO").single().property("UID")?.value).isEqualTo("x")
}
@Test
fun `an unbalanced quote does not swallow the whole line`() {
val property = ICalParser.parseLine("""X-PROP;CN="unclosed:value""")
assertThat(property?.name).isEqualTo("X-PROP")
assertThat(property?.value).isEqualTo("value")
}
@Test
fun `duplicate properties are both kept`() {
val component = ICalParser.parse("BEGIN:VTODO\nCATEGORIES:a\nCATEGORIES:a\nEND:VTODO\n")
assertThat(component.properties("CATEGORIES")).hasSize(2)
}
}
}
@@ -0,0 +1,64 @@
package de.jeanlucmakiola.agendula.domain.ical
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class ICalSerializerTest {
@Test
fun `a line is folded at 75 octets, not 75 characters`() {
// Each 'ä' is two octets, so 40 of them is 80 — over the limit at 40
// characters, which a character-counting folder would let through.
val property = ICalProperty("SUMMARY", value = "ä".repeat(40))
val folded = ICalSerializer.fold(ICalSerializer.render(property))
assertThat(folded).contains("\r\n ")
folded.split("\r\n").forEach {
assertThat(it.toByteArray(Charsets.UTF_8).size).isAtMost(75)
}
}
@Test
fun `folding never splits a multi-byte character`() {
val value = "🌱".repeat(30)
val folded = ICalSerializer.fold("SUMMARY:$value")
val unfolded = ICalParser.unfold(folded + "\r\n").single()
assertThat(unfolded).isEqualTo("SUMMARY:$value")
}
@Test
fun `a parameter value is quoted only when the grammar forces it`() {
assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", "plain")), "v")))
.isEqualTo("X;P=plain:v")
assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", "a:b")), "v")))
.isEqualTo("""X;P="a:b":v""")
assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", "a,b")), "v")))
.isEqualTo("""X;P="a,b":v""")
}
@Test
fun `an embedded double quote survives when the value need not be quoted`() {
// The parser only unquotes a matched leading/trailing pair, so stripping
// unconditionally would lose two characters from a value it never quoted.
assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", """a"b""")), "v")))
.isEqualTo("""X;P=a"b:v""")
}
@Test
fun `an embedded double quote is dropped only when quoting is forced`() {
assertThat(ICalSerializer.render(ICalProperty("X", listOf(ICalParam("P", """a"b,c""")), "v")))
.isEqualTo("""X;P="ab,c":v""")
}
@Test
fun `components round-trip through serialise and parse`() {
val original = ICalComponent(
name = "VTODO",
properties = listOf(ICalProperty("UID", value = "x")),
components = listOf(
ICalComponent("VALARM", listOf(ICalProperty("ACTION", value = "DISPLAY"))),
),
)
assertThat(ICalParser.parse(ICalSerializer.serialize(original))).isEqualTo(original)
}
}
@@ -0,0 +1,99 @@
package de.jeanlucmakiola.agendula.domain.ical
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class VTimeZonesTest {
@Test fun `a DST zone gets both observances`() {
val zone = VTimeZones.forZone("Europe/Berlin")!!
assertThat(zone.property("TZID")!!.value).isEqualTo("Europe/Berlin")
assertThat(zone.components.map { it.name })
.containsExactly("STANDARD", "DAYLIGHT")
val daylight = zone.components("DAYLIGHT").single()
assertThat(daylight.property("TZOFFSETFROM")!!.value).isEqualTo("+0100")
assertThat(daylight.property("TZOFFSETTO")!!.value).isEqualTo("+0200")
// The EU rule is "the last Sunday", which is what java.time encodes as
// dayOfMonthIndicator = -1.
assertThat(daylight.property("RRULE")!!.value)
.isEqualTo("FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU")
}
@Test fun `a nth-weekday rule becomes an ordinal BYDAY`() {
val zone = VTimeZones.forZone("America/New_York")!!
// US DST starts on the second Sunday in March, which java.time encodes as
// "on or after the 8th".
assertThat(zone.components("DAYLIGHT").single().property("RRULE")!!.value)
.isEqualTo("FREQ=YEARLY;BYMONTH=3;BYDAY=2SU")
}
@Test fun `a fixed-offset zone gets one standard observance and no rule`() {
val zone = VTimeZones.forZone("Asia/Kolkata")!!
val standard = zone.components.single()
assertThat(standard.name).isEqualTo("STANDARD")
assertThat(standard.property("TZOFFSETTO")!!.value).isEqualTo("+0530")
assertThat(standard.property("RRULE")).isNull()
}
@Test fun `UTC is representable`() {
val zone = VTimeZones.forZone("UTC")!!
assertThat(zone.components.single().property("TZOFFSETTO")!!.value).isEqualTo("+0000")
}
@Test fun `a negative offset keeps its sign`() {
val zone = VTimeZones.forZone("America/New_York")!!
assertThat(zone.components("STANDARD").single().property("TZOFFSETTO")!!.value)
.isEqualTo("-0500")
}
@Test fun `an unknown zone is null rather than an exception`() {
assertThat(VTimeZones.forZone("Mars/Olympus_Mons")).isNull()
assertThat(VTimeZones.forZone("")).isNull()
}
@Test fun `observance DTSTART is a real instance of its own rule`() {
val daylight = VTimeZones.forZone("Europe/Berlin")!!.components("DAYLIGHT").single()
val start = daylight.property("DTSTART")!!.value
// 1970-03-29 was a Sunday, and the last one in March.
assertThat(start).isEqualTo("19700329T020000")
}
@Test fun `zones are collected from every TZID in the tree`() {
val todo = ICalComponent(
name = "VTODO",
properties = listOf(
ICalProperty("DTSTART", listOf(ICalParam("TZID", "Europe/Berlin")), "20260101T090000"),
ICalProperty("DUE", listOf(ICalParam("TZID", "Europe/Berlin")), "20260101T100000"),
),
components = listOf(
ICalComponent(
name = "VALARM",
properties = listOf(
ICalProperty(
"TRIGGER",
listOf(ICalParam("TZID", "America/New_York")),
"20260101T080000",
),
),
components = emptyList(),
),
),
)
val zones = VTimeZones.forComponent(todo).map { it.property("TZID")!!.value }
// Deduplicated, and the nested one is not missed.
assertThat(zones).containsExactly("America/New_York", "Europe/Berlin").inOrder()
}
@Test fun `a component with no TZID needs no definitions`() {
val todo = ICalComponent(
name = "VTODO",
properties = listOf(ICalProperty("DTSTART", emptyList(), "20260101T090000Z")),
components = emptyList(),
)
assertThat(VTimeZones.forComponent(todo)).isEmpty()
}
}
@@ -0,0 +1,59 @@
package de.jeanlucmakiola.agendula.domain.recurrence
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import kotlin.time.Instant
class DistanceFromCurrentTest {
private fun at(text: String) = Instant.parse(text)
private val occurrences = listOf(
at("2025-01-05T09:00:00Z"),
at("2025-01-06T09:00:00Z"),
at("2025-01-07T09:00:00Z"),
at("2025-01-08T09:00:00Z"),
)
@Test
fun `the current occurrence is the first one at or after now`() {
val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T10:00:00Z"))
assertThat(index).isEqualTo(2)
}
@Test
fun `an occurrence exactly at now is the current one`() {
val index = RecurrenceExpander.currentOccurrenceIndex(occurrences, at("2025-01-06T09:00:00Z"))
assertThat(index).isEqualTo(1)
}
@Test
fun `past occurrences count down and later ones count up`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-06T10:00:00Z"))
assertThat(distances).containsExactly(-2, -1, 0, 1).inOrder()
}
@Test
fun `a series entirely in the future is current at its first occurrence`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2024-12-01T00:00:00Z"))
assertThat(distances).containsExactly(0, 1, 2, 3).inOrder()
}
@Test
fun `a series entirely in the past is current at its last occurrence`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2026-01-01T00:00:00Z"))
assertThat(distances).containsExactly(-3, -2, -1, 0).inOrder()
}
@Test
fun `exactly one occurrence is ever the current one`() {
val distances = RecurrenceExpander.distancesFromCurrent(occurrences, at("2025-01-07T00:00:00Z"))
assertThat(distances.count { it == 0 }).isEqualTo(1)
}
@Test
fun `an empty series has no distances`() {
assertThat(RecurrenceExpander.distancesFromCurrent(emptyList(), at("2025-01-01T00:00:00Z"))).isEmpty()
assertThat(RecurrenceExpander.currentOccurrenceIndex(emptyList(), at("2025-01-01T00:00:00Z"))).isEqualTo(-1)
}
}
@@ -0,0 +1,483 @@
package de.jeanlucmakiola.agendula.domain.recurrence
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
import java.time.ZoneId
import kotlin.time.Instant
/**
* The provider only ever materialised the single next occurrence, so there is no
* provider behaviour to compare multi-occurrence expansion against. These cases
* assert against RFC 5545 §3.8.5 directly.
*/
class RecurrenceExpanderTest {
private val berlin = "Europe/Berlin"
private val newYork = ZoneId.of("America/New_York")
private fun at(text: String) = Instant.parse(text)
private fun spec(
rrule: String? = null,
rdate: String? = null,
exdate: String? = null,
anchor: String,
isAllDay: Boolean = false,
timeZone: String? = berlin,
) = RecurrenceSpec(rrule, rdate, exdate, at(anchor), isAllDay, timeZone)
private fun window(
from: String = "2000-01-01T00:00:00Z",
until: String = "2100-01-01T00:00:00Z",
max: Int = 500,
) = ExpansionWindow(at(from), at(until), max)
private fun expand(
spec: RecurrenceSpec,
window: ExpansionWindow = window(),
floatingZone: ZoneId = newYork,
) = RecurrenceExpander.expand(spec, window, floatingZone).map { it.toString() }
// --- frequencies ---------------------------------------------------------
@Test
fun `daily rule yields consecutive days at the same local time`() {
val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `interval skips the intervening days`() {
val result = expand(spec(rrule = "FREQ=DAILY;INTERVAL=2;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-09T08:00:00Z",
"2025-01-11T08:00:00Z",
).inOrder()
}
@Test
fun `weekly by day expands to the named weekdays`() {
// The anchor is a Tuesday, which BYDAY=MO,WE,FR does not name. DTSTART is
// the first instance of the set regardless (RFC 5545 §3.8.5.3), so the
// Tuesday leads and the pattern takes over from there.
val result = expand(
spec(rrule = "FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z", // Tue, the anchor
"2025-01-08T08:00:00Z", // Wed
"2025-01-10T08:00:00Z", // Fri
"2025-01-13T08:00:00Z", // Mon
"2025-01-15T08:00:00Z", // Wed
).inOrder()
}
@Test
fun `monthly by day expands to the nth weekday of the month`() {
// 2025-01-07 is the first Tuesday, so BYDAY=2TU lands on the 14th; the
// anchor still leads.
val result = expand(spec(rrule = "FREQ=MONTHLY;BYDAY=2TU;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-14T08:00:00Z",
"2025-02-11T08:00:00Z",
).inOrder()
}
@Test
fun `monthly by month day skips months that lack the day`() {
val result = expand(spec(rrule = "FREQ=MONTHLY;BYMONTHDAY=31;COUNT=3", anchor = "2025-01-31T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-31T08:00:00Z",
"2025-03-31T07:00:00Z", // February and April have no 31st; March is already CEST
"2025-05-31T07:00:00Z",
).inOrder()
}
@Test
fun `yearly rule repeats on the anniversary`() {
val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2026-01-07T08:00:00Z",
"2027-01-07T08:00:00Z",
).inOrder()
}
@Test
fun `yearly rule on a leap day only recurs in leap years`() {
val result = expand(spec(rrule = "FREQ=YEARLY;COUNT=3", anchor = "2024-02-29T08:00:00Z"))
assertThat(result).containsExactly(
"2024-02-29T08:00:00Z",
"2028-02-29T08:00:00Z",
"2032-02-29T08:00:00Z",
).inOrder()
}
// --- limits --------------------------------------------------------------
@Test
fun `COUNT limits the series`() {
val result = expand(spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-01-07T08:00:00Z"))
assertThat(result).hasSize(2)
}
@Test
fun `UNTIL includes an occurrence falling exactly on it`() {
val result = expand(
spec(rrule = "FREQ=DAILY;UNTIL=20250109T080000Z", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `a floating UNTIL is read in the series zone`() {
// RFC 5545 §3.3.10 requires UNTIL in UTC when DTSTART carries a zone, and
// lib-recur throws outright on the mismatch. Stored rules break the rule
// anyway, so 09:00 floating has to mean 09:00 in Berlin.
val result = expand(
spec(rrule = "FREQ=DAILY;UNTIL=20250109T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `a date-valued UNTIL covers the whole of its last day`() {
// ⚠️ A DATE-valued UNTIL is midnight, and the repair below rebuilt it
// from hours/minutes/seconds — all zero for a DATE — so a series anchored
// at 09:00 Berlin lost every occurrence on the final day. RFC 5545
// §3.3.10 puts the bound at the end of that date.
val result = expand(
spec(rrule = "FREQ=DAILY;UNTIL=20250109", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `an all-day series with a date-valued UNTIL keeps its last day`() {
val result = expand(
spec(
rrule = "FREQ=DAILY;UNTIL=20250109",
anchor = "2025-01-07T00:00:00Z",
isAllDay = true,
timeZone = null,
),
)
assertThat(result).hasSize(3)
}
@Test
fun `an unbounded rule stops at the occurrence ceiling`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(max = 4),
)
assertThat(result).hasSize(4)
assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z")
}
@Test
fun `a sub-daily series spends most of the ceiling on occurrences from the pivot on`() {
// The real shape: an eight-hourly task, a year of window behind now and a
// 500-occurrence ceiling. Filling the budget from the window start would
// exhaust it ~166 days before now, so the task would never appear in Today
// or Upcoming at all.
val result = expand(
spec(rrule = "FREQ=HOURLY;INTERVAL=8", anchor = "2024-01-01T00:00:00Z"),
ExpansionWindow(
from = at("2024-06-01T00:00:00Z"),
until = at("2026-06-01T00:00:00Z"),
maxOccurrences = 500,
pivot = at("2025-06-01T00:00:00Z"),
),
)
assertThat(result).hasSize(500)
// A quarter of the budget looks back, and it keeps the *most recent* of
// the past — not the oldest, which is what filling from the window start
// would have kept. (ISO-8601 UTC sorts lexicographically.)
assertThat(result.count { it < "2025-06-01T00:00:00Z" }).isEqualTo(125)
assertThat(result.first()).isGreaterThan("2025-04-01T00:00:00Z")
assertThat(result.last()).isGreaterThan("2025-09-01T00:00:00Z")
}
@Test
fun `the ceiling goes entirely to the future when nothing precedes the pivot`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
ExpansionWindow(
from = at("2025-01-01T00:00:00Z"),
until = at("2030-01-01T00:00:00Z"),
maxOccurrences = 4,
pivot = at("2025-01-01T00:00:00Z"),
),
)
assertThat(result).hasSize(4)
assertThat(result.last()).isEqualTo("2025-01-10T08:00:00Z")
}
@Test
fun `an unbounded rule stops at the window end`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(until = "2025-01-10T00:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `the window end is exclusive`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(until = "2025-01-09T08:00:00Z"),
)
assertThat(result).doesNotContain("2025-01-09T08:00:00Z")
}
@Test
fun `occurrences before the window start are skipped`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(from = "2025-06-01T00:00:00Z", until = "2025-06-04T00:00:00Z"),
)
assertThat(result).containsExactly(
"2025-06-01T07:00:00Z",
"2025-06-02T07:00:00Z",
"2025-06-03T07:00:00Z",
).inOrder()
}
// --- DST and all-day -----------------------------------------------------
@Test
fun `a daily series keeps its local time across a DST boundary`() {
// Europe/Berlin springs forward on 2025-03-30, so 09:00 local moves from
// 08:00Z to 07:00Z while the wall-clock time the user set stays put.
val result = expand(spec(rrule = "FREQ=DAILY;COUNT=4", anchor = "2025-03-28T08:00:00Z"))
assertThat(result).containsExactly(
"2025-03-28T08:00:00Z",
"2025-03-29T08:00:00Z",
"2025-03-30T07:00:00Z",
"2025-03-31T07:00:00Z",
).inOrder()
}
@Test
fun `an all-day series pins every occurrence to UTC midnight`() {
// Date-anchored, matching TaskWriteMapper's forAllDay: the stored zone and
// the device zone are both irrelevant, and no DST shift reaches it.
val result = expand(
spec(
rrule = "FREQ=DAILY;COUNT=3",
anchor = "2025-03-29T22:45:00Z",
isAllDay = true,
timeZone = berlin,
),
)
assertThat(result).containsExactly(
"2025-03-29T00:00:00Z",
"2025-03-30T00:00:00Z",
"2025-03-31T00:00:00Z",
).inOrder()
}
@Test
fun `an all-day weekly series stays on the same weekday`() {
val result = expand(
spec(rrule = "FREQ=WEEKLY;COUNT=3", anchor = "2025-01-15T00:00:00Z", isAllDay = true, timeZone = null),
)
assertThat(result).containsExactly(
"2025-01-15T00:00:00Z",
"2025-01-22T00:00:00Z",
"2025-01-29T00:00:00Z",
).inOrder()
}
@Test
fun `a series with no zone expands in the floating zone`() {
val spec = spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = null)
// 09:00 in New York, over the 2025-03-09 US DST switch.
assertThat(expand(spec, floatingZone = newYork)).containsExactly(
"2025-03-08T14:00:00Z",
"2025-03-09T13:00:00Z",
).inOrder()
}
// --- RDATE / EXDATE ------------------------------------------------------
@Test
fun `RDATE adds occurrences the rule does not produce`() {
val result = expand(
spec(
rrule = "FREQ=DAILY;COUNT=2",
rdate = "20250115T140000",
anchor = "2025-01-07T08:00:00Z",
),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-15T13:00:00Z", // 14:00 Berlin
).inOrder()
}
@Test
fun `an RDATE before the anchor is part of the set`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "20250101T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result.first()).isEqualTo("2025-01-01T08:00:00Z")
}
@Test
fun `an RDATE repeating a rule instance is not emitted twice`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", rdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `multiple RDATEs are comma-separated`() {
val result = expand(
spec(rdate = "20250110T090000,20250112T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-10T08:00:00Z",
"2025-01-12T08:00:00Z",
).inOrder()
}
@Test
fun `EXDATE removes an occurrence`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `EXDATE can remove the anchor itself`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250107T090000", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `a UTC EXDATE matches a zoned occurrence at the same instant`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=3", exdate = "20250108T080000Z", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `an all-day EXDATE removes the matching date`() {
val result = expand(
spec(
rrule = "FREQ=DAILY;COUNT=3",
exdate = "20250116",
anchor = "2025-01-15T00:00:00Z",
isAllDay = true,
),
)
assertThat(result).containsExactly(
"2025-01-15T00:00:00Z",
"2025-01-17T00:00:00Z",
).inOrder()
}
// --- degenerate input ----------------------------------------------------
@Test
fun `a spec with no rule expands to just its anchor`() {
assertThat(expand(spec(anchor = "2025-01-07T08:00:00Z")))
.containsExactly("2025-01-07T08:00:00Z")
}
@Test
fun `a malformed rule degrades to the anchor instead of throwing`() {
assertThat(expand(spec(rrule = "FREQ=NONSENSE", anchor = "2025-01-07T08:00:00Z")))
.containsExactly("2025-01-07T08:00:00Z")
}
@Test
fun `a malformed RDATE is dropped and the rule still expands`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=2", rdate = "not-a-date", anchor = "2025-01-07T08:00:00Z"),
)
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
).inOrder()
}
@Test
fun `an unknown zone id falls back to the floating zone`() {
val result = expand(
spec(rrule = "FREQ=DAILY;COUNT=2", anchor = "2025-03-08T14:00:00Z", timeZone = "Mars/Olympus"),
floatingZone = newYork,
)
assertThat(result).containsExactly(
"2025-03-08T14:00:00Z",
"2025-03-09T13:00:00Z",
).inOrder()
}
@Test
fun `a sub-second anchor does not double the first occurrence`() {
// RFC 5545 DATE-TIME has second precision, but a task created from
// Clock.now() carries millis. Left un-floored, lib-recur emits the raw
// anchor *and* its truncated self, so the series starts twice.
val result = expand(spec(rrule = "FREQ=DAILY;COUNT=3", anchor = "2025-01-07T08:00:00.081Z"))
assertThat(result).containsExactly(
"2025-01-07T08:00:00Z",
"2025-01-08T08:00:00Z",
"2025-01-09T08:00:00Z",
).inOrder()
}
@Test
fun `a window that ends before the anchor yields nothing`() {
val result = expand(
spec(rrule = "FREQ=DAILY", anchor = "2025-01-07T08:00:00Z"),
window(until = "2024-01-01T00:00:00Z"),
)
assertThat(result).isEmpty()
}
}
@@ -0,0 +1,194 @@
package de.jeanlucmakiola.agendula.domain.recurrence
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import org.junit.jupiter.api.Test
class RecurrenceOccurrencesTest {
private fun date(year: Int, month: Int, day: Int) = LocalDate(year, month, day)
@Test
fun `daily rule starts at the task's own date`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 7, 31),
date(2026, 8, 1),
).inOrder()
}
@Test
fun `interval multiplies the period`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Daily, interval = 3)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 2),
date(2026, 8, 5),
).inOrder()
}
@Test
fun `weekly without weekday picks repeats the start's weekday`() {
// 30 Jul 2026 is a Thursday.
val occurrences = SimpleRecurrence(RecurrenceFreq.Weekly)
.upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 6),
date(2026, 8, 13),
).inOrder()
}
@Test
fun `weekly with picks fires on every chosen day, in weekday order`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY),
).upcomingOccurrences(date(2026, 7, 30), limit = 4)
// The Thursday start is an occurrence in its own right (RFC 5545 puts
// DTSTART in the set), then Friday 31 Jul and Mon/Fri of the next week.
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 7, 31),
date(2026, 8, 3),
date(2026, 8, 7),
).inOrder()
}
@Test
fun `the start counts once even when it is also one of the picks`() {
// Thursday start with Thursday picked: the seeded DTSTART and the rule's
// own hit are the same date and must not both be listed.
val occurrences = SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.THURSDAY),
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 6),
date(2026, 8, 13),
).inOrder()
}
@Test
fun `weekly interval skips whole weeks, counted from Monday`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Weekly,
interval = 2,
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.THURSDAY),
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
// Start week (27 Jul–2 Aug) contributes only its Thursday; the next block
// is two weeks on, so 10 Aug and 13 Aug — not 3 Aug.
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 8, 10),
date(2026, 8, 13),
).inOrder()
}
@Test
fun `monthly skips months without the start day rather than clamping`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Monthly)
.upcomingOccurrences(date(2026, 1, 31), limit = 4)
// February, April and June have no 31st — the rule passes them by.
assertThat(occurrences).containsExactly(
date(2026, 1, 31),
date(2026, 3, 31),
date(2026, 5, 31),
date(2026, 7, 31),
).inOrder()
}
@Test
fun `yearly on 29 February only lands in leap years`() {
val occurrences = SimpleRecurrence(RecurrenceFreq.Yearly)
.upcomingOccurrences(date(2024, 2, 29), limit = 3)
assertThat(occurrences).containsExactly(
date(2024, 2, 29),
date(2028, 2, 29),
date(2032, 2, 29),
).inOrder()
}
@Test
fun `count limits the series and skipped periods don't consume one`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Monthly,
end = RecurrenceEnd.Count(3),
).upcomingOccurrences(date(2026, 1, 31), limit = 10)
assertThat(occurrences).containsExactly(
date(2026, 1, 31),
date(2026, 3, 31),
date(2026, 5, 31),
).inOrder()
}
@Test
fun `until is inclusive of its own date`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(date(2026, 8, 1)),
).upcomingOccurrences(date(2026, 7, 30), limit = 10)
assertThat(occurrences).containsExactly(
date(2026, 7, 30),
date(2026, 7, 31),
date(2026, 8, 1),
).inOrder()
}
@Test
fun `an until before the start yields nothing instead of spinning`() {
val occurrences = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(date(2026, 7, 1)),
).upcomingOccurrences(date(2026, 7, 30), limit = 3)
assertThat(occurrences).isEmpty()
}
@Test
fun `a run inside one year needs no year, one that leaves it does`() {
val start = date(2026, 7, 30)
val withinYear = SimpleRecurrence(RecurrenceFreq.Daily).upcomingOccurrences(start, limit = 3)
assertThat(occurrencesSpanYears(withinYear, start)).isFalse()
// Yearly: same day and month every time, so the year is the only thing
// telling the three dates apart.
val yearly = SimpleRecurrence(RecurrenceFreq.Yearly).upcomingOccurrences(start, limit = 3)
assertThat(occurrencesSpanYears(yearly, start)).isTrue()
// Monthly rolling past December.
val newYearStart = date(2026, 11, 30)
val overflowing = SimpleRecurrence(RecurrenceFreq.Monthly)
.upcomingOccurrences(newYearStart, limit = 3)
assertThat(overflowing).containsExactly(
date(2026, 11, 30),
date(2026, 12, 30),
date(2027, 1, 30),
).inOrder()
assertThat(occurrencesSpanYears(overflowing, newYearStart)).isTrue()
}
@Test
fun `limit and count are both respected, whichever is smaller`() {
val rule = SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Count(2))
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 5)).hasSize(2)
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 1)).hasSize(1)
assertThat(rule.upcomingOccurrences(date(2026, 7, 30), limit = 0)).isEmpty()
}
}
@@ -0,0 +1,164 @@
package de.jeanlucmakiola.agendula.domain.recurrence
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import org.junit.jupiter.api.Test
class SimpleRecurrenceTest {
private val utc = TimeZone.UTC
private val berlin = TimeZone.of("Europe/Berlin")
@Test
fun `plain frequency parses with defaults`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly))
assertThat(parseSimpleRecurrence("FREQ=DAILY"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Daily))
}
@Test
fun `leading RRULE prefix and WKST are tolerated`() {
assertThat(parseSimpleRecurrence("RRULE:FREQ=MONTHLY;WKST=MO"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Monthly))
}
@Test
fun `interval parses`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;INTERVAL=2"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Weekly, interval = 2))
}
@Test
fun `until parses date-only and UTC datetime forms`() {
val expected = SimpleRecurrence(
RecurrenceFreq.Daily,
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
)
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801", utc)).isEqualTo(expected)
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T235959Z", utc))
.isEqualTo(expected)
}
@Test
fun `until datetime converts from UTC into the given zone before taking the date`() {
// 21:59:59Z == 23:59:59 in Berlin (CEST) — still August 1 there.
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801T215959Z", berlin))
.isEqualTo(
SimpleRecurrence(RecurrenceFreq.Daily, end = RecurrenceEnd.Until(LocalDate(2026, 8, 1))),
)
}
@Test
fun `count parses`() {
assertThat(parseSimpleRecurrence("FREQ=YEARLY;COUNT=5"))
.isEqualTo(SimpleRecurrence(RecurrenceFreq.Yearly, end = RecurrenceEnd.Count(5)))
}
@Test
fun `weekly byday parses as weekday picks`() {
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,FR"))
.isEqualTo(
SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.MONDAY, DayOfWeek.FRIDAY),
),
)
}
@Test
fun `rules beyond the simple shape are rejected`() {
// Ordinal BYDAY ("second Thursday") and BYDAY on non-weekly rules.
assertThat(parseSimpleRecurrence("FREQ=WEEKLY;BYDAY=MO,2TH")).isNull()
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYDAY=MO")).isNull()
assertThat(parseSimpleRecurrence("FREQ=MONTHLY;BYMONTHDAY=15")).isNull()
assertThat(parseSimpleRecurrence("FREQ=HOURLY")).isNull()
assertThat(parseSimpleRecurrence("")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;UNTIL=20260801;COUNT=3")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;INTERVAL=0")).isNull()
assertThat(parseSimpleRecurrence("FREQ=DAILY;COUNT=abc")).isNull()
}
@Test
fun `toRRule renders the minimal form`() {
assertThat(SimpleRecurrence(RecurrenceFreq.Weekly).toRRule()).isEqualTo("FREQ=WEEKLY")
assertThat(SimpleRecurrence(RecurrenceFreq.Daily, interval = 3).toRRule())
.isEqualTo("FREQ=DAILY;INTERVAL=3")
assertThat(
SimpleRecurrence(RecurrenceFreq.Monthly, end = RecurrenceEnd.Count(12)).toRRule(),
).isEqualTo("FREQ=MONTHLY;COUNT=12")
}
@Test
fun `toRRule renders weekdays in ISO order regardless of set order`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Weekly,
byDays = setOf(DayOfWeek.FRIDAY, DayOfWeek.MONDAY, DayOfWeek.WEDNESDAY),
).toRRule()
assertThat(rule).isEqualTo("FREQ=WEEKLY;BYDAY=MO,WE,FR")
}
@Test
fun `toRRule ignores weekday picks on non-weekly frequencies`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Monthly,
byDays = setOf(DayOfWeek.MONDAY),
).toRRule()
assertThat(rule).isEqualTo("FREQ=MONTHLY")
}
@Test
fun `toRRule writes until as the end of the chosen day in the given zone`() {
val rule = SimpleRecurrence(
RecurrenceFreq.Weekly,
interval = 2,
end = RecurrenceEnd.Until(LocalDate(2026, 8, 1)),
)
assertThat(rule.toRRule(utc))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T235959Z")
// 23:59:59 Berlin (CEST, +2) == 21:59:59Z.
assertThat(rule.toRRule(berlin))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;UNTIL=20260801T215959Z")
}
// 2026-06-19T23:59:00Z — a moment just before a June 20 occurrence.
private val cutoffMillis = 1_781_913_540_000L
@Test
fun `truncation replaces count and keeps every other part`() {
assertThat(rruleTruncatedAt("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;COUNT=30", cutoffMillis))
.isEqualTo("FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20260619T235900Z")
}
@Test
fun `truncation replaces an existing until`() {
assertThat(rruleTruncatedAt("FREQ=DAILY;UNTIL=20301231T235959Z", cutoffMillis))
.isEqualTo("FREQ=DAILY;UNTIL=20260619T235900Z")
}
@Test
fun `truncation works on rules the simple picker cannot express`() {
assertThat(rruleTruncatedAt("RRULE:FREQ=MONTHLY;BYDAY=2TH", cutoffMillis))
.isEqualTo("FREQ=MONTHLY;BYDAY=2TH;UNTIL=20260619T235900Z")
}
@Test
fun `parse and render round-trip`() {
val rules = listOf(
"FREQ=DAILY",
"FREQ=WEEKLY;INTERVAL=2",
"FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,FR;UNTIL=20301231T235959Z",
"FREQ=MONTHLY;COUNT=6",
"FREQ=YEARLY;UNTIL=20301231T235959Z",
)
rules.forEach { rule ->
assertThat(parseSimpleRecurrence(rule, utc)!!.toRRule(utc)).isEqualTo(rule)
}
// Round-trips through a non-UTC zone too: 22:59:59Z == 23:59:59 CET.
val berlinRule = "FREQ=WEEKLY;BYDAY=MO,FR;UNTIL=20301231T225959Z"
assertThat(parseSimpleRecurrence(berlinRule, berlin)!!.toRRule(berlin))
.isEqualTo(berlinRule)
}
}
@@ -0,0 +1,67 @@
package de.jeanlucmakiola.agendula.ui.common
import com.google.common.truth.Truth.assertThat
import java.time.LocalDate
import java.time.ZoneId
import org.junit.jupiter.api.Test
/**
* The read side of the UNTIL round-trip. `SimpleRecurrence.toRRule` deliberately
* writes the end of the chosen *local* day expressed in UTC, so displaying it
* means converting back into the device zone first.
*/
class RecurrenceTextTest {
private val berlin = ZoneId.of("Europe/Berlin")
private val losAngeles = ZoneId.of("America/Los_Angeles")
private val utc = ZoneId.of("UTC")
@Test
fun `UTC form converts back to the picked day west of UTC`() {
// toRRule("2026-12-31", America/Los_Angeles) → 23:59:59 local = 07:59:59Z
// on 1 Jan. Reading the digits raw would show 1 Jan 2027.
assertThat(untilLocalDate("20270101T075959Z", losAngeles))
.isEqualTo(LocalDate.of(2026, 12, 31))
}
@Test
fun `UTC form converts back to the picked day east of UTC`() {
// Berlin in December is UTC+1: 23:59:59 local = 22:59:59Z the same day.
assertThat(untilLocalDate("20261231T225959Z", berlin))
.isEqualTo(LocalDate.of(2026, 12, 31))
}
@Test
fun `UTC form is unchanged at UTC itself`() {
assertThat(untilLocalDate("20261231T235959Z", utc))
.isEqualTo(LocalDate.of(2026, 12, 31))
}
@Test
fun `summer offset is honoured, not a fixed one`() {
// Berlin in July is UTC+2, so the same local end-of-day lands at 21:59:59Z.
assertThat(untilLocalDate("20260801T215959Z", berlin))
.isEqualTo(LocalDate.of(2026, 8, 1))
}
@Test
fun `date-only form is already local and passes through`() {
assertThat(untilLocalDate("20261231", losAngeles))
.isEqualTo(LocalDate.of(2026, 12, 31))
}
@Test
fun `floating date-time form uses its date as-is`() {
// No trailing Z, so it isn't an instant — no conversion may be applied.
assertThat(untilLocalDate("20261231T235959", losAngeles))
.isEqualTo(LocalDate.of(2026, 12, 31))
}
@Test
fun `garbage returns null rather than throwing`() {
assertThat(untilLocalDate("", berlin)).isNull()
assertThat(untilLocalDate("nonsense", berlin)).isNull()
assertThat(untilLocalDate("2026", berlin)).isNull()
assertThat(untilLocalDate("20261340", berlin)).isNull()
}
}
@@ -0,0 +1,20 @@
package de.jeanlucmakiola.agendula.ui.navigation
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.Test
class DestinationsTest {
@Test
fun `task detail carries the occurrence only when there is one`() {
assertThat(Dest.TaskDetail.build(7)).isEqualTo("taskDetail/7")
assertThat(Dest.TaskDetail.build(7, 1_700_000_000_000)).isEqualTo("taskDetail/7?occ=1700000000000")
assertThat(Dest.TaskDetail.route).isEqualTo("taskDetail/{taskId}?occ={occ}")
}
@Test
fun `settings opens on the hub unless a section is named`() {
assertThat(Dest.Settings.build()).isEqualTo(Dest.SETTINGS)
assertThat(Dest.Settings.build(Dest.Settings.SECTION_ACCOUNTS)).isEqualTo("settings?section=Accounts")
}
}
@@ -0,0 +1,101 @@
package de.jeanlucmakiola.agendula.widget
import com.google.common.truth.Truth.assertThat
import de.jeanlucmakiola.agendula.domain.SmartList
import de.jeanlucmakiola.agendula.domain.TaskFilter
import de.jeanlucmakiola.agendula.domain.TaskSorting
import de.jeanlucmakiola.agendula.domain.TaskStatus
import de.jeanlucmakiola.agendula.domain.allDayInstantOf
import de.jeanlucmakiola.agendula.domain.testTask
import org.junit.jupiter.api.Test
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import kotlin.time.Instant
class WidgetDataTest {
private val newYork = ZoneId.of("America/New_York")
private val today = LocalDate.of(2026, 7, 20)
private fun at(local: String, zone: ZoneId = newYork): Instant =
Instant.fromEpochMilliseconds(LocalDateTime.parse(local).atZone(zone).toInstant().toEpochMilli())
private val now = at("2026-07-20T12:00:00")
// --- filter storage ------------------------------------------------------
@Test
fun `every offered filter round-trips through storage`() {
val filters = WIDGET_SMART_LISTS.map { TaskFilter.Smart(it) } + TaskFilter.OfList(42)
filters.forEach { assertThat(parseWidgetFilter(it.widgetStorageValue())).isEqualTo(it) }
}
@Test
fun `a fresh widget shows Today`() {
assertThat(parseWidgetFilter(null)).isEqualTo(TaskFilter.Smart(SmartList.TODAY))
}
@Test
fun `garbage and smart lists the widget does not offer fall back to Today`() {
listOf("", "list:", "list:-3", "list:abc", "smart:COMPLETED", "smart:nope", "whatever").forEach {
assertThat(parseWidgetFilter(it)).isEqualTo(DEFAULT_WIDGET_FILTER)
}
}
// --- due classification --------------------------------------------------
@Test
fun `an all-day due is read as its UTC date, not the local one`() {
// UTC midnight of the 21st is still the 20th in New York; read as timed it would say today.
val tomorrowAllDay = allDayInstantOf(today.plusDays(1))
assertThat(dueKind(tomorrowAllDay, allDay = true, today = today, zone = newYork)).isEqualTo(DueKind.TOMORROW)
assertThat(isOverdue(allDayInstantOf(today), allDay = true, now = now, today = today, zone = newYork)).isFalse()
}
@Test
fun `a timed task earlier today is today, and overdue`() {
val morning = at("2026-07-20T09:00:00")
assertThat(dueKind(morning, allDay = false, today = today, zone = newYork)).isEqualTo(DueKind.TODAY)
assertThat(isOverdue(morning, allDay = false, now = now, today = today, zone = newYork)).isTrue()
}
@Test
fun `past and later days classify by date`() {
assertThat(dueKind(at("2026-07-18T09:00:00"), false, today, newYork)).isEqualTo(DueKind.PAST)
assertThat(dueKind(at("2026-07-25T09:00:00"), false, today, newYork)).isEqualTo(DueKind.LATER)
assertThat(isOverdue(allDayInstantOf(today.minusDays(1)), true, now, today, newYork)).isTrue()
}
// --- rows ------------------------------------------------------------------
@Test
fun `closed tasks are dropped and the rest sorted`() {
val rows = widgetRows(
listOf(
testTask(id = 1, title = "later", due = at("2026-07-22T09:00:00")),
testTask(id = 2, title = "done", status = TaskStatus.COMPLETED),
testTask(id = 3, title = "soon", due = at("2026-07-20T15:00:00")),
),
TaskSorting.DEFAULT,
now,
newYork,
)
assertThat(rows.map { it.title }).containsExactly("soon", "later").inOrder()
}
@Test
fun `a subtask is left to its parent when both are shown, kept when orphaned`() {
val parent = testTask(id = 1, title = "parent")
val child = testTask(id = 2, title = "child").copy(parentId = 1)
val orphan = testTask(id = 3, title = "orphan").copy(parentId = 99)
val rows = widgetRows(listOf(parent, child, orphan), TaskSorting.DEFAULT, now, newYork)
assertThat(rows.map { it.title }).containsExactly("parent", "orphan")
}
@Test
fun `rows are capped`() {
val many = (1L..(MAX_WIDGET_ROWS + 10L)).map { testTask(id = it, title = "t$it") }
assertThat(widgetRows(many, TaskSorting.DEFAULT, now, newYork)).hasSize(MAX_WIDGET_ROWS)
}
}
@@ -0,0 +1,179 @@
package de.jeanlucmakiola.agendula.widget
import android.content.Intent
import com.google.common.truth.Truth.assertThat
import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.atStartOfDayIn
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import org.junit.jupiter.api.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Instant
/** The "when is the next local midnight" arithmetic, especially where midnight is not 00:00. */
class WidgetRolloverSchedulerTest {
private val berlin = TimeZone.of("Europe/Berlin")
private fun at(local: String, zone: TimeZone): Instant =
LocalDateTime.parse(local).toInstant(zone)
private fun nextRollover(local: String, zone: TimeZone = berlin): Instant =
WidgetRolloverScheduler.nextRolloverAt(at(local, zone), zone)
// --- the ordinary day ----------------------------------------------------
@Test
fun `midday rolls over at the coming midnight`() {
val next = nextRollover("2026-08-27T12:00:00")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `a second before midnight still targets tonight, not tomorrow night`() {
val next = nextRollover("2026-08-27T23:59:59")
assertThat(next).isEqualTo(at("2026-08-28T00:00:05", berlin))
}
@Test
fun `at midnight exactly the target is the next day, never the current instant`() {
// Re-arming after a firing must move a whole day on, or the widget wakes
// itself in a tight loop.
val now = at("2026-08-28T00:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2026-08-29T00:00:05", berlin))
assertThat(next - now).isGreaterThan(Duration.ZERO)
}
@Test
fun `re-arming from the slack instant itself moves a full day on`() {
// What actually happens in practice: the receiver runs at midnight + slack.
val now = at("2026-08-28T00:00:05", berlin)
assertThat(WidgetRolloverScheduler.nextRolloverAt(now, berlin))
.isEqualTo(at("2026-08-29T00:00:05", berlin))
}
@Test
fun `the result is always in the future for every minute of a day`() {
val zone = berlin
// Spans Berlin's 2024 spring-forward: the likeliest source of a target
// in the past, i.e. an alarm that fires immediately, forever.
var probe = LocalDateTime.parse("2024-03-29T00:00:00").toInstant(zone)
val end = LocalDateTime.parse("2024-04-01T00:00:00").toInstant(zone)
while (probe < end) {
assertThat(WidgetRolloverScheduler.nextRolloverAt(probe, zone)).isGreaterThan(probe)
probe += 1.minutes
}
}
// --- daylight saving -----------------------------------------------------
@Test
fun `spring forward keeps the rollover one day away, not one hour short`() {
// Berlin skipped 02:00-03:00 on 31 March 2024, so that day was 23h long.
// A rollover computed as "now + 24h" would land at 01:00 on 1 April.
val now = at("2024-03-30T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-03-31T00:00:05", berlin))
assertThat(next - now).isLessThan(24.hours)
}
@Test
fun `fall back does not overshoot into the repeated hour`() {
// Berlin repeated 02:00-03:00 on 27 October 2024: a 25h day, so
// "now + 24h" would land at 23:00 on the 26th and never roll over.
val now = at("2024-10-26T12:00:00", berlin)
val next = WidgetRolloverScheduler.nextRolloverAt(now, berlin)
assertThat(next).isEqualTo(at("2024-10-27T00:00:05", berlin))
assertThat(next.toLocalDateTime(berlin).date).isEqualTo(LocalDate.parse("2024-10-27"))
}
@Test
fun `a zone that repeats midnight takes the first start of day`() {
// Sao Paulo used to end DST by moving 00:00 back to 23:00, so the day
// began twice. Pinned as the accepted trade: the widget runs an hour
// ahead until the next redraw. No live zone does this since 2019.
val saoPaulo = TimeZone.of("America/Sao_Paulo")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2018-02-16T12:00:00", saoPaulo), saoPaulo,
)
val local = next.toLocalDateTime(saoPaulo)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-02-17"))
assertThat(local.hour).isEqualTo(0)
}
@Test
fun `a zone where midnight does not exist rolls over at the real start of day`() {
// Cuba starts DST at 00:00, so 11 March 2018 began at 01:00 in Havana.
// Targeting a literal 00:00 there would arm an instant on the wrong day.
val havana = TimeZone.of("America/Havana")
val next = WidgetRolloverScheduler.nextRolloverAt(at("2018-03-10T12:00:00", havana), havana)
val local = next.toLocalDateTime(havana)
assertThat(local.date).isEqualTo(LocalDate.parse("2018-03-11"))
assertThat(local.hour).isEqualTo(1)
assertThat(local.minute).isEqualTo(0)
// And it is genuinely the first instant of that date, not a guess.
assertThat(next).isEqualTo(
LocalDate.parse("2018-03-11").atStartOfDayIn(havana) +
WidgetRolloverScheduler.ROLLOVER_SLACK,
)
}
// --- timezone changes ----------------------------------------------------
@Test
fun `the same instant rolls over at different times in different zones`() {
// TIMEZONE_CHANGED must re-arm to the new local midnight: the arithmetic
// follows the zone, not a cached offset.
val instant = at("2026-08-27T12:00:00", berlin)
val tokyo = TimeZone.of("Asia/Tokyo")
val berlinNext = WidgetRolloverScheduler.nextRolloverAt(instant, berlin)
val tokyoNext = WidgetRolloverScheduler.nextRolloverAt(instant, tokyo)
assertThat(tokyoNext).isNotEqualTo(berlinNext)
assertThat(tokyoNext).isLessThan(berlinNext)
assertThat(tokyoNext.toLocalDateTime(tokyo).hour).isEqualTo(0)
}
@Test
fun `a half-hour offset zone still lands on its own midnight`() {
val kathmandu = TimeZone.of("Asia/Kathmandu")
val next = WidgetRolloverScheduler.nextRolloverAt(
at("2026-08-27T12:00:00", kathmandu), kathmandu,
)
val local = next.toLocalDateTime(kathmandu)
assertThat(local.date.toString()).isEqualTo("2026-08-28")
assertThat(local.hour).isEqualTo(0)
}
// --- the wiring ----------------------------------------------------------
@Test
fun `the receiver actually handles the action the alarm is sent with`() {
// The single point where the whole fix would die silently: the alarm
// fires, the receiver drops it on the action guard, nothing redraws.
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS)
.contains(WidgetUpdateReceiver.ACTION_ROLLOVER)
}
@Test
fun `a language change redraws without re-arming`() {
assertThat(WidgetUpdateReceiver.HANDLED_ACTIONS).contains(Intent.ACTION_LOCALE_CHANGED)
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).doesNotContain(Intent.ACTION_LOCALE_CHANGED)
}
@Test
fun `everything that loses the alarm re-arms it`() {
assertThat(WidgetUpdateReceiver.REARM_ACTIONS).containsAtLeast(
WidgetUpdateReceiver.ACTION_ROLLOVER,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
Intent.ACTION_DATE_CHANGED,
)
}
}
+32
View File
@@ -0,0 +1,32 @@
# VTODO fixture corpus
The mapper's **specification**, not its regression net. Every file here is a
shape that was found in the wild and that a plausible implementation gets wrong.
A clean, server-generated VTODO catches nothing and is deliberately absent.
`RoundTripTest` asserts every fixture survives `parse → map → write → serialise`
with no property lost, compared as a canonical multiset modulo an allowlist
(`PRODID`, `DTSTAMP`, `LAST-MODIFIED`, `SEQUENCE`,
VTIMEZONE bodies, fold positions, parameter quoting).
| File | What it is for |
|---|---|
| `nextcloud-shared-confidential.ics` | A `CLASS:CONFIDENTIAL` task as Nextcloud rewrites it on GET from a **shared** calendar — `DUE`, `STATUS`, `COMPLETED`, `PERCENT-COMPLETE`, `PRIORITY` and `RELATED-TO` already stripped, ETag left untouched. Re-PUTting this destroys the owner's task |
| `recurring-master-overrides.ics` | Master plus two `RECURRENCE-ID` overrides in one resource, sharing a UID |
| `valarm-unknown-nested.ics` | Unknown properties **inside** a `VALARM` — the case a flat property-per-row model cannot represent |
| `unknown-component.ics` | An entirely unknown sub-component |
| `unknown-tzid.ics` | A `TZID` no device tzdb knows, with its own `VTIMEZONE` |
| `uid-special-chars.ics` | A UID containing `/` and `@` — the href sanitiser must not assume it is filename-safe |
| `rrule-due-no-dtstart.ics` | `RRULE` + `DUE` and no `DTSTART`: no well-defined `RECURRENCE-ID`, undefined in RFC 5545, ubiquitous in the wild |
| `moz-alarm-props.ics` | `X-MOZ-LASTACK` / `X-MOZ-SNOOZE-TIME`. Dropping them causes documented Thunderbird alarm storms |
| `apple-sort-order.ics` | `X-APPLE-SORT-ORDER` — preserve, never interpret |
| `floating-time.ics` | `DTSTART` with neither `Z` nor `TZID`. Readable, not reproducible: it must round-trip from the residue, not be rewritten as a guess |
| `completion-model-a.ics` | Completion as a `RECURRENCE-ID` override, master stays open — jtx Board, Thunderbird. **The model we write** |
| `completion-model-b.ics` | Master's `DUE` advanced in place, completion cleared — tasks.org, Evolution, Nextcloud in practice |
| `completion-model-c.ics` | `STATUS:COMPLETED` on the master, killing the series — Nextcloud Tasks ≤ 0.17. **Always a bug**; we read it, we never write it |
| `completion-model-d.ics` | The completed occurrence detached as a new task with a new UID — OpenTasks |
| `quoted-param-colon.ics` | A quoted parameter value containing a colon and a comma. The value separator is the first colon *outside* quotes |
| `mixed-value-types.ics` | `DTSTART;VALUE=DATE` with a timed `DUE`. One `is_all_day` flag cannot author both, so the odd one out must stay in the residue rather than be flattened to a date |
| `malformed-scalars.ics` | `PRIORITY:11`, `PERCENT-COMPLETE:150`, `SEQUENCE:x`, `STATUS:X-DEFERRED`, `CLASS:X-INTERNAL` — values no spec allows. Clamping or defaulting any of them is a silent rewrite of somebody's data |
| `two-timezones.ics` | `DTSTART` and `DUE` in different zones. One `timezone` column cannot author both |
| `folded-utf8.ics` | A line folded mid-emoji-adjacent, to prove folding counts octets and splits on character boundaries |
@@ -0,0 +1,11 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Apple Inc.//macOS 15.3//EN
BEGIN:VTODO
UID:apple-sort-001
DTSTAMP:20260901T120000Z
SUMMARY:Buy milk
X-APPLE-SORT-ORDER:734829163
PRIORITY:3
END:VTODO
END:VCALENDAR
@@ -0,0 +1,22 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//jtx Board//EN
BEGIN:VTODO
UID:model-a-001
DTSTAMP:20260901T120000Z
SUMMARY:Take out the bins
DTSTART;VALUE=DATE:20260901
RRULE:FREQ=WEEKLY;BYDAY=TU
STATUS:NEEDS-ACTION
END:VTODO
BEGIN:VTODO
UID:model-a-001
RECURRENCE-ID;VALUE=DATE:20260901
DTSTAMP:20260901T190000Z
SUMMARY:Take out the bins
DTSTART;VALUE=DATE:20260901
STATUS:COMPLETED
COMPLETED:20260901T190000Z
PERCENT-COMPLETE:100
END:VTODO
END:VCALENDAR
@@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//tasks.org//EN
BEGIN:VTODO
UID:model-b-001
DTSTAMP:20260908T190000Z
SUMMARY:Take out the bins
DUE;VALUE=DATE:20260908
RRULE:FREQ=WEEKLY;BYDAY=TU
STATUS:NEEDS-ACTION
END:VTODO
END:VCALENDAR
@@ -0,0 +1,14 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nextcloud Tasks 0.17//EN
BEGIN:VTODO
UID:model-c-001
DTSTAMP:20260901T190000Z
SUMMARY:Take out the bins
DUE;VALUE=DATE:20260901
RRULE:FREQ=WEEKLY;BYDAY=TU
STATUS:COMPLETED
COMPLETED:20260901T190000Z
PERCENT-COMPLETE:100
END:VTODO
END:VCALENDAR
@@ -0,0 +1,20 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//dmfs.org/OpenTasks//EN
BEGIN:VTODO
UID:model-d-001
DTSTAMP:20260908T190000Z
SUMMARY:Take out the bins
DUE;VALUE=DATE:20260908
RRULE:FREQ=WEEKLY;BYDAY=TU
STATUS:NEEDS-ACTION
END:VTODO
BEGIN:VTODO
UID:model-d-detached-001
DTSTAMP:20260901T190000Z
SUMMARY:Take out the bins
DUE;VALUE=DATE:20260901
STATUS:COMPLETED
COMPLETED:20260901T190000Z
END:VTODO
END:VCALENDAR
@@ -0,0 +1,11 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Radicale//NONSGML Radicale Server//EN
BEGIN:VTODO
UID:floating-001
DTSTAMP:20260901T120000Z
SUMMARY:Wake up wherever you are
DTSTART:20260905T070000
DUE:20260905T073000
END:VTODO
END:VCALENDAR
@@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Agendula//Test//EN
BEGIN:VTODO
UID:folded-utf8-001
DTSTAMP:20260901T120000Z
SUMMARY:Gießkanne füllen und die Pflanzen im Wintergarten gießen — 🌱🌿🪴
jede Woche montags
DESCRIPTION:Zeile eins\nZeile zwei mit Komma\, Semikolon\; und Backslash\\ dr
in
END:VTODO
END:VCALENDAR
@@ -0,0 +1,14 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Careless Client//EN
BEGIN:VTODO
UID:malformed-scalars-001
DTSTAMP:20260901T120000Z
SUMMARY:Values no spec allows
PRIORITY:11
PERCENT-COMPLETE:150
SEQUENCE:x
STATUS:X-DEFERRED
CLASS:X-INTERNAL
END:VTODO
END:VCALENDAR
@@ -0,0 +1,11 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mixed//EN
BEGIN:VTODO
UID:mixed-value-001
DTSTAMP:20260901T120000Z
SUMMARY:Starts on a day, due at a time
DTSTART;VALUE=DATE:20260901
DUE;TZID=Europe/Berlin:20260901T090000
END:VTODO
END:VCALENDAR
@@ -0,0 +1,18 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VTODO
UID:moz-alarm-001
DTSTAMP:20260901T120000Z
SUMMARY:Call the dentist
DUE;TZID=Europe/Berlin:20260905T100000
X-MOZ-LASTACK:20260904T090000Z
X-MOZ-SNOOZE-TIME:20260905T093000Z
X-MOZ-GENERATION:3
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Call the dentist
TRIGGER;RELATED=END:-PT15M
END:VALARM
END:VTODO
END:VCALENDAR
@@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nextcloud calendar v4.7.0
BEGIN:VTODO
UID:conf-shared-001
DTSTAMP:20260901T120000Z
SUMMARY:Quarterly filing
CLASS:CONFIDENTIAL
CREATED:20260801T090000Z
LAST-MODIFIED:20260901T115900Z
END:VTODO
END:VCALENDAR
@@ -0,0 +1,11 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//SOGo//EN
BEGIN:VTODO
UID:quoted-param-001
DTSTAMP:20260901T120000Z
SUMMARY:Review with the team
ATTENDEE;CN="Smith, J:r";PARTSTAT=NEEDS-ACTION:mailto:jr@example.com
ORGANIZER;CN=Alice:mailto:alice@example.com
END:VTODO
END:VCALENDAR
@@ -0,0 +1,33 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
BEGIN:VTODO
UID:series-weekly-001
DTSTAMP:20260901T120000Z
SUMMARY:Water the plants
DTSTART;TZID=Europe/Berlin:20260901T080000
DUE;TZID=Europe/Berlin:20260901T090000
RRULE:FREQ=WEEKLY;BYDAY=MO
STATUS:NEEDS-ACTION
END:VTODO
BEGIN:VTODO
UID:series-weekly-001
RECURRENCE-ID;TZID=Europe/Berlin:20260908T080000
DTSTAMP:20260908T081500Z
SUMMARY:Water the plants
DTSTART;TZID=Europe/Berlin:20260908T080000
DUE;TZID=Europe/Berlin:20260908T090000
STATUS:COMPLETED
PERCENT-COMPLETE:100
COMPLETED:20260908T081500Z
END:VTODO
BEGIN:VTODO
UID:series-weekly-001
RECURRENCE-ID;TZID=Europe/Berlin:20260915T080000
DTSTAMP:20260915T070000Z
SUMMARY:Water the plants (away — ask neighbour)
DTSTART;TZID=Europe/Berlin:20260915T080000
DUE;TZID=Europe/Berlin:20260915T090000
STATUS:NEEDS-ACTION
END:VTODO
END:VCALENDAR
@@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nextcloud Tasks//EN
BEGIN:VTODO
UID:rrule-no-dtstart-001
DTSTAMP:20260901T120000Z
SUMMARY:Pay rent
DUE;VALUE=DATE:20260901
RRULE:FREQ=MONTHLY;BYMONTHDAY=1
STATUS:NEEDS-ACTION
END:VTODO
END:VCALENDAR
@@ -0,0 +1,11 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Traveller//EN
BEGIN:VTODO
UID:two-zones-001
DTSTAMP:20260901T120000Z
SUMMARY:Leave Berlin, land in New York
DTSTART;TZID=Europe/Berlin:20260901T080000
DUE;TZID=America/New_York:20260901T140000
END:VTODO
END:VCALENDAR
@@ -0,0 +1,9 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp//CalDAV Client//EN
BEGIN:VTODO
UID:20260901T120000Z/task@example.com
DTSTAMP:20260901T120000Z
SUMMARY:UID that is not filename-safe
END:VTODO
END:VCALENDAR
@@ -0,0 +1,13 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Some Other Client//EN
BEGIN:VTODO
UID:unknown-comp-001
DTSTAMP:20260901T120000Z
SUMMARY:Task with a component we have never seen
BEGIN:X-VENDOR-METADATA
X-VENDOR-KEY:project-alpha
X-VENDOR-WEIGHT:7
END:X-VENDOR-METADATA
END:VTODO
END:VCALENDAR
@@ -0,0 +1,19 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Legacy Groupware//EN
BEGIN:VTIMEZONE
TZID:Custom/Company-HQ
BEGIN:STANDARD
DTSTART:19700101T000000
TZOFFSETFROM:+0130
TZOFFSETTO:+0130
TZNAME:HQ
END:STANDARD
END:VTIMEZONE
BEGIN:VTODO
UID:unknown-tzid-001
DTSTAMP:20260901T120000Z
SUMMARY:Board pack due
DUE;TZID=Custom/Company-HQ:20260910T170000
END:VTODO
END:VCALENDAR
@@ -0,0 +1,18 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Apple Inc.//iOS 18.2//EN
BEGIN:VTODO
UID:alarm-nested-001
DTSTAMP:20260901T120000Z
SUMMARY:Renew passport
DUE;TZID=Europe/Berlin:20261001T170000
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:Reminder
TRIGGER;RELATED=START:-PT30M
ACKNOWLEDGED:20260901T113000Z
X-WR-ALARMUID:8E6C4A1E-0000-4C1B-9B1A-3F5F4C2D9A11
X-APPLE-DEFAULT-ALARM:TRUE
END:VALARM
END:VTODO
END:VCALENDAR