diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt index 186110a9..c897a99c 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandler.kt @@ -10,10 +10,12 @@ import android.content.Entity import at.bitfire.synctools.icalendar.AssociatedComponents import at.bitfire.synctools.mapping.jtx.handler.DescriptionHandler import at.bitfire.synctools.mapping.jtx.handler.JtxFieldHandler +import at.bitfire.synctools.mapping.jtx.handler.RecurrenceFieldsHandler import at.bitfire.synctools.storage.jtx.JtxObjectAndExceptions import at.techbee.jtx.JtxContract import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.component.CalendarComponent +import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.component.VJournal import net.fortuna.ical4j.model.component.VToDo import net.fortuna.ical4j.model.property.ProdId @@ -29,7 +31,8 @@ class JtxObjectHandler( private val prodId: ProdId ) { private val fieldHandlers: Array = arrayOf( - DescriptionHandler() + DescriptionHandler(), + RecurrenceFieldsHandler() ) /** @@ -53,8 +56,8 @@ class JtxObjectHandler( main = jtxObjectAndExceptions.main ) - val rRules = main.getProperties>(Property.RRULE) - val exceptions: List = if (rRules.isNotEmpty()) { + val isRecurring = main.containsProperty>(Property.RRULE) || main.containsProperty>(Property.RDATE) + val exceptions: List = if (isRecurring) { // add exceptions to recurring main jtx object jtxObjectAndExceptions.exceptions.map { exception -> mapJtxObject( @@ -110,6 +113,10 @@ class JtxObjectHandler( return JtxContract.JtxICalObject.Component.valueOf(componentValue) } + private fun CalendarComponent.containsProperty(name: String): Boolean { + return getProperties(name).isNotEmpty() + } + /** * Result of the [mapToCalendarComponents] operation. * diff --git a/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt new file mode 100644 index 00000000..ac1a467d --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandler.kt @@ -0,0 +1,146 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.jtx.handler + +import android.content.Entity +import at.bitfire.synctools.icalendar.plusAssign +import at.techbee.jtx.JtxContract +import at.techbee.jtx.JtxContract.JtxICalObject.TZ_ALLDAY +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.ParameterList +import net.fortuna.ical4j.model.component.CalendarComponent +import net.fortuna.ical4j.model.parameter.TzId +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.property.DateListProperty +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.Temporal +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Handler for recurrence-related fields of a jtx Board content provider data row. + */ +class RecurrenceFieldsHandler : JtxFieldHandler { + + private val logger + get() = Logger.getLogger(javaClass.name) + + override fun process(from: Entity, main: Entity, to: CalendarComponent) { + if (from === main) { + processMain(from, to) + } else { + processException(from, to) + } + } + + private fun processMain(from: Entity, to: CalendarComponent) { + val values = from.entityValues + val dtstartTimezone = values.getAsString(JtxContract.JtxICalObject.DTSTART_TIMEZONE) + + values.getAsString(JtxContract.JtxICalObject.RRULE)?.takeIf { it.isNotBlank() }?.let { rruleString -> + try { + to += RRule(rruleString) + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse RRULE, ignoring", e) + } + } + + values.getAsString(JtxContract.JtxICalObject.RDATE)?.takeIf { it.isNotBlank() }?.let { rdateString -> + try { + val timestamps = JtxContract.getLongListFromString(rdateString) + if (timestamps.isNotEmpty()) { + to += timestampsToProperty(timestamps, dtstartTimezone) { RDate(it) } + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse RDATE, ignoring", e) + } + } + + values.getAsString(JtxContract.JtxICalObject.EXDATE)?.takeIf { it.isNotBlank() }?.let { exdateString -> + try { + val timestamps = JtxContract.getLongListFromString(exdateString) + if (timestamps.isNotEmpty()) { + to += timestampsToProperty(timestamps, dtstartTimezone) { ExDate(it) } + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse EXDATE, ignoring", e) + } + } + } + + private fun processException(from: Entity, to: CalendarComponent) { + val values = from.entityValues + val recurid = values.getAsString(JtxContract.JtxICalObject.RECURID) ?: return + val recuridTimezone = values.getAsString(JtxContract.JtxICalObject.RECURID_TIMEZONE) + + try { + to += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty() || recuridTimezone == ZoneOffset.UTC.id) { + RecurrenceId(recurid) + } else { + RecurrenceId(ParameterList(listOf(TzId(recuridTimezone))), recurid) + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Couldn't parse RECURID, ignoring", e) + } + } + + private fun > timestampsToProperty( + timestamps: List, + dtstartTimezone: String?, + factory: (DateList<*>) -> T + ): T { + val dateList = timestampsToDateList(timestamps, dtstartTimezone) + val property = factory(dateList) + when (val first = dateList.dates.firstOrNull()) { + is ZonedDateTime -> property.add(TzId(first.zone.id)) + is LocalDate -> property.add(Value.DATE) + } + return property + } + + private fun timestampsToDateList(timestamps: List, dtstartTimezone: String?): DateList<*> = + when { + dtstartTimezone == TZ_ALLDAY -> + DateList(timestamps.map { + LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) + }) + dtstartTimezone == ZoneOffset.UTC.id -> + DateList(timestamps.map { + Instant.ofEpochMilli(it) + }) + dtstartTimezone.isNullOrEmpty() -> + DateList(timestamps.map { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + }) + else -> { + val zone = try { + ZoneId.of(dtstartTimezone) + } catch (e: Exception) { + logger.log(Level.WARNING, "Invalid DTSTART_TIMEZONE '$dtstartTimezone', falling back to UTC", e) + null + } + if (zone != null) { + DateList(timestamps.map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), zone) + }) + } else { + DateList(timestamps.map { + Instant.ofEpochMilli(it) + }) + } + } + } +} diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandlerTest.kt index bdde7574..aae936e1 100644 --- a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandlerTest.kt +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/JtxObjectHandlerTest.kt @@ -20,6 +20,7 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import java.time.ZoneOffset @RunWith(RobolectricTestRunner::class) class JtxObjectHandlerTest { @@ -97,4 +98,70 @@ class JtxObjectHandlerTest { assertTrue(result.generatedUid) assertNotNull(result.uid) } + + @Test + fun `exceptions are mapped for recurring task with RRULE`() { + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.RECURID to "20260516T120000Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id + )) + val jtxObjectAndExceptions = JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.UID to "uid", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5" + )), + exceptions = listOf(exception) + ) + + val result = handler.mapToCalendarComponents(jtxObjectAndExceptions) + + assertEquals(1, result.associatedComponents.exceptions.size) + } + + @Test + fun `exceptions are mapped for recurring task with RDATE only`() { + // Regression test: exceptions must be included even when recurrence is expressed + // via RDATE alone (no RRULE). Previously only RRULE was checked. + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.RECURID to "20260516T120000Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id + )) + val jtxObjectAndExceptions = JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.UID to "uid", + JtxContract.JtxICalObject.DTSTART_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RDATE to "1779451200000" // 2026-05-22T12:00:00Z + )), + exceptions = listOf(exception) + ) + + val result = handler.mapToCalendarComponents(jtxObjectAndExceptions) + + assertEquals(1, result.associatedComponents.exceptions.size) + } + + @Test + fun `exceptions are dropped for non-recurring task`() { + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.RECURID to "20260516T120000Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id + )) + val jtxObjectAndExceptions = JtxObjectAndExceptions( + main = Entity(contentValuesOf( + JtxContract.JtxICalObject.COMPONENT to "VTODO", + JtxContract.JtxICalObject.UID to "uid" + // no RRULE, no RDATE + )), + exceptions = listOf(exception) + ) + + val result = handler.mapToCalendarComponents(jtxObjectAndExceptions) + + assertEquals(0, result.associatedComponents.exceptions.size) + } } diff --git a/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt new file mode 100644 index 00000000..e9b00400 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/synctools/mapping/jtx/handler/RecurrenceFieldsHandlerTest.kt @@ -0,0 +1,373 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.mapping.jtx.handler + +import android.content.ContentValues +import android.content.Entity +import androidx.core.content.contentValuesOf +import at.techbee.jtx.JtxContract +import at.techbee.jtx.JtxContract.JtxICalObject.TZ_ALLDAY +import net.fortuna.ical4j.model.Parameter +import net.fortuna.ical4j.model.Property +import net.fortuna.ical4j.model.component.VToDo +import net.fortuna.ical4j.model.parameter.TzId +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.time.LocalDate +import java.time.ZoneOffset +import kotlin.jvm.optionals.getOrNull + +@RunWith(RobolectricTestRunner::class) +class RecurrenceFieldsHandlerTest { + + private val handler = RecurrenceFieldsHandler() + + // ===== Main object tests ===== + + @Test + fun `No recurrence fields`() { + val from = Entity(ContentValues()) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + assertNull(output.getProperty>(Property.RRULE).getOrNull()) + assertNull(output.getProperty>(Property.RDATE).getOrNull()) + assertNull(output.getProperty>(Property.EXDATE).getOrNull()) + } + + @Test + fun `RRULE is mapped to main`() { + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + assertEquals( + "FREQ=DAILY;COUNT=5", + output.getProperty>(Property.RRULE).getOrNull()?.value + ) + } + + @Test + fun `RDATE with UTC timestamps`() { + // 1779451200000 ms = 2026-05-22T12:00:00Z + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779451200000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(1, rdate?.dates?.size) + assertEquals("20260522T120000Z", rdate?.value) + } + + @Test + fun `RDATE with all-day timestamps`() { + // 1779408000000 ms = 2026-05-22T00:00:00Z → all-day LocalDate 2026-05-22 + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to TZ_ALLDAY, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779408000000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(1, rdate?.dates?.size) + assertEquals(LocalDate.of(2026, 5, 22), rdate?.dates?.first()) + assertEquals(Value.DATE, rdate?.getParameter(Parameter.VALUE)?.getOrNull()) + } + + @Test + fun `RDATE with named timezone`() { + // 1779444000000 ms = 2026-05-22T10:00:00Z = 2026-05-22T12:00:00+02:00[Europe/Vienna] + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "Europe/Vienna", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779444000000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(1, rdate?.dates?.size) + assertEquals("20260522T120000", rdate?.value) + assertEquals("Europe/Vienna", rdate?.getParameter(Parameter.TZID)?.getOrNull()?.value) + } + + @Test + fun `RDATE with floating timestamps`() { + // null DTSTART_TIMEZONE → floating LocalDateTime + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779451200000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(1, rdate?.dates?.size) + // Floating datetimes have no TZID parameter + assertNull(rdate?.getParameter(Parameter.TZID)?.getOrNull()) + } + + @Test + fun `Multiple RDATE timestamps produce single RDate property with all dates`() { + // 1779451200000 = 2026-05-22T12:00:00Z, 1779624000000 = 2026-05-24T12:00:00Z + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779451200000,1779624000000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(2, rdate?.dates?.size) + } + + @Test + fun `RDATE with empty timestamps is ignored`() { + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RDATE to "" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + assertNull(output.getProperty>(Property.RDATE).getOrNull()) + } + + @Test + fun `EXDATE with UTC timestamps`() { + // 1779451200000 ms = 2026-05-22T12:00:00Z + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.EXDATE to "1779451200000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val exdate = output.getProperty>(Property.EXDATE).getOrNull() + assertNotNull(exdate) + assertEquals(1, exdate?.dates?.size) + assertEquals("20260522T120000Z", exdate?.value) + } + + @Test + fun `EXDATE with all-day timestamps`() { + // 1779408000000 ms = 2026-05-22T00:00:00Z → all-day LocalDate 2026-05-22 + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to TZ_ALLDAY, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.EXDATE to "1779408000000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val exdate = output.getProperty>(Property.EXDATE).getOrNull() + assertNotNull(exdate) + assertEquals(1, exdate?.dates?.size) + assertEquals(LocalDate.of(2026, 5, 22), exdate?.dates?.first()) + assertEquals(Value.DATE, exdate?.getParameter(Parameter.VALUE)?.getOrNull()) + } + + @Test + fun `EXDATE with named timezone`() { + // 1779444000000 ms = 2026-05-22T10:00:00Z = 2026-05-22T12:00:00+02:00[Europe/Vienna] + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "Europe/Vienna", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.EXDATE to "1779444000000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val exdate = output.getProperty>(Property.EXDATE).getOrNull() + assertNotNull(exdate) + assertEquals(1, exdate?.dates?.size) + assertEquals("20260522T120000", exdate?.value) + assertEquals("Europe/Vienna", exdate?.getParameter(Parameter.TZID)?.getOrNull()?.value) + } + + @Test + fun `Invalid RRULE is ignored without crash`() { + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.RRULE to "NOT_A_VALID_RRULE" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + assertNull(output.getProperty>(Property.RRULE).getOrNull()) + } + + @Test + fun `RDATE with invalid DTSTART_TIMEZONE falls back to UTC`() { + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.DTSTART_TIMEZONE to "INVALID_TIMEZONE", + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5", + JtxContract.JtxICalObject.RDATE to "1779451200000" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + val rdate = output.getProperty>(Property.RDATE).getOrNull() + assertNotNull(rdate) + assertEquals(1, rdate?.dates?.size) + assertEquals("20260522T120000Z", rdate?.value) + } + + @Test + fun `Main ignores RECURID`() { + val from = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260516T120000Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id, + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5" + )) + val output = VToDo() + + handler.process(from = from, main = from, to = output) + + assertNull(output.getProperty>(Property.RECURRENCE_ID).getOrNull()) + assertNotNull(output.getProperty>(Property.RRULE).getOrNull()) + } + + // ===== Exception tests ===== + + @Test + fun `Exception without RECURID adds no RECURRENCE-ID`() { + val main = Entity(contentValuesOf( + JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5" + )) + val exception = Entity(ContentValues()) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + assertNull(output.getProperty>(Property.RECURRENCE_ID).getOrNull()) + } + + @Test + fun `Exception with all-day RECURID`() { + val main = Entity(contentValuesOf(JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5")) + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260523", + JtxContract.JtxICalObject.RECURID_TIMEZONE to TZ_ALLDAY + )) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + val recurId = output.getProperty>(Property.RECURRENCE_ID).getOrNull() + assertNotNull(recurId) + assertEquals("20260523", recurId?.value) + assertNull(recurId?.getParameter(Parameter.TZID)?.getOrNull()) + } + + @Test + fun `Exception with floating RECURID`() { + val main = Entity(contentValuesOf(JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5")) + // null RECURID_TIMEZONE → floating + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260516T120000" + )) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + val recurId = output.getProperty>(Property.RECURRENCE_ID).getOrNull() + assertNotNull(recurId) + assertEquals("20260516T120000", recurId?.value) + assertNull(recurId?.getParameter(Parameter.TZID)?.getOrNull()) + } + + @Test + fun `Exception with UTC RECURID has no TZID parameter`() { + val main = Entity(contentValuesOf(JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5")) + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260516T120000Z", + JtxContract.JtxICalObject.RECURID_TIMEZONE to ZoneOffset.UTC.id + )) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + val recurId = output.getProperty>(Property.RECURRENCE_ID).getOrNull() + assertNotNull(recurId) + assertEquals("20260516T120000Z", recurId?.value) + assertNull(recurId?.getParameter(Parameter.TZID)?.getOrNull()) + } + + @Test + fun `Exception with named timezone RECURID`() { + val main = Entity(contentValuesOf(JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5")) + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260516T120000", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "Europe/Vienna" + )) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + val recurId = output.getProperty>(Property.RECURRENCE_ID).getOrNull() + assertNotNull(recurId) + assertEquals("20260516T120000", recurId?.value) + assertEquals("Europe/Vienna", recurId?.getParameter(Parameter.TZID)?.getOrNull()?.value) + } + + @Test + fun `Exception ignores RRULE, RDATE and EXDATE`() { + val main = Entity(contentValuesOf(JtxContract.JtxICalObject.RRULE to "FREQ=DAILY;COUNT=5")) + val exception = Entity(contentValuesOf( + JtxContract.JtxICalObject.RECURID to "20260516T120000", + JtxContract.JtxICalObject.RECURID_TIMEZONE to "Europe/Vienna", + JtxContract.JtxICalObject.RRULE to "FREQ=WEEKLY;COUNT=3", + JtxContract.JtxICalObject.RDATE to "1779451200000", + JtxContract.JtxICalObject.EXDATE to "1779624000000" + )) + val output = VToDo() + + handler.process(from = exception, main = main, to = output) + + assertNull(output.getProperty>(Property.RRULE).getOrNull()) + assertNull(output.getProperty>(Property.RDATE).getOrNull()) + assertNull(output.getProperty>(Property.EXDATE).getOrNull()) + assertNotNull(output.getProperty>(Property.RECURRENCE_ID).getOrNull()) + } +}