/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License version 2 as * published by the Free Software Foundation. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. */ #include #include #include #include #include #include #include "calendarstore.h" namespace { /// Wraps VEVENT lines (and optional extra components) into a VCALENDAR. QByteArray ics(const QString &body) { return QStringLiteral("BEGIN:VCALENDAR\r\nVERSION:2.0\r\n" "PRODID:-//example//test//EN\r\n%1END:VCALENDAR\r\n") .arg(body).toUtf8(); } QString vevent(const QString &lines) { return QStringLiteral("BEGIN:VEVENT\r\n%1END:VEVENT\r\n").arg(lines); } const char *kRomeVtimezone = "BEGIN:VTIMEZONE\r\nTZID:Europe/Rome\r\n" "BEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nTZNAME:CEST\r\n" "DTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\n" "BEGIN:STANDARD\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nTZNAME:CET\r\n" "DTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\n" "END:VTIMEZONE\r\n"; CalEvent parse(const QString &body, bool *unknown = nullptr) { bool ignored = false; return CalendarStore::parseEvent(ics(body), QStringLiteral("/x/e.ics"), QStringLiteral("x"), unknown ? unknown : &ignored); } void writeFile(const QString &path, const QByteArray &content) { QDir().mkpath(QFileInfo(path).absolutePath()); QFile file(path); QVERIFY2(file.open(QIODevice::WriteOnly | QIODevice::Truncate), qPrintable(file.errorString())); file.write(content); } QList expand(const QString &body, const QDateTime &from, const QDateTime &to, CalEvent *out = nullptr) { const CalEvent event = parse(body); if (out) *out = event; return CalendarStore::occurrences({ event }, from, to); } QDateTime utc(int y, int m, int d, int h = 0, int min = 0) { return QDateTime(QDate(y, m, d), QTime(h, min), QTimeZone::utc()); } } // namespace class TestCalendarStore : public QObject { Q_OBJECT private slots: void parsesAUtcEvent(); void readsATzidWithItsVtimezone(); void readsATzidWithoutAVtimezoneAsIana(); void readsAnUnknownTzidAsLocalAndSaysSo(); void readsFloatingTimeAsLocal(); void readsAnAllDayEventWithAnExclusiveEnd(); void loadReadsCollectionsAndFallsBack(); void loadSkipsAndCountsABrokenFile(); void expandsAWeeklyEventAcrossDstInItsOwnZone(); void expandsAnAllDayEventOntoOneDay(); void removesAnExdate(); void anOverrideReplacesItsSlotAndMovesFreely(); void anInfiniteSeriesYieldsOnlyTheWindow(); void honoursCountAndUntil(); void expandsMonthlyLastFridayAndYearlyByMonth(); void readsTheRepeatRuleExdatesAndOverrides(); void expandsADenseOldSeriesToTheWindow(); void anAllDayOverrideKeepsItsOwnAllDayFlag(); }; void TestCalendarStore::parsesAUtcEvent() { bool unknownZone = true; const CalEvent event = CalendarStore::parseEvent( ics(vevent(QStringLiteral( "UID:utc-1@example.org\r\nDTSTAMP:20260901T000000Z\r\n" "SUMMARY:Standup\r\nLOCATION:Room 2\r\n" "DTSTART:20260922T080000Z\r\nDTEND:20260922T081500Z\r\n" "SEQUENCE:3\r\n"))), QStringLiteral("/tmp/x/utc-1.ics"), QStringLiteral("x"), &unknownZone); QCOMPARE(event.uid, QStringLiteral("utc-1@example.org")); QCOMPARE(event.summary, QStringLiteral("Standup")); QCOMPARE(event.location, QStringLiteral("Room 2")); QCOMPARE(event.start, QDateTime(QDate(2026, 9, 22), QTime(8, 0), QTimeZone::utc())); QCOMPARE(event.end, QDateTime(QDate(2026, 9, 22), QTime(8, 15), QTimeZone::utc())); QVERIFY(!event.allDay); QCOMPARE(event.sequence, 3); QCOMPARE(event.filePath, QStringLiteral("/tmp/x/utc-1.ics")); QVERIFY(!unknownZone); QVERIFY(!event.rawText.isEmpty()); } void TestCalendarStore::readsATzidWithItsVtimezone() { const CalEvent e = parse(QString::fromLatin1(kRomeVtimezone) + vevent(QStringLiteral( "UID:a@example.org\r\nDTSTART;TZID=Europe/Rome:20260922T100000\r\n" "DTEND;TZID=Europe/Rome:20260922T110000\r\n"))); // 10:00 in Rome in September is 08:00 UTC (CEST, +2). QCOMPARE(e.start.toUTC(), QDateTime(QDate(2026, 9, 22), QTime(8, 0), QTimeZone::utc())); QCOMPARE(e.end.toUTC(), QDateTime(QDate(2026, 9, 22), QTime(9, 0), QTimeZone::utc())); } void TestCalendarStore::readsATzidWithoutAVtimezoneAsIana() { // 120 of the user's 312 files are shaped like this: RFC 5545 requires the // VTIMEZONE, and real servers omit it. bool unknown = true; const CalEvent e = parse(vevent(QStringLiteral( "UID:b@example.org\r\nDTSTART;TZID=Europe/Rome:20260922T100000\r\n" "DTEND;TZID=Europe/Rome:20260922T110000\r\n")), &unknown); QCOMPARE(e.start.toUTC(), QDateTime(QDate(2026, 9, 22), QTime(8, 0), QTimeZone::utc())); QVERIFY(!unknown); } void TestCalendarStore::readsAnUnknownTzidAsLocalAndSaysSo() { bool unknown = false; const CalEvent e = parse(vevent(QStringLiteral( "UID:c@example.org\r\nDTSTART;TZID=Not A Zone:20260922T100000\r\n" "DTEND;TZID=Not A Zone:20260922T110000\r\n")), &unknown); QVERIFY(unknown); QCOMPARE(e.start, QDateTime(QDate(2026, 9, 22), QTime(10, 0))); } void TestCalendarStore::readsFloatingTimeAsLocal() { const CalEvent e = parse(vevent(QStringLiteral( "UID:d@example.org\r\nDTSTART:20260922T100000\r\nDTEND:20260922T110000\r\n"))); QCOMPARE(e.start, QDateTime(QDate(2026, 9, 22), QTime(10, 0))); QCOMPARE(e.start.timeSpec(), Qt::LocalTime); } void TestCalendarStore::readsAnAllDayEventWithAnExclusiveEnd() { const CalEvent e = parse(vevent(QStringLiteral( "UID:e@example.org\r\nDTSTART;VALUE=DATE:20260924\r\nDTEND;VALUE=DATE:20260925\r\n"))); QVERIFY(e.allDay); QCOMPARE(e.start.date(), QDate(2026, 9, 24)); // Exclusive: a one-day event on the 24th ends at the START of the 25th. QCOMPARE(e.end.date(), QDate(2026, 9, 25)); QCOMPARE(e.end.time(), QTime(0, 0)); } void TestCalendarStore::loadReadsCollectionsAndFallsBack() { QTemporaryDir dir; const QString root = dir.path(); writeFile(root + QStringLiteral("/52/displayname"), "Work"); writeFile(root + QStringLiteral("/52/color"), "#60a5fa\n"); writeFile(root + QStringLiteral("/52/one.ics"), ics(vevent(QStringLiteral( "UID:one@example.org\r\nDTSTART:20260922T080000Z\r\n")))); writeFile(root + QStringLiteral("/31/two.ics"), ics(vevent(QStringLiteral( "UID:two@example.org\r\nDTSTART:20260923T080000Z\r\n")))); writeFile(root + QStringLiteral("/31/notes.txt"), "not a calendar file"); const LoadResult r = CalendarStore::load(root); QCOMPARE(r.collections.size(), 2); QCOMPARE(r.events.size(), 2); QCOMPARE(r.unparsable, 0); // Sorted by display name, so "31" (no displayname) sorts before "Work". QCOMPARE(r.collections[0].dir, QStringLiteral("31")); QCOMPARE(r.collections[0].displayName, QStringLiteral("31")); QVERIFY(r.collections[0].color.isValid()); QCOMPARE(r.collections[1].displayName, QStringLiteral("Work")); QCOMPARE(r.collections[1].color, QColor(QStringLiteral("#60a5fa"))); QVERIFY(!r.collections[1].readOnly); // The hashed fallback is stable across loads, or colours would shuffle. QCOMPARE(CalendarStore::load(root).collections[0].color, r.collections[0].color); } void TestCalendarStore::loadSkipsAndCountsABrokenFile() { QTemporaryDir dir; writeFile(dir.path() + QStringLiteral("/c/good.ics"), ics(vevent(QStringLiteral( "UID:good@example.org\r\nDTSTART:20260922T080000Z\r\n")))); writeFile(dir.path() + QStringLiteral("/c/bad.ics"), "this is not iCalendar"); const LoadResult r = CalendarStore::load(dir.path()); QCOMPARE(r.events.size(), 1); QCOMPARE(r.unparsable, 1); } void TestCalendarStore::expandsAWeeklyEventAcrossDstInItsOwnZone() { // 2026-10-25 is the last Sunday of October: Rome leaves CEST (+2) for CET // (+1). A weekly 10:00 meeting must stay at 10:00 in Rome on both sides, // which means its UTC time MOVES. Expanding in UTC would keep 08:00Z and // draw the second one at 09:00 local. const QTimeZone rome("Europe/Rome"); const QList occ = expand(vevent(QStringLiteral( "UID:w@example.org\r\nDTSTART;TZID=Europe/Rome:20261019T100000\r\n" "DTEND;TZID=Europe/Rome:20261019T110000\r\nRRULE:FREQ=WEEKLY\r\n")), utc(2026, 10, 18), utc(2026, 11, 1)); QCOMPARE(occ.size(), 2); QCOMPARE(occ[0].start.toTimeZone(rome).time(), QTime(10, 0)); QCOMPARE(occ[1].start.toTimeZone(rome).time(), QTime(10, 0)); QCOMPARE(occ[0].start.toUTC().time(), QTime(8, 0)); QCOMPARE(occ[1].start.toUTC().time(), QTime(9, 0)); QCOMPARE(occ[1].end.toTimeZone(rome).time(), QTime(11, 0)); } void TestCalendarStore::expandsAnAllDayEventOntoOneDay() { const QList occ = expand(vevent(QStringLiteral( "UID:ad@example.org\r\nDTSTART;VALUE=DATE:20260924\r\nDTEND;VALUE=DATE:20260925\r\n")), QDateTime(QDate(2026, 9, 25), QTime(0, 0)), QDateTime(QDate(2026, 9, 26), QTime(0, 0))); // The window starts on the 25th, where the event has already ENDED: an // inclusive end would draw it on the 25th too. QCOMPARE(occ.size(), 0); } void TestCalendarStore::removesAnExdate() { const QList occ = expand(vevent(QStringLiteral( "UID:x@example.org\r\nDTSTART:20260921T080000Z\r\nDTEND:20260921T090000Z\r\n" "RRULE:FREQ=DAILY\r\nEXDATE:20260922T080000Z\r\n")), utc(2026, 9, 21), utc(2026, 9, 24)); QCOMPARE(occ.size(), 2); QCOMPARE(occ[0].start, utc(2026, 9, 21, 8)); QCOMPARE(occ[1].start, utc(2026, 9, 23, 8)); } void TestCalendarStore::anOverrideReplacesItsSlotAndMovesFreely() { // A five-day series (21st-25th) whose 22nd is moved to the 30th. A window // over the 21st-23rd must NOT show the 22nd, since its slot is taken; a // window over the 30th, where the series has no slot at all, MUST show // the override, since an override is placed by its own start. const QString body = vevent(QStringLiteral( "UID:o@example.org\r\nDTSTART:20260921T080000Z\r\nDTEND:20260921T090000Z\r\n" "RRULE:FREQ=DAILY;COUNT=5\r\nSUMMARY:Series\r\n")) + vevent(QStringLiteral( "UID:o@example.org\r\nRECURRENCE-ID:20260922T080000Z\r\n" "DTSTART:20260930T150000Z\r\nDTEND:20260930T160000Z\r\nSUMMARY:Moved\r\n")); CalEvent event; QList occ = expand(body, utc(2026, 9, 21), utc(2026, 9, 24), &event); QCOMPARE(occ.size(), 2); // the 21st and 23rd QCOMPARE(event.overrides.size(), 1); QCOMPARE(event.overrides[0].summary, QStringLiteral("Moved")); occ = expand(body, utc(2026, 9, 30), utc(2026, 10, 1)); QCOMPARE(occ.size(), 1); // COUNT=5 ends on the 25th; only the override is here QVERIFY(occ[0].isOverride); QCOMPARE(occ[0].start, utc(2026, 9, 30, 15)); QCOMPARE(occ[0].recurrenceId, utc(2026, 9, 22, 8)); } void TestCalendarStore::anAllDayOverrideKeepsItsOwnAllDayFlag() { // The master is timed, the override is an all-day "day off". The override // occurrence must carry its OWN all-day state, not the master's. const QString body = vevent(QStringLiteral( "UID:adov@example.org\r\nDTSTART:20260921T080000Z\r\nDTEND:20260921T090000Z\r\n" "RRULE:FREQ=DAILY\r\n")) + vevent(QStringLiteral( "UID:adov@example.org\r\nRECURRENCE-ID:20260922T080000Z\r\n" "DTSTART;VALUE=DATE:20260922\r\nDTEND;VALUE=DATE:20260923\r\nSUMMARY:Day off\r\n")); const QDateTime day0(QDate(2026, 9, 22), QTime(0, 0)); const QList occ = expand(body, day0, day0.addDays(1)); QCOMPARE(occ.size(), 1); QVERIFY(occ[0].isOverride); QVERIFY(occ[0].allDay); QCOMPARE(occ[0].recurrenceId, utc(2026, 9, 22, 8)); } void TestCalendarStore::anInfiniteSeriesYieldsOnlyTheWindow() { const QList occ = expand(vevent(QStringLiteral( "UID:inf@example.org\r\nDTSTART:20000101T080000Z\r\nRRULE:FREQ=DAILY\r\n")), utc(2026, 9, 1), utc(2026, 9, 8)); QCOMPARE(occ.size(), 7); QCOMPARE(occ.first().start, utc(2026, 9, 1, 8)); } void TestCalendarStore::expandsADenseOldSeriesToTheWindow() { // A daily series from 1970 needs ~20700 iterator steps to reach a 2026 // window. At a 10000 cap the walk dies around 1997 and returns nothing. const QList occ = expand(vevent(QStringLiteral( "UID:dense@example.org\r\nDTSTART:19700101T080000Z\r\n" "DTEND:19700101T090000Z\r\nRRULE:FREQ=DAILY\r\n")), utc(2026, 9, 21), utc(2026, 9, 28)); QCOMPARE(occ.size(), 7); QCOMPARE(occ.first().start, utc(2026, 9, 21, 8)); QCOMPARE(occ.last().start, utc(2026, 9, 27, 8)); } void TestCalendarStore::honoursCountAndUntil() { QCOMPARE(expand(vevent(QStringLiteral( "UID:c@example.org\r\nDTSTART:20260901T080000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\n")), utc(2026, 9, 1), utc(2026, 10, 1)).size(), 3); QCOMPARE(expand(vevent(QStringLiteral( "UID:u@example.org\r\nDTSTART:20260901T080000Z\r\n" "RRULE:FREQ=DAILY;UNTIL=20260905T080000Z\r\n")), utc(2026, 9, 1), utc(2026, 10, 1)).size(), 5); } void TestCalendarStore::expandsMonthlyLastFridayAndYearlyByMonth() { // Last Fridays: 2026-09-25, 2026-10-30. QList occ = expand(vevent(QStringLiteral( "UID:lf@example.org\r\nDTSTART:20260925T080000Z\r\nRRULE:FREQ=MONTHLY;BYDAY=-1FR\r\n")), utc(2026, 9, 1), utc(2026, 11, 1)); QCOMPARE(occ.size(), 2); QCOMPARE(occ[1].start.date(), QDate(2026, 10, 30)); // Last Friday of September: 2026-09-25, 2027-09-24. occ = expand(vevent(QStringLiteral( "UID:ly@example.org\r\nDTSTART:20260925T080000Z\r\n" "RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1FR\r\n")), utc(2026, 1, 1), utc(2028, 1, 1)); QCOMPARE(occ.size(), 2); QCOMPARE(occ[1].start.date(), QDate(2027, 9, 24)); } void TestCalendarStore::readsTheRepeatRuleExdatesAndOverrides() { CalEvent e; expand(vevent(QStringLiteral( "UID:r@example.org\r\nDTSTART:20260921T080000Z\r\n" "RRULE:FREQ=MONTHLY;BYDAY=-1FR\r\nEXDATE:20261030T080000Z\r\n")), utc(2026, 9, 1), utc(2026, 9, 2), &e); QCOMPARE(e.repeat.freq, RepeatRule::Freq::Monthly); QCOMPARE(e.repeat.ordinal, -1); QCOMPARE(e.exdates, QList{ utc(2026, 10, 30, 8) }); } QTEST_MAIN(TestCalendarStore) #include "test_calendarstore.moc"