706 lines
33 KiB
C++
706 lines
33 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_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_exceptions
|
|
== loaded.events[index].recurrence_exceptions,
|
|
"recurrence 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:20260718T090000Z",
|
|
"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");
|
|
|
|
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 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_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;
|
|
}
|
|
}
|