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:
@@ -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)
|
||||
}
|
||||
+218
@@ -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()
|
||||
}
|
||||
}
|
||||
+121
@@ -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 & 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><tag> ä é � &bogus;</span>".htmlToPlainText())
|
||||
.isEqualTo("<tag> ä é � &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()
|
||||
}
|
||||
}
|
||||
+59
@@ -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)
|
||||
}
|
||||
}
|
||||
+483
@@ -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()
|
||||
}
|
||||
}
|
||||
+194
@@ -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()
|
||||
}
|
||||
}
|
||||
+164
@@ -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)
|
||||
}
|
||||
}
|
||||
+1330
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user