diff options
author | Sunik Kupfer <kupfer@bitfire.at> | 2022-08-05 22:27:29 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-08-05 22:27:29 +0300 |
commit | ccea6cbd487b0872d7e399f290fd00ffbb08f37f (patch) | |
tree | 51db0b6a2c480ba5aed9f1d5410613b59cb2c789 | |
parent | 41171b57fdc5f045b17d09df85853a6cc13ef4fd (diff) |
validation and repairing (closes bitfireAT/ical4android#39, bitfireAT/davx5#8) (#45)
* Drop RRULEs with UNTIL before DTSTART
Note: Validation/repair rules should be better formalized and modularized
* Tests show timezone problems
* validation and repairing (closes bitfireAT/ical4android#39, bitfireAT/davx5#8)
* As discussed in the call
* validation and repairing (closes bitfireAT/ical4android#39, bitfireAT/davx5#8)
* Move validator to validation package
* Minor reformatting
* use time from DTSTART in UNTIL instead of midnight
* merge remote changes
* drop ICalPreprocessor validation rule
* make EventValidator a class with only "repair()" as API and other methods internal
* validate each Event created from a VEvent before saving it to calendar provider, as well as each Event retrieved from calendar provider before creating a VEvent from it
* add tests to cover timezone cases
* stop use of strings and parsing for date building and add a until timezone in test
* [WIP] fix test with timezone offsets
* be more lenient with the result when repairing events where time is cut off
Co-authored-by: Ricki Hirner <hirner@bitfire.at>
-rw-r--r-- | src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt | 12 | ||||
-rw-r--r-- | src/main/java/at/bitfire/ical4android/AndroidEvent.kt | 5 | ||||
-rw-r--r-- | src/main/java/at/bitfire/ical4android/Event.kt | 12 | ||||
-rw-r--r-- | src/main/java/at/bitfire/ical4android/ICalendar.kt | 1 | ||||
-rw-r--r-- | src/main/java/at/bitfire/ical4android/validation/EventValidator.kt | 107 | ||||
-rw-r--r-- | src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt (renamed from src/main/java/at/bitfire/ical4android/ICalPreprocessor.kt) | 13 | ||||
-rw-r--r-- | src/test/java/at/bitfire/ical4android/ICalPreprocessorTest.kt | 1 | ||||
-rw-r--r-- | src/test/java/at/bitfire/ical4android/validation/EventValidatorTest.kt | 314 |
8 files changed, 443 insertions, 22 deletions
diff --git a/src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt b/src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt index 5eb0bdc..c9ff0b2 100644 --- a/src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt +++ b/src/androidTest/java/at/bitfire/ical4android/AndroidEventTest.kt @@ -10,9 +10,7 @@ import android.content.ContentUris import android.content.ContentValues import android.database.DatabaseUtils import android.net.Uri -import android.provider.CalendarContract import android.provider.CalendarContract.* -import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.MiscUtils.ContentProviderClientHelper.closeCompat @@ -21,7 +19,6 @@ import at.bitfire.ical4android.impl.TestCalendar import at.bitfire.ical4android.impl.TestEvent import at.bitfire.ical4android.util.AndroidTimeUtils import net.fortuna.ical4j.model.* -import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.parameter.* import net.fortuna.ical4j.model.property.* @@ -31,7 +28,6 @@ import org.junit.Assert.* import java.net.URI import java.time.Duration import java.time.Period -import java.util.* class AndroidEventTest { @@ -49,7 +45,7 @@ class AndroidEventTest { @BeforeClass @JvmStatic fun connectProvider() { - provider = getInstrumentation().targetContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! + provider = getInstrumentation().targetContext.contentResolver.acquireContentProviderClient(AUTHORITY)!! } @AfterClass @@ -60,7 +56,7 @@ class AndroidEventTest { } - private val testAccount = Account("ical4android@example.com", CalendarContract.ACCOUNT_TYPE_LOCAL) + private val testAccount = Account("ical4android@example.com", ACCOUNT_TYPE_LOCAL) private val tzVienna = DateUtils.ical4jTimeZone("Europe/Vienna")!! private val tzShanghai = DateUtils.ical4jTimeZone("Asia/Shanghai")!! @@ -75,7 +71,7 @@ class AndroidEventTest { fun prepare() { calendar = TestCalendar.findOrCreate(testAccount, provider) assertNotNull(calendar) - calendarUri = ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendar.id) + calendarUri = ContentUris.withAppendedId(Calendars.CONTENT_URI, calendar.id) } @After @@ -1804,7 +1800,7 @@ class AndroidEventTest { @Test fun testPopulateReminder_TypeEmail_AccountNameNotEmail() { // test account name that doesn't look like an email address - val nonEmailAccount = Account("ical4android", CalendarContract.ACCOUNT_TYPE_LOCAL) + val nonEmailAccount = Account("ical4android", ACCOUNT_TYPE_LOCAL) val testCalendar = TestCalendar.findOrCreate(nonEmailAccount, provider) try { populateReminder(testCalendar) { diff --git a/src/main/java/at/bitfire/ical4android/AndroidEvent.kt b/src/main/java/at/bitfire/ical4android/AndroidEvent.kt index a465c06..4230ee0 100644 --- a/src/main/java/at/bitfire/ical4android/AndroidEvent.kt +++ b/src/main/java/at/bitfire/ical4android/AndroidEvent.kt @@ -25,6 +25,7 @@ import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate import at.bitfire.ical4android.util.TimeApiExtensions.toLocalTime import at.bitfire.ical4android.util.TimeApiExtensions.toRfc5545Duration import at.bitfire.ical4android.util.TimeApiExtensions.toZonedDateTime +import at.bitfire.ical4android.validation.EventValidator import net.fortuna.ical4j.model.* import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.component.VAlarm @@ -715,11 +716,12 @@ abstract class AndroidEvent( val dtStart = event.dtStart ?: throw InvalidCalendarException("Events must have DTSTART") val allDay = DateUtils.isDate(dtStart) - val recurring = event.rRules.isNotEmpty() || event.rDates.isNotEmpty() // make sure that time zone is supported by Android AndroidTimeUtils.androidifyTimeZone(dtStart) + val recurring = event.rRules.isNotEmpty() || event.rDates.isNotEmpty() + /* [CalendarContract.Events SDK documentation] When inserting a new event the following fields must be included: - dtstart @@ -778,6 +780,7 @@ abstract class AndroidEvent( builder .withValue(Events.DURATION, duration?.toRfc5545Duration(dtStart.date.toInstant())) .withValue(Events.DTEND, null) + // add RRULe if (event.rRules.isNotEmpty()) builder.withValue(Events.RRULE, event.rRules.joinToString(AndroidTimeUtils.RECURRENCE_RULE_SEPARATOR) { it.value }) else diff --git a/src/main/java/at/bitfire/ical4android/Event.kt b/src/main/java/at/bitfire/ical4android/Event.kt index fcbb67f..3f2e567 100644 --- a/src/main/java/at/bitfire/ical4android/Event.kt +++ b/src/main/java/at/bitfire/ical4android/Event.kt @@ -6,6 +6,7 @@ package at.bitfire.ical4android import at.bitfire.ical4android.DateUtils.isDateTime import at.bitfire.ical4android.ICalendar.Companion.CALENDAR_NAME +import at.bitfire.ical4android.validation.EventValidator import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.* @@ -194,13 +195,8 @@ class Event: ICalendar() { e.alarms.addAll(event.alarms) - // validation - if (e.dtStart == null) - throw InvalidCalendarException("Event without start time") - else if (e.dtEnd != null && e.dtStart!!.date > e.dtEnd!!.date) { - Ical4Android.log.warning("DTSTART after DTEND; removing DTEND") - e.dtEnd = null - } + // validate and repair + EventValidator(e).repair() return e } @@ -221,6 +217,8 @@ class Event: ICalendar() { val dtStart = dtStart ?: throw InvalidCalendarException("Won't generate event without start time") + EventValidator(this).repair() // validate and repair this event before creating VEVENT + // "main event" (without exceptions) val components = ical.components val mainEvent = toVEvent() diff --git a/src/main/java/at/bitfire/ical4android/ICalendar.kt b/src/main/java/at/bitfire/ical4android/ICalendar.kt index a6ea234..678b195 100644 --- a/src/main/java/at/bitfire/ical4android/ICalendar.kt +++ b/src/main/java/at/bitfire/ical4android/ICalendar.kt @@ -4,6 +4,7 @@ package at.bitfire.ical4android +import at.bitfire.ical4android.validation.ICalPreprocessor import net.fortuna.ical4j.data.CalendarBuilder import net.fortuna.ical4j.data.ParserException import net.fortuna.ical4j.model.Calendar diff --git a/src/main/java/at/bitfire/ical4android/validation/EventValidator.kt b/src/main/java/at/bitfire/ical4android/validation/EventValidator.kt new file mode 100644 index 0000000..ac0e108 --- /dev/null +++ b/src/main/java/at/bitfire/ical4android/validation/EventValidator.kt @@ -0,0 +1,107 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.ical4android.validation + +import at.bitfire.ical4android.DateUtils +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.Ical4Android +import at.bitfire.ical4android.InvalidCalendarException +import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDate +import at.bitfire.ical4android.util.TimeApiExtensions.toIcal4jDateTime +import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate +import at.bitfire.ical4android.util.TimeApiExtensions.toZoneIdCompat +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RRule +import java.time.* + +/** + * Sometimes CalendarStorage or servers respond with invalid event definitions. Here we try to + * validate, repair and assume whatever seems appropriate before denying the whole event. + */ +class EventValidator(val e: Event) { + + fun repair() { + val dtStart = correctStartAndEndTime(e) + sameTypeForDtStartAndRruleUntil(dtStart, e.rRules) + removeRRulesWithUntilBeforeDtStart(dtStart, e.rRules) + } + + companion object { + /** + * Ensure proper start and end time + */ + internal fun correctStartAndEndTime(e: Event): DtStart { + val dtStart = e.dtStart ?: throw InvalidCalendarException("Event without start time") + e.dtEnd?.let { dtEnd -> + if (dtStart.date > dtEnd.date) { + Ical4Android.log.warning("DTSTART after DTEND; removing DTEND") + e.dtEnd = null + } + } + return dtStart + } + + /** + * Tries to make the value type of UNTIL and DTSTART the same (both DATE or DATETIME). + */ + internal fun sameTypeForDtStartAndRruleUntil(dtStart: DtStart, rRules: MutableList<RRule>) { + if (DateUtils.isDate(dtStart)) { + for (rRule in rRules) { + rRule.recur.until?.let { until -> + if (until is DateTime) { + Ical4Android.log.warning("DTSTART has DATE, but UNTIL has DATETIME; making UNTIL have DATE only") + rRule.recur.until = until.toLocalDate().toIcal4jDate() + } + } + } + } else if (DateUtils.isDateTime(dtStart)) { + for (rRule in rRules) { + rRule.recur.until?.let { until -> + if (until !is DateTime) { + Ical4Android.log.warning("DTSTART has DATETIME, but UNTIL has DATE; copying time from DTSTART to UNTIL") + val timeZone = if (dtStart.timeZone != null) + dtStart.timeZone.toZoneIdCompat() + else if (dtStart.isUtc) + ZoneOffset.UTC + else /* floating time */ + ZoneId.systemDefault() + rRule.recur.until = + ZonedDateTime.of( + until.toLocalDate(), // date from until + LocalTime.ofInstant(dtStart.date.toInstant(), timeZone), // time from dtStart + timeZone + ).toIcal4jDateTime() + } + } + } + } else + throw InvalidCalendarException("Event with invalid DTSTART value") + } + + /** + * Will remove the RRULES of an event where UNTIL lies before DTSTART + */ + internal fun removeRRulesWithUntilBeforeDtStart(dtStart: DtStart, rRules: MutableList<RRule>) { + val iter = rRules.iterator() + while (iter.hasNext()) { + val rRule = iter.next() + + // drop invalid RRULEs + if (hasUntilBeforeDtStart(dtStart, rRule)) + iter.remove() + } + } + + /** + * Checks whether UNTIL of an RRULE lies before DTSTART + */ + internal fun hasUntilBeforeDtStart(dtStart: DtStart, rRule: RRule): Boolean { + val until = rRule.recur.until ?: return false + return until < dtStart.date + } + } +}
\ No newline at end of file diff --git a/src/main/java/at/bitfire/ical4android/ICalPreprocessor.kt b/src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt index ae7293c..f76ff12 100644 --- a/src/main/java/at/bitfire/ical4android/ICalPreprocessor.kt +++ b/src/main/java/at/bitfire/ical4android/validation/ICalPreprocessor.kt @@ -2,8 +2,9 @@ * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. **************************************************************************************************/ -package at.bitfire.ical4android +package at.bitfire.ical4android.validation +import at.bitfire.ical4android.Ical4Android import net.fortuna.ical4j.model.Calendar import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.transform.rfc5545.CreatedPropertyRule @@ -18,10 +19,10 @@ import java.util.* import java.util.logging.Level /** - * Applies some rules to increase compatibility or parsed iCalendars: + * Applies some rules to increase compatibility of parsed (incoming) iCalendars: * * - [CreatedPropertyRule] to make sure CREATED is UTC - * - [DatePropertyRule], [DateListPropertyRule]: to rename Outlook-specific TZID parameters + * - [DatePropertyRule], [DateListPropertyRule] to rename Outlook-specific TZID parameters * (like "W. Europe Standard Time" to an Android-friendly name like "Europe/Vienna") * */ @@ -30,10 +31,10 @@ object ICalPreprocessor { private val TZOFFSET_REGEXP = Regex("^(TZOFFSET(FROM|TO):[+\\-]?)((18|19|[2-6]\\d)\\d\\d)$", RegexOption.MULTILINE) private val propertyRules = arrayOf( - CreatedPropertyRule(), // make sure CREATED is UTC + CreatedPropertyRule(), // make sure CREATED is UTC - DatePropertyRule(), // These two rules also replace VTIMEZONEs of the iCalendar ... - DateListPropertyRule() // ... by the ical4j VTIMEZONE with the same TZID! + DatePropertyRule(), // These two rules also replace VTIMEZONEs of the iCalendar ... + DateListPropertyRule(), // ... by the ical4j VTIMEZONE with the same TZID! ) diff --git a/src/test/java/at/bitfire/ical4android/ICalPreprocessorTest.kt b/src/test/java/at/bitfire/ical4android/ICalPreprocessorTest.kt index 894452e..e1d614b 100644 --- a/src/test/java/at/bitfire/ical4android/ICalPreprocessorTest.kt +++ b/src/test/java/at/bitfire/ical4android/ICalPreprocessorTest.kt @@ -4,6 +4,7 @@ package at.bitfire.ical4android +import at.bitfire.ical4android.validation.ICalPreprocessor import net.fortuna.ical4j.data.CalendarBuilder import net.fortuna.ical4j.model.Component import net.fortuna.ical4j.model.component.VEvent diff --git a/src/test/java/at/bitfire/ical4android/validation/EventValidatorTest.kt b/src/test/java/at/bitfire/ical4android/validation/EventValidatorTest.kt new file mode 100644 index 0000000..c64d0d7 --- /dev/null +++ b/src/test/java/at/bitfire/ical4android/validation/EventValidatorTest.kt @@ -0,0 +1,314 @@ +/*************************************************************************************************** + * Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details. + **************************************************************************************************/ + +package at.bitfire.ical4android.validation + +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.InvalidCalendarException +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.Recur +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RRule +import org.junit.Assert.* +import org.junit.Test +import java.io.StringReader +import java.util.* + +class EventValidatorTest { + + val tzReg = TimeZoneRegistryFactory.getInstance().createRegistry() + + + // DTSTART and DTEND + + @Test + fun testEnsureCorrectStartAndEndTime_noDtStart() { + assertThrows(InvalidCalendarException::class.java) { + val event = Event().apply { + dtEnd = DtEnd(DateTime("20000105T000000")) // DATETIME + // no dtStart + } + EventValidator.correctStartAndEndTime(event) + } + + assertThrows(InvalidCalendarException::class.java) { + Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:51d8529a-5844-4609-918b-2891b855e0e8\n" + + "DTEND;VALUE=DATE:20211116\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + } + } + + @Test + fun testEnsureCorrectStartAndEndTime_dtEndBeforeDtStart() { + val event = Event().apply { + dtStart = DtStart(DateTime("20000105T001100")) // DATETIME + dtEnd = DtEnd(DateTime("20000105T000000")) // DATETIME + } + assertTrue(event.dtStart!!.date.time > event.dtEnd!!.date.time) + EventValidator.correctStartAndEndTime(event) + assertNull(event.dtEnd) + + val event1 = Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:51d8529a-5844-4609-918b-2891b855e0e8\n" + + "DTSTART;VALUE=DATE:20211117\n" + // DATE + "DTEND;VALUE=DATE:20211116\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + assertNull(event1.dtEnd) + } + + + // RRULE UNTIL and DTSTART of same type (DATETIME/DATE) + + @Test + fun testSameTypeForDtStartAndRruleUntil_DtStartAndRruleUntilAreBothDateTimeOrDate() { + // should do nothing when types are the same + + val event = Event().apply { + dtStart = DtStart(DateTime("20211115T001100Z")) // DATETIME (UTC) + rRules.add(RRule("FREQ=MONTHLY;UNTIL=20251214T001100Z")) // DATETIME (UTC) + } + assertEquals(DateTime("20211115T001100Z"), event.dtStart!!.date) + assertEquals(DateTime("20251214T001100Z"), event.rRules.first.recur.until) + EventValidator.sameTypeForDtStartAndRruleUntil(event.dtStart!!, event.rRules) + assertEquals(DateTime("20211115T001100Z"), event.dtStart!!.date) + assertEquals(DateTime("20251214T001100Z"), event.rRules.first.recur.until) + + val event1 = Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:51d8529a-5844-4609-918b-2891b855e0e8\n" + + "DTSTART;VALUE=DATE:20211115\n" + // DATE + "RRULE:FREQ=MONTHLY;UNTIL=20231214;BYMONTHDAY=15\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + assertEquals(Date("20231214"), event1.rRules.first.recur.until) + + val event2 = Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:381fb26b-2da5-4dd2-94d7-2e0874128aa7\n" + + "DTSTART;VALUE=DATE:20080215\n" + // DATE + "RRULE:FREQ=YEARLY;UNTIL=20230216;BYMONTHDAY=15\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + assertEquals(Date("20230216"), event2.rRules.first.recur.until) + } + + @Test + fun testSameTypeForDtStartAndRruleUntil_DtStartIsDateAndRruleUntilIsDateTime() { + // should remove (possibly existing) time in RRULE if DTSTART value is of type DATE (not DATETIME) + // we want time to be cut off hard, not taking time zones into account risking the date could flip when time is close to midnight + + val event = Event().apply { + dtStart = DtStart(Date("20211115")) // DATE + rRules.add(RRule("FREQ=MONTHLY;UNTIL=20211214T235959Z")) // DATETIME (UTC), close to flip up + } + assertEquals( + DateTime("20211214T235959Z"), + event.rRules.first.recur.until + ) + EventValidator.sameTypeForDtStartAndRruleUntil(event.dtStart!!, event.rRules) + assertEquals(Date("20211214"), event.rRules.first.recur.until) + + val event1 = Event.eventsFromReader( + StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:51d8529a-5844-4609-918b-2891b855e0e8\n" + + "DTSTART;VALUE=DATE:20211115\n" + // DATE + "RRULE:FREQ=MONTHLY;UNTIL=20211214T235959;BYMONTHDAY=15\n" + // DATETIME (no timezone), close to flip up + "END:VEVENT\n" + + "END:VCALENDAR" + ) + ).first() + assertEquals(1639440000000, event1.rRules.first.recur.until.time) + assertEquals(Date("20211214"), event1.rRules.first.recur.until) + } + + @Test + fun testSameTypeForDtStartAndRruleUntil_DtStartIsDateAndRruleUntilIsDateTime_2() { + val event2 = Event().apply { + dtStart = DtStart(Date("20080215")) // DATE (local/system time zone) + rRules.add(RRule("FREQ=YEARLY;TZID=Europe/Vienna;UNTIL=20230218T000000;BYMONTHDAY=15")) // DATETIME (with timezone), close to flip down + } + // NOTE: ical4j will: + // - ignore the time zone of the RRULE (TZID=Europe/Vienna) + // - use the timezone from DTSTART to determine the time value (timezone of DTSTART is local/system for a DATE) + // - take DST into account + // Because of this when running the test in a different timezone the date may flip down before we cut off time, making the test hard to predict. + // As it does not happen often, for the sake of simplicity we just accept either + EventValidator.sameTypeForDtStartAndRruleUntil(event2.dtStart!!, event2.rRules) + assertTrue( + Date("20230218") == event2.rRules.first.recur.until || + Date("20230217") == event2.rRules.first.recur.until + ) + } + + @Test + fun testSameTypeForDtStartAndRruleUntil_DtStartIsDateTimeAndRruleUntilIsDate() { + // should add (possibly missing) time in UNTIL if DTSTART value is of type DATETIME (not just DATE) + + val event = Event().apply { + dtStart = DtStart(DateTime("20110605T001100Z")) // DATETIME (UTC) + rRules.add(RRule("FREQ=MONTHLY;UNTIL=20211214")) // DATE + } + assertEquals(Date("20211214"), event.rRules.first.recur.until) + EventValidator.sameTypeForDtStartAndRruleUntil(event.dtStart!!, event.rRules) + assertEquals(DateTime("20211214T001100Z"), event.rRules.first.recur.until) + + val event1 = Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:51d8529a-5844-4609-918b-2891b855e0e8\n" + + "DTSTART;TZID=America/New_York:20211111T053000\n" + // DATETIME (with timezone) + "RRULE:FREQ=MONTHLY;UNTIL=20211214;BYMONTHDAY=15\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + assertEquals(DateTime("20211214T053000", tzReg.getTimeZone("America/New_York")), event1.rRules.first.recur.until) + + val event2 = Event.eventsFromReader(StringReader( + "BEGIN:VCALENDAR\n" + + "BEGIN:VEVENT\n" + + "UID:381fb26b-2da5-4dd2-94d7-2e0874128aa7\n" + + "DTSTART;VALUE=DATETIME:20080214T001100\n" + // DATETIME (no timezone) + "RRULE:FREQ=YEARLY;UNTIL=20110214;BYMONTHDAY=15\n" + // DATE + "END:VEVENT\n" + + "END:VCALENDAR")).first() + assertEquals(DateTime("20110214T001100"), event2.rRules.first.recur.until) + } + + + // RRULE UNTIL time before DTSTART time + + @Test + fun testHasUntilBeforeDtStart_DtStartTime_RRuleNoUntil() { + assertFalse( + EventValidator.hasUntilBeforeDtStart( + DtStart(DateTime("20220531T010203")), RRule()) + ) + } + + + @Test + fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeBeforeDtStart_UTC() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart("20220912", tzReg.getTimeZone("UTC")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220911T235959Z")) + .build()))) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeBeforeDtStart_noTimezone() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart("20220912"), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220911T235959")) + .build()))) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeBeforeDtStart_withTimezone() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart("20220912", tzReg.getTimeZone("America/New_York")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220911T235959", tzReg.getTimeZone("America/New_York"))) + .build()))) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_DateBeforeDtStart() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart("20220531"), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220530T000000")) + .build()))) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartDate_RRuleUntil_TimeAfterDtStart() { + assertFalse( + EventValidator.hasUntilBeforeDtStart(DtStart("20200912"), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220912T000001Z")) + .build())) + ) + } + + + @Test + fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_DateBeforeDtStart() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(Date("20220530")) + .build())) + ) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_TimeBeforeDtStart() { + assertTrue( + EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T010202")) + .build())) + ) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_TimeAtDtStart() { + assertFalse( + EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T010203")) + .build())) + ) + } + + @Test + fun testHasUntilBeforeDtStart_DtStartTime_RRuleUntil_TimeAfterDtStart() { + assertFalse( + EventValidator.hasUntilBeforeDtStart(DtStart(DateTime("20220531T010203")), RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T010204")) + .build())) + ) + } + + + @Test + fun testRemoveRRulesWithUntilBeforeDtStart() { + val dtStart = DtStart(DateTime("20220531T125304")) + val rruleBefore = RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T125303")) + .build()) + val rruleAfter = RRule(Recur.Builder() + .frequency(Recur.Frequency.DAILY) + .until(DateTime("20220531T125305")) + .build()) + + val rrules = mutableListOf( + rruleBefore, + rruleAfter + ) + EventValidator.removeRRulesWithUntilBeforeDtStart(dtStart, rrules) + assertArrayEquals(arrayOf( + // rRuleBefore has been removed because RRULE UNTIL is before DTSTART + rruleAfter + ), rrules.toTypedArray()) + } + +}
\ No newline at end of file |