Files
nocal/tests/ics_tests.cpp
Bernardo Magri cfca6ab6d0 feat: support safe recurrence additions
Parse, persist, expand, and round-trip compatible RDATE values while preserving whole-series TUI behavior and conservative rewrite guards.

Verified: normal, warning-as-error, and ASan/UBSan builds pass all 9 suites; storage failure paths preserve calendar and backup bytes; live PTY restores terminal state.
2026-07-18 10:14:53 +01:00

856 lines
41 KiB
C++

#include "nocal/storage/ics_store.hpp"
#include <chrono>
#include <cstdlib>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <stdexcept>
#include <string>
#include <vector>
#if !defined(_WIN32)
#include <sys/stat.h>
#endif
namespace {
using namespace std::chrono;
void require(bool condition, const char* message) {
if (!condition) {
throw std::runtime_error(message);
}
}
void write_fixture(const std::filesystem::path& path, const std::string& contents) {
std::ofstream output(path, std::ios::binary);
output << contents;
require(static_cast<bool>(output), "failed to write test fixture");
}
std::string read_fixture(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
require(static_cast<bool>(input), "failed to open test fixture");
return {std::istreambuf_iterator<char>{input}, {}};
}
void require_no_temporary_files(const std::filesystem::path& path) {
const std::string prefix = "." + path.filename().string() + ".tmp.";
for (const auto& entry : std::filesystem::directory_iterator(path.parent_path())) {
require(!entry.path().filename().string().starts_with(prefix),
"atomic save left a temporary file behind");
}
}
nocal::Event event_named(std::string uid, std::string title, int day_offset = 0) {
nocal::Event event;
event.uid = std::move(uid);
event.title = std::move(title);
event.start = sys_days{year{2026}/July/17} + days{day_offset} + 9h;
event.end = event.start + 1h;
return event;
}
void test_round_trip_and_folding(const std::filesystem::path& path) {
nocal::Event timed;
timed.uid = "meeting-1@example.test";
timed.title = "Planning, review; and \\ decisions";
timed.start = sys_days{year{2026}/July/17} + 9h + 30min + 15s;
timed.end = timed.start + 45min;
timed.location = "Nomarchy HQ";
timed.description = "First line\nA long UTF-8 line: café café café café café café café café café café café café";
timed.calendar_id = "work";
timed.color = "#89b4fa";
nocal::Event all_day;
all_day.uid = "holiday-1@example.test";
all_day.title = "Release day";
all_day.start = sys_days{year{2026}/July/20};
all_day.end = sys_days{year{2026}/July/22};
all_day.all_day = true;
const std::vector<nocal::Event> source{timed, all_day};
const auto saved_revision = nocal::storage::IcsStore::save(path, source);
const auto loaded = nocal::storage::IcsStore::load(path);
require(saved_revision.existed() && saved_revision.size() > 0,
"save did not return an existing non-empty revision");
require(loaded.revision.existed() && loaded.revision.size() == saved_revision.size(),
"load did not retain the exact saved revision");
require(loaded.warnings.empty(), "saved calendar produced load warnings");
require(loaded.safe_to_rewrite, "nocal-generated calendar was not safe to rewrite");
require(loaded.events.size() == 2, "round-trip event count differs");
const auto& loaded_timed = loaded.events[0];
require(loaded_timed.uid == timed.uid, "UID did not round-trip");
require(loaded_timed.title == timed.title, "escaped SUMMARY did not round-trip");
require(loaded_timed.start == timed.start && loaded_timed.end == timed.end,
"timed interval did not round-trip");
require(loaded_timed.description == timed.description, "DESCRIPTION did not round-trip");
require(loaded_timed.calendar_id == timed.calendar_id && loaded_timed.color == timed.color,
"nocal extension properties did not round-trip");
require(loaded.events[1].all_day, "all-day flag did not round-trip");
require(loaded.events[1].start == all_day.start && loaded.events[1].end == all_day.end,
"all-day interval did not round-trip");
std::ifstream input(path, std::ios::binary);
const std::string bytes{std::istreambuf_iterator<char>{input}, {}};
require(bytes.find("\r\n ") != std::string::npos, "long property was not folded");
require(bytes.find("Planning\\, review\\; and \\\\ decisions") != std::string::npos,
"TEXT escaping was not written");
std::size_t offset = 0;
while (offset < bytes.size()) {
const std::size_t end = bytes.find("\r\n", offset);
require(end != std::string::npos, "physical line is not CRLF terminated");
require(end - offset <= 75, "folded physical line exceeds 75 octets");
offset = end + 2;
}
}
void test_local_utc_and_recovery(const std::filesystem::path& path) {
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:local\r\n"
"DTSTART:20260717T101500\r\n"
"DTEND:20260717T111500\r\n"
"SUMMARY:Local time\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:utc\r\n"
"DTSTART:20260717T101500Z\r\n"
"DTEND:20260717T111500Z\r\n"
"DESCRIPTION:folded text begins here and continues over a deliberately long value \r\n"
" with the rest\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:one-day\r\n"
"DTSTART;VALUE=DATE:20260718\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\n"
"UID:broken\r\n"
"DTSTART:not-a-date\r\n"
"END:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.events.size() == 3, "malformed VEVENT was not isolated");
require(!loaded.warnings.empty(), "malformed VEVENT was not diagnosed");
require(!loaded.safe_to_rewrite, "malformed source was considered safe to rewrite");
require(loaded.events[0].start == loaded.events[1].start,
"local and UTC DATE-TIME should agree while tests run in UTC");
require(loaded.events[2].all_day, "DATE DTSTART was not recognized");
require(loaded.events[2].end - loaded.events[2].start == 24h,
"implicit all-day DTEND should be the following day in UTC");
require(loaded.events[1].description.find("value with the rest") != std::string::npos,
"folded content line was not unfolded");
}
void test_missing_file(const std::filesystem::path& path) {
std::error_code ignored;
std::filesystem::remove(path, ignored);
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.events.empty() && loaded.warnings.empty(),
"missing calendar should load as empty without warnings");
require(loaded.safe_to_rewrite, "missing calendar should be safe to create");
require(!loaded.revision.existed() && loaded.revision.size() == 0,
"missing calendar produced an existing revision");
}
void test_nested_creation_and_permissions(const std::filesystem::path& root) {
const auto path = root / "new" / "nested" / "calendar.ics";
nocal::storage::IcsStore::save(path, {});
require(std::filesystem::is_regular_file(path), "save did not create a nested calendar path");
require(read_fixture(path).starts_with("BEGIN:VCALENDAR\r\n"),
"new calendar does not contain a VCALENDAR");
require_no_temporary_files(path);
#if !defined(_WIN32)
struct stat metadata {};
require(::stat(path.c_str(), &metadata) == 0, "could not stat saved calendar");
require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0,
"saved calendar is not readable and writable by its owner");
require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0,
"saved calendar grants group or other permissions");
#endif
}
void test_atomic_replacement(const std::filesystem::path& root) {
const auto path = root / "replace.ics";
write_fixture(path, "old calendar bytes");
nocal::Event replacement;
replacement.uid = "replacement@example.test";
replacement.title = "Replacement";
replacement.start = sys_days{year{2026}/July/23} + 10h;
replacement.end = replacement.start + 30min;
const std::vector<nocal::Event> events{replacement};
nocal::storage::IcsStore::save(path, events);
const std::string bytes = read_fixture(path);
require(bytes.find("old calendar bytes") == std::string::npos,
"save did not replace the existing calendar");
require(bytes.find("UID:replacement@example.test") != std::string::npos,
"replacement calendar bytes are incomplete");
require_no_temporary_files(path);
}
void test_revisions_reject_external_changes(const std::filesystem::path& root) {
const auto path = root / "revisions.ics";
const auto backup = nocal::storage::IcsStore::backup_path(path);
const std::vector<nocal::Event> original_events{
event_named("revision-1@example.test", "Original")};
const auto missing = nocal::storage::IcsStore::load(path);
const auto created = nocal::storage::IcsStore::save(path, original_events, missing.revision);
require(created.existed(), "created revision says the calendar does not exist");
require(!std::filesystem::exists(backup), "initial creation unexpectedly made a backup");
const auto loaded = nocal::storage::IcsStore::load(path);
const std::string original = read_fixture(path);
std::string externally_changed = original;
const auto title = externally_changed.find("SUMMARY:Original");
require(title != std::string::npos, "test fixture lacks the expected title");
externally_changed[title + std::string("SUMMARY:").size()] = 'X';
require(externally_changed.size() == original.size(), "external edit changed fixture size");
write_fixture(path, externally_changed);
write_fixture(backup, "existing backup must remain");
bool failed = false;
std::string message;
try {
const std::vector<nocal::Event> replacement{
event_named("revision-2@example.test", "Replacement")};
(void)nocal::storage::IcsStore::save(path, replacement, loaded.revision);
} catch (const std::runtime_error& error) {
failed = true;
message = error.what();
}
require(failed, "same-size external change was not rejected");
require(message.find("reload") != std::string::npos,
"external-change error did not tell the caller to reload");
require(read_fixture(path) == externally_changed,
"conflicting save changed the externally modified calendar");
require(read_fixture(backup) == "existing backup must remain",
"conflicting save changed the existing backup");
require_no_temporary_files(path);
}
void test_backup_replacement_failure_preserves_calendar(const std::filesystem::path& root) {
#if !defined(_WIN32)
const auto path = root / "backup-replacement-failure.ics";
const std::vector<nocal::Event> original_events{
event_named("survivor@example.test", "Must survive")};
const auto revision = nocal::storage::IcsStore::save(path, original_events);
const std::string before = read_fixture(path);
const auto backup = nocal::storage::IcsStore::backup_path(path);
std::filesystem::create_directory(backup);
write_fixture(backup / "sentinel", "backup destination must survive");
bool failed = false;
try {
const std::vector<nocal::Event> replacement{
event_named("replacement@example.test", "Replacement")};
(void)nocal::storage::IcsStore::save(path, replacement, revision);
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "save unexpectedly replaced a non-empty backup directory");
require(read_fixture(path) == before, "backup failure changed the calendar");
require(read_fixture(backup / "sentinel") == "backup destination must survive",
"backup failure damaged the existing backup destination");
require_no_temporary_files(path);
require_no_temporary_files(backup);
#else
(void)root;
#endif
}
void test_backup_rotation_permissions_and_restore(const std::filesystem::path& root) {
const auto path = root / "backup.ics";
const auto backup = nocal::storage::IcsStore::backup_path(path);
const std::vector<nocal::Event> first_events{event_named("first@example.test", "First")};
const auto first_revision = nocal::storage::IcsStore::save(path, first_events);
const std::string first_bytes = read_fixture(path);
require(!std::filesystem::exists(backup), "initial save created a backup");
const std::vector<nocal::Event> second_events{event_named("second@example.test", "Second", 1)};
const auto second_revision = nocal::storage::IcsStore::save(path, second_events, first_revision);
const std::string second_bytes = read_fixture(path);
require(read_fixture(backup) == first_bytes, "backup does not contain exact previous bytes");
#if !defined(_WIN32)
struct stat metadata {};
require(::stat(backup.c_str(), &metadata) == 0, "could not stat calendar backup");
require((metadata.st_mode & (S_IRWXG | S_IRWXO)) == 0,
"calendar backup grants group or other permissions");
require((metadata.st_mode & S_IRUSR) != 0 && (metadata.st_mode & S_IWUSR) != 0,
"calendar backup is not owner-readable and owner-writable");
#endif
const std::vector<nocal::Event> third_events{event_named("third@example.test", "Third", 2)};
const auto third_revision = nocal::storage::IcsStore::save(path, third_events, second_revision);
require(read_fixture(backup) == second_bytes, "backup did not rotate to the previous revision");
const std::string backup_before_restore = read_fixture(backup);
const auto restored = nocal::storage::IcsStore::restore_backup(path, third_revision);
require(restored.existed() && restored.size() == backup_before_restore.size(),
"restore returned an incorrect revision");
require(read_fixture(path) == second_bytes, "restore did not install exact backup bytes");
require(read_fixture(backup) == backup_before_restore, "restore changed the backup");
std::string external = read_fixture(path);
external[0] = external[0] == 'B' ? 'X' : 'B';
write_fixture(path, external);
bool conflict = false;
try {
(void)nocal::storage::IcsStore::restore_backup(path, restored);
} catch (const std::runtime_error&) {
conflict = true;
}
require(conflict, "restore ignored an external calendar change");
require(read_fixture(path) == external, "conflicting restore changed the calendar");
require(read_fixture(backup) == backup_before_restore,
"conflicting restore changed the backup");
require_no_temporary_files(path);
}
void test_restore_requires_backup(const std::filesystem::path& root) {
const auto path = root / "missing-backup.ics";
const std::vector<nocal::Event> events{event_named("only@example.test", "Only")};
const auto revision = nocal::storage::IcsStore::save(path, events);
const std::string before = read_fixture(path);
bool failed = false;
try {
(void)nocal::storage::IcsStore::restore_backup(path, revision);
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "restore unexpectedly succeeded without a backup");
require(read_fixture(path) == before, "failed restore changed the calendar");
require(!std::filesystem::exists(nocal::storage::IcsStore::backup_path(path)),
"failed restore created a backup");
require_no_temporary_files(path);
}
void test_lock_failure_preserves_existing_bytes(const std::filesystem::path& root) {
#if !defined(_WIN32)
const auto path = root / "must-survive.ics";
const std::string original = "existing bytes must survive";
write_fixture(path, original);
const auto backup = nocal::storage::IcsStore::backup_path(path);
write_fixture(backup, "existing backup must survive");
std::filesystem::path lock_path = path;
lock_path += ".lock";
std::filesystem::create_directory(lock_path);
bool failed = false;
try {
nocal::storage::IcsStore::save(path, {});
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "save unexpectedly succeeded without acquiring its destination lock");
require(read_fixture(path) == original, "failed save modified the existing calendar");
require(read_fixture(backup) == "existing backup must survive",
"failed save modified the existing backup");
require_no_temporary_files(path);
std::filesystem::remove(lock_path);
#else
(void)root;
#endif
}
void test_rename_failure_cleans_temporary_file(const std::filesystem::path& root) {
#if !defined(_WIN32)
const auto path = root / "destination-is-a-directory.ics";
std::filesystem::create_directory(path);
const auto sentinel = path / "existing-data";
write_fixture(sentinel, "must remain");
bool failed = false;
try {
nocal::storage::IcsStore::save(path, {});
} catch (const std::runtime_error&) {
failed = true;
}
require(failed, "save unexpectedly replaced a non-empty directory");
require(read_fixture(sentinel) == "must remain", "failed replacement damaged existing data");
require_no_temporary_files(path);
#else
(void)root;
#endif
}
void test_unsupported_data_is_not_rewrite_safe(const std::filesystem::path& root) {
const auto path = root / "unsupported.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VEVENT\r\n"
"UID:recurring\r\n"
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
"DTEND;TZID=Europe/London:20260717T111500\r\n"
"SUMMARY;LANGUAGE=en:Recurring appointment\r\n"
"RRULE:FREQ=WEEKLY\r\n"
"ATTENDEE:mailto:person@example.test\r\n"
"BEGIN:VALARM\r\n"
"ACTION:DISPLAY\r\n"
"TRIGGER:-PT10M\r\n"
"END:VALARM\r\n"
"END:VEVENT\r\n"
"X-UNKNOWN-CALENDAR-PROPERTY:keep me\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite,
"unsupported recurrence, alarm, attendee, parameters, or properties were considered safe");
require(!loaded.warnings.empty(), "unsafe calendar did not explain why editing is disabled");
}
void test_time_basis_round_trips_and_dst(const std::filesystem::path& root) {
const auto path = root / "time-bases.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\n"
"VERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:floating\r\nSUMMARY:Floating\r\n"
"DTSTART:20260717T101500\r\nDTEND:20260717T111500\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:utc-basis\r\nSUMMARY:UTC\r\n"
"DTSTART:20260717T101500Z\r\nDTEND:20260717T111500Z\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:london\r\nSUMMARY:London\r\n"
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
"DTEND;TZID=Europe/London:20260717T111500\r\nRRULE:FREQ=DAILY;COUNT=3\r\n"
"EXDATE;TZID=Europe/London:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:dst\r\nSUMMARY:DST transition\r\n"
"DTSTART;TZID=Europe/London:20260329T003000\r\n"
"DTEND;TZID=Europe/London:20260329T023000\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite && loaded.warnings.empty(),
"supported time bases were not rewrite-safe");
require(loaded.events.size() == 4, "time-basis fixture event count differs");
require(loaded.events[0].time_basis == nocal::TimeBasis::floating,
"floating DATE-TIME lost its basis");
require(loaded.events[1].time_basis == nocal::TimeBasis::utc,
"UTC DATE-TIME lost its basis");
require(loaded.events[2].time_basis == nocal::TimeBasis::zoned
&& loaded.events[2].time_zone == "Europe/London",
"TZID DATE-TIME lost its zone");
require(loaded.events[2].start == sys_days{year{2026}/July/17} + 9h + 15min,
"Europe/London summer offset was not applied");
require(loaded.events[2].recurrence_exceptions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 9h + 15min,
sys_days{year{2026}/July/19} + 9h + 15min},
"zoned EXDATE values were not converted with TZID");
require(loaded.events[3].end - loaded.events[3].start == 1h,
"Europe/London DST transition was not converted correctly");
const auto round_trip = root / "time-bases-round-trip.ics";
nocal::storage::IcsStore::save(round_trip, loaded.events);
const std::string serialized = read_fixture(round_trip);
require(serialized.find("DTSTART:20260717T101500\r\n") != std::string::npos,
"floating DTSTART was not serialized without a suffix");
require(serialized.find("DTSTART:20260717T101500Z\r\n") != std::string::npos,
"UTC DTSTART was not serialized with Z");
require(serialized.find("DTSTART;TZID=Europe/London:20260717T101500\r\n")
!= std::string::npos,
"zoned DTSTART was not serialized with TZID and local fields");
require(serialized.find(
"EXDATE;TZID=Europe/London:20260718T101500,20260719T101500")
!= std::string::npos,
"zoned EXDATE values were not grouped with TZID");
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
require(reloaded.safe_to_rewrite && reloaded.events.size() == loaded.events.size(),
"time-basis output did not reload safely");
for (std::size_t index = 0; index < loaded.events.size(); ++index) {
require(reloaded.events[index].start == loaded.events[index].start
&& reloaded.events[index].end == loaded.events[index].end
&& reloaded.events[index].time_basis == loaded.events[index].time_basis
&& reloaded.events[index].time_zone == loaded.events[index].time_zone
&& reloaded.events[index].recurrence == loaded.events[index].recurrence
&& reloaded.events[index].recurrence_additions
== loaded.events[index].recurrence_additions
&& reloaded.events[index].recurrence_exceptions
== loaded.events[index].recurrence_exceptions,
"time-basis semantic round trip differs");
}
}
void test_supported_recurrence_and_exdates(const std::filesystem::path& root) {
const auto path = root / "recurrence.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:daily\r\nDTSTART:20260701T090000Z\r\n"
"DTEND:20260701T100000Z\r\nRRULE:FREQ=DAILY;INTERVAL=2;COUNT=5\r\n"
"EXDATE:20260703T090000Z,20260705T090000Z\r\n"
"EXDATE:20260707T090000Z\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:weekly\r\nDTSTART:20260701T090000\r\n"
"DTEND:20260701T100000\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR;"
"UNTIL=20260801T090000\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:monthly\r\nDTSTART:20260701T090000Z\r\n"
"DTEND:20260701T100000Z\r\nRRULE:FREQ=MONTHLY;BYMONTHDAY=1,-1\r\n"
"END:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:yearly\r\nDTSTART;VALUE=DATE:20260701\r\n"
"DTEND;VALUE=DATE:20260702\r\nRRULE:FREQ=YEARLY;BYMONTH=1,7;"
"UNTIL=20290701\r\nEXDATE;VALUE=DATE:20270701,20280701\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite && loaded.warnings.empty(),
"supported recurrence was not rewrite-safe");
require(loaded.events.size() == 4, "recurrence fixture event count differs");
const auto& daily = *loaded.events[0].recurrence;
require(daily.frequency == nocal::RecurrenceFrequency::daily
&& daily.interval == 2 && daily.count == 5 && !daily.until,
"daily RRULE fields differ");
require(loaded.events[0].recurrence_exceptions.size() == 3,
"multiple/comma-separated EXDATE values were not collected");
const auto& weekly = *loaded.events[1].recurrence;
require(weekly.frequency == nocal::RecurrenceFrequency::weekly
&& weekly.by_weekday == std::vector<weekday>{Monday, Wednesday, Friday}
&& weekly.until.has_value(),
"weekly RRULE fields differ");
const auto& monthly = *loaded.events[2].recurrence;
require(monthly.frequency == nocal::RecurrenceFrequency::monthly
&& monthly.by_month_day == std::vector<int>{1, -1},
"monthly RRULE fields differ");
const auto& yearly = *loaded.events[3].recurrence;
require(yearly.frequency == nocal::RecurrenceFrequency::yearly
&& yearly.by_month == std::vector<unsigned>{1, 7} && yearly.until,
"yearly RRULE fields differ");
require(loaded.events[3].recurrence_exceptions.size() == 2,
"all-day EXDATE values differ");
const auto round_trip = root / "recurrence-round-trip.ics";
nocal::storage::IcsStore::save(round_trip, loaded.events);
const std::string serialized = read_fixture(round_trip);
require(serialized.find("RRULE:FREQ=DAILY;INTERVAL=2;COUNT=5") != std::string::npos,
"daily RRULE was not serialized");
require(serialized.find("EXDATE:20260703T090000Z,20260705T090000Z,20260707T090000Z")
!= std::string::npos,
"timed EXDATE values were not grouped");
require(serialized.find("EXDATE;VALUE=DATE:20270701,20280701") != std::string::npos,
"all-day EXDATE values were not grouped with VALUE=DATE");
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
require(reloaded.safe_to_rewrite && reloaded.warnings.empty(),
"serialized recurrence did not reload safely");
require(reloaded.events.size() == loaded.events.size(),
"recurrence semantic round-trip event count differs");
for (std::size_t index = 0; index < loaded.events.size(); ++index) {
require(reloaded.events[index].recurrence == loaded.events[index].recurrence
&& reloaded.events[index].recurrence_additions
== loaded.events[index].recurrence_additions
&& reloaded.events[index].recurrence_exceptions
== loaded.events[index].recurrence_exceptions,
"recurrence semantic round trip differs");
}
}
void test_supported_rdates(const std::filesystem::path& root) {
const auto path = root / "rdates.ics";
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VEVENT\r\nUID:utc-rdate\r\nSUMMARY:UTC additions\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
"RDATE:20260718T090000Z,20260720T090000Z\r\n"
"RDATE:20260722T090000Z\r\nEXDATE:20260720T090000Z\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:floating-rdate\r\nSUMMARY:Floating additions\r\n"
"DTSTART:20260717T101500\r\nDTEND:20260717T111500\r\n"
"RDATE:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:zoned-rdate\r\nSUMMARY:Zoned additions\r\n"
"DTSTART;TZID=Europe/London:20260717T101500\r\n"
"DTEND;TZID=Europe/London:20260717T111500\r\n"
"RDATE;TZID=Europe/London:20260718T101500,20260719T101500\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:all-day-rdate\r\nSUMMARY:All-day additions\r\n"
"DTSTART;VALUE=DATE:20260717\r\nDTEND;VALUE=DATE:20260718\r\n"
"RDATE;VALUE=DATE:20260719,20260721\r\nEND:VEVENT\r\n"
"BEGIN:VEVENT\r\nUID:mixed-rdate\r\nSUMMARY:Rule and additions\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
"RRULE:FREQ=DAILY;COUNT=3\r\nRDATE:20260725T090000Z,20260727T090000Z\r\n"
"EXDATE:20260718T090000Z,20260725T090000Z\r\nEND:VEVENT\r\n"
"END:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(loaded.safe_to_rewrite && loaded.warnings.empty(),
"supported RDATE values were not rewrite-safe");
require(loaded.events.size() == 5, "RDATE fixture event count differs");
require(!loaded.events[0].recurrence.has_value(),
"RDATE-only event acquired an RRULE");
require(loaded.events[0].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 9h,
sys_days{year{2026}/July/20} + 9h,
sys_days{year{2026}/July/22} + 9h},
"multiple or comma-separated UTC RDATE values were not collected");
require(loaded.events[0].recurrence_exceptions
== std::vector<nocal::TimePoint>{sys_days{year{2026}/July/20} + 9h},
"RDATE-only EXDATE value was not retained");
require(loaded.events[1].time_basis == nocal::TimeBasis::floating
&& loaded.events[1].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 10h + 15min,
sys_days{year{2026}/July/19} + 10h + 15min},
"floating RDATE values did not preserve their time basis");
require(loaded.events[2].time_basis == nocal::TimeBasis::zoned
&& loaded.events[2].time_zone == "Europe/London"
&& loaded.events[2].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 9h + 15min,
sys_days{year{2026}/July/19} + 9h + 15min},
"zoned RDATE values were not converted with DTSTART's TZID");
require(loaded.events[3].all_day
&& loaded.events[3].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/19}, sys_days{year{2026}/July/21}},
"all-day RDATE values were not parsed as DATE values");
require(loaded.events[4].recurrence.has_value()
&& loaded.events[4].recurrence_additions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/25} + 9h,
sys_days{year{2026}/July/27} + 9h}
&& loaded.events[4].recurrence_exceptions
== std::vector<nocal::TimePoint>{
sys_days{year{2026}/July/18} + 9h,
sys_days{year{2026}/July/25} + 9h},
"mixed RRULE, RDATE, and EXDATE values differ");
const auto round_trip = root / "rdates-round-trip.ics";
nocal::storage::IcsStore::save(round_trip, loaded.events);
const std::string serialized = read_fixture(round_trip);
require(serialized.find(
"RDATE:20260718T090000Z,20260720T090000Z,20260722T090000Z")
!= std::string::npos,
"UTC RDATE values were not grouped on serialization");
require(serialized.find("RDATE:20260718T101500,20260719T101500")
!= std::string::npos,
"floating RDATE values were not serialized without a suffix");
require(serialized.find(
"RDATE;TZID=Europe/London:20260718T101500,20260719T101500")
!= std::string::npos,
"zoned RDATE values were not serialized with TZID");
require(serialized.find("RDATE;VALUE=DATE:20260719,20260721")
!= std::string::npos,
"all-day RDATE values were not serialized with VALUE=DATE");
const auto reloaded = nocal::storage::IcsStore::load(round_trip);
require(reloaded.safe_to_rewrite && reloaded.warnings.empty(),
"serialized RDATE calendar did not reload safely");
require(reloaded.events.size() == loaded.events.size(),
"RDATE semantic round-trip event count differs");
for (std::size_t index = 0; index < loaded.events.size(); ++index) {
require(reloaded.events[index].recurrence == loaded.events[index].recurrence
&& reloaded.events[index].recurrence_additions
== loaded.events[index].recurrence_additions
&& reloaded.events[index].recurrence_exceptions
== loaded.events[index].recurrence_exceptions,
"RDATE semantic round trip differs");
}
}
void test_unsupported_recurrence_and_zones_are_unsafe(const std::filesystem::path& root) {
const std::vector<std::string> unsupported_properties{
"RRULE:FREQ=WEEKLY;BYDAY=1MO",
"RRULE:FREQ=DAILY;BYDAY=MO",
"RRULE:FREQ=DAILY;COUNT=2;UNTIL=20260720T090000Z",
"RRULE:FREQ=HOURLY",
"RRULE:FREQ=DAILY;WKST=MO",
"RDATE;VALUE=PERIOD:20260718T090000Z/PT1H",
"RECURRENCE-ID:20260718T090000Z"};
for (std::size_t index = 0; index < unsupported_properties.size(); ++index) {
const auto path = root / ("unsupported-rule-" + std::to_string(index) + ".ics");
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:unsupported\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
+ unsupported_properties[index] + "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite && !loaded.warnings.empty(),
"unsupported recurrence feature was considered rewrite-safe");
require(loaded.events.size() == 1,
"unsupported recurrence prevented base-event browseability");
}
const auto multiple = root / "multiple-rrule.ics";
write_fixture(multiple,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:multiple\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
"RRULE:FREQ=DAILY\r\nRRULE:FREQ=WEEKLY\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
require(!nocal::storage::IcsStore::load(multiple).safe_to_rewrite,
"multiple RRULE properties were considered rewrite-safe");
const auto unknown_zone = root / "unknown-zone.ics";
write_fixture(unknown_zone,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:unknown-zone\r\n"
"DTSTART;TZID=Custom/Mars:20260717T090000\r\n"
"DTEND;TZID=Custom/Mars:20260717T100000\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
const auto unknown = nocal::storage::IcsStore::load(unknown_zone);
require(!unknown.safe_to_rewrite && unknown.events.size() == 1,
"unknown TZID was not read-only and browseable");
const auto custom_zone = root / "vtimezone.ics";
write_fixture(custom_zone,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTIMEZONE\r\nTZID:Custom/Test\r\n"
"END:VTIMEZONE\r\nBEGIN:VEVENT\r\nUID:custom\r\n"
"DTSTART:20260717T090000Z\r\nDTEND:20260717T100000Z\r\n"
"END:VEVENT\r\nEND:VCALENDAR\r\n");
require(!nocal::storage::IcsStore::load(custom_zone).safe_to_rewrite,
"VTIMEZONE definition was considered rewrite-safe");
struct InvalidRdate {
std::string start;
std::string end;
std::string property;
};
const std::vector<InvalidRdate> invalid_rdates{
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
"RDATE:20260718T090000"},
{"DTSTART;TZID=Europe/London:20260717T090000",
"DTEND;TZID=Europe/London:20260717T100000",
"RDATE;TZID=Europe/Paris:20260718T090000"},
{"DTSTART;VALUE=DATE:20260717", "DTEND;VALUE=DATE:20260718",
"RDATE:20260719T090000Z"},
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
"RDATE;TZID=Custom/Mars:20260718T090000"},
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000Z",
"RDATE:not-a-date"}};
for (std::size_t index = 0; index < invalid_rdates.size(); ++index) {
const auto path = root / ("invalid-rdate-" + std::to_string(index) + ".ics");
const auto& fixture = invalid_rdates[index];
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:invalid-rdate\r\n"
+ fixture.start + "\r\n" + fixture.end + "\r\n" + fixture.property
+ "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite && !loaded.warnings.empty(),
"invalid RDATE was considered rewrite-safe");
require(loaded.events.size() == 1 && loaded.events[0].recurrence_additions.empty(),
"invalid RDATE prevented base-event browsing or became an occurrence");
}
const std::vector<std::pair<std::string, std::string>> mismatched_ends{
{"DTSTART:20260717T090000Z", "DTEND:20260717T100000"},
{"DTSTART;TZID=Europe/London:20260717T090000",
"DTEND;TZID=Europe/Paris:20260717T100000"},
{"DTSTART;VALUE=DATE:20260717", "DTEND:20260718T100000Z"}};
for (std::size_t index = 0; index < mismatched_ends.size(); ++index) {
const auto path = root / ("mismatched-time-" + std::to_string(index) + ".ics");
write_fixture(path,
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:mismatch\r\n"
+ mismatched_ends[index].first + "\r\n" + mismatched_ends[index].second
+ "\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n");
const auto loaded = nocal::storage::IcsStore::load(path);
require(!loaded.safe_to_rewrite && loaded.events.empty(),
"mismatched DTSTART/DTEND kinds were accepted");
}
}
void test_save_validation_preserves_existing_bytes(const std::filesystem::path& root) {
const auto path = root / "validation-preserves.ics";
const std::string original = "existing bytes must survive validation";
const std::string original_backup = "existing backup must survive validation";
write_fixture(path, original);
const auto backup = nocal::storage::IcsStore::backup_path(path);
write_fixture(backup, original_backup);
nocal::Event invalid_zone = event_named("zone@example.test", "Invalid zone");
invalid_zone.time_basis = nocal::TimeBasis::zoned;
invalid_zone.time_zone = "Custom/Unknown";
bool zone_failed = false;
try {
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{invalid_zone});
} catch (const std::invalid_argument&) {
zone_failed = true;
}
require(zone_failed && read_fixture(path) == original,
"invalid TZID validation changed existing bytes");
require(read_fixture(backup) == original_backup,
"invalid TZID validation changed existing backup bytes");
nocal::Event invalid_rule = event_named("rule@example.test", "Invalid rule");
nocal::RecurrenceRule rule;
rule.frequency = nocal::RecurrenceFrequency::daily;
rule.by_weekday.push_back(Monday);
invalid_rule.recurrence = rule;
bool rule_failed = false;
try {
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{invalid_rule});
} catch (const std::invalid_argument&) {
rule_failed = true;
}
require(rule_failed && read_fixture(path) == original,
"invalid RRULE validation changed existing bytes");
require(read_fixture(backup) == original_backup,
"invalid RRULE validation changed existing backup bytes");
nocal::Event lone_exception = event_named("exdate@example.test", "Lone exception");
lone_exception.recurrence_exceptions.push_back(lone_exception.start + 24h);
bool exdate_failed = false;
try {
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{lone_exception});
} catch (const std::invalid_argument&) {
exdate_failed = true;
}
require(exdate_failed && read_fixture(path) == original
&& read_fixture(backup) == original_backup,
"EXDATE-without-RRULE-or-RDATE validation changed calendar or backup bytes");
nocal::Event duplicate_rdate = event_named("rdate@example.test", "Duplicate RDATE");
duplicate_rdate.recurrence_additions = {
duplicate_rdate.start + 24h, duplicate_rdate.start + 24h};
bool rdate_failed = false;
try {
nocal::storage::IcsStore::save(path, std::vector<nocal::Event>{duplicate_rdate});
} catch (const std::invalid_argument&) {
rdate_failed = true;
}
require(rdate_failed && read_fixture(path) == original
&& read_fixture(backup) == original_backup,
"duplicate RDATE validation changed calendar or backup bytes");
require_no_temporary_files(path);
}
} // namespace
int main() {
#if !defined(_WIN32)
setenv("TZ", "UTC", 1);
tzset();
#endif
const auto root = std::filesystem::temp_directory_path() / "nocal-ics-tests";
const auto path = root / "calendar.ics";
try {
std::error_code ignored;
std::filesystem::remove_all(root, ignored);
std::filesystem::create_directories(root);
test_round_trip_and_folding(path);
test_local_utc_and_recovery(path);
test_missing_file(path);
test_nested_creation_and_permissions(root);
test_atomic_replacement(root);
test_revisions_reject_external_changes(root);
test_backup_replacement_failure_preserves_calendar(root);
test_backup_rotation_permissions_and_restore(root);
test_restore_requires_backup(root);
test_lock_failure_preserves_existing_bytes(root);
test_rename_failure_cleans_temporary_file(root);
test_unsupported_data_is_not_rewrite_safe(root);
test_time_basis_round_trips_and_dst(root);
test_supported_recurrence_and_exdates(root);
test_supported_rdates(root);
test_unsupported_recurrence_and_zones_are_unsafe(root);
test_save_validation_preserves_existing_bytes(root);
std::filesystem::remove_all(root, ignored);
std::cout << "ics_tests: ok\n";
return 0;
} catch (const std::exception& error) {
std::error_code ignored;
std::filesystem::remove_all(root, ignored);
std::cerr << "ics_tests: " << error.what() << '\n';
return 1;
}
}