feat(write): event delete + WRITE_CALENDAR foundation (v1.1)
First slice of milestone 2 (write support), per the new plan in docs/superpowers/plans/2026-06-11-03-write-support.md: - Delete from the event detail screen with confirmation; recurring events choose "only this event" (cancelled exception via CONTENT_EXCEPTION_URI, series survives) or "all events in the series" (Events-row delete) - WRITE_CALENDAR in the manifest; onboarding requests read+write in one system dialog but only read gates the app — declining write keeps it usable read-only. v1.0 installs get a contextual write request on their first delete - CALENDAR_ACCESS_LEVEL is read into CalendarSource.canModifyContents; read-only calendars (WebCal, birthdays, …) show no write actions. The no-op placeholder Edit button is removed until edit ships (v1.3) - Onboarding copy drops the now-false "read-only" claim (DE+EN) - Tests: repository delete delegation/error propagation, access-level mapping; FakeCalendarDataSource grows write ops Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package de.jeanlucmakiola.calendula.data.calendar
|
||||
|
||||
import android.provider.CalendarContract
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@@ -12,6 +13,7 @@ class CalendarMapperTest {
|
||||
accountType: String? = "LOCAL",
|
||||
color: Int = 0,
|
||||
visible: Int = 1,
|
||||
accessLevel: Int = CalendarContract.Calendars.CAL_ACCESS_OWNER,
|
||||
): MapColumnReader = MapColumnReader(
|
||||
CalendarProjection.IDX_ID to id,
|
||||
CalendarProjection.IDX_DISPLAY_NAME to displayName,
|
||||
@@ -19,6 +21,7 @@ class CalendarMapperTest {
|
||||
CalendarProjection.IDX_ACCOUNT_TYPE to accountType,
|
||||
CalendarProjection.IDX_COLOR to color,
|
||||
CalendarProjection.IDX_VISIBLE to visible,
|
||||
CalendarProjection.IDX_ACCESS_LEVEL to accessLevel,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -39,6 +42,7 @@ class CalendarMapperTest {
|
||||
accountType = "com.google",
|
||||
color = 0xFF112233.toInt(),
|
||||
isVisibleInSystem = true,
|
||||
canModifyContents = true,
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -65,4 +69,25 @@ class CalendarMapperTest {
|
||||
assertThat(src.accountName).isEqualTo("")
|
||||
assertThat(src.accountType).isEqualTo("")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contributor access and above can modify contents`() {
|
||||
val contributor = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_CONTRIBUTOR)
|
||||
val owner = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_OWNER)
|
||||
assertThat(contributor.toCalendarSource().canModifyContents).isTrue()
|
||||
assertThat(owner.toCalendarSource().canModifyContents).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `read access cannot modify contents`() {
|
||||
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_READ)
|
||||
assertThat(src.toCalendarSource().canModifyContents).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing access level defaults to read-only`() {
|
||||
// WebCal subscriptions on some providers report level 0 (CAL_ACCESS_NONE).
|
||||
val src = reader(accessLevel = CalendarContract.Calendars.CAL_ACCESS_NONE)
|
||||
assertThat(src.toCalendarSource().canModifyContents).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,43 @@ class CalendarRepositoryImplTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteEvent delegates to the data source`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.deleteEvent(eventId = 42L)
|
||||
|
||||
assertThat(fake.deletedEventIds).containsExactly(42L)
|
||||
assertThat(fake.deletedOccurrences).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteOccurrence forwards event id and begin time`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource()
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
repo.deleteOccurrence(eventId = 42L, beginMillis = 1_000L)
|
||||
|
||||
assertThat(fake.deletedOccurrences).containsExactly(42L to 1_000L)
|
||||
assertThat(fake.deletedEventIds).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteEvent propagates write failures`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
writeError = WriteFailedException("delete event id=42")
|
||||
}
|
||||
val repo = CalendarRepositoryImpl(fake, newPrefs(tempDir), Dispatchers.Unconfined)
|
||||
|
||||
try {
|
||||
repo.deleteEvent(eventId = 42L)
|
||||
error("Expected WriteFailedException")
|
||||
} catch (expected: WriteFailedException) {
|
||||
assertThat(expected.message).contains("42")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `eventDetail throws NoSuchEventException when data source returns null`(@TempDir tempDir: Path) = runTest {
|
||||
val fake = FakeCalendarDataSource().apply {
|
||||
|
||||
@@ -13,6 +13,11 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
var calendarsResult: List<CalendarSource> = emptyList()
|
||||
var instancesResult: (Long, Long) -> List<EventInstance> = { _, _ -> emptyList() }
|
||||
var eventDetailResult: (Long) -> EventDetail? = { null }
|
||||
/** Set to make the next write call throw. */
|
||||
var writeError: Exception? = null
|
||||
|
||||
val deletedEventIds = mutableListOf<Long>()
|
||||
val deletedOccurrences = mutableListOf<Pair<Long, Long>>()
|
||||
|
||||
private val listeners = mutableListOf<() -> Unit>()
|
||||
|
||||
@@ -20,6 +25,16 @@ internal class FakeCalendarDataSource : CalendarDataSource {
|
||||
override fun instances(beginMillis: Long, endMillis: Long): List<EventInstance> =
|
||||
instancesResult(beginMillis, endMillis)
|
||||
override fun eventDetail(eventId: Long): EventDetail? = eventDetailResult(eventId)
|
||||
|
||||
override fun deleteEvent(eventId: Long) {
|
||||
writeError?.let { throw it }
|
||||
deletedEventIds += eventId
|
||||
}
|
||||
|
||||
override fun deleteOccurrence(eventId: Long, beginMillis: Long) {
|
||||
writeError?.let { throw it }
|
||||
deletedOccurrences += eventId to beginMillis
|
||||
}
|
||||
override fun registerChangeListener(listener: () -> Unit) {
|
||||
listeners += listener
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user