#include "nocal/domain/calendar_transfer.hpp" #include #include #include #include #include #include #include #include #include #include namespace { int failures = 0; void check(const bool condition, const std::string_view message) { if (!condition) { ++failures; std::cerr << "FAIL: " << message << '\n'; } } template void check_invalid_argument(Function&& function, const std::string_view message) { try { std::forward(function)(); check(false, message); } catch (const std::invalid_argument&) { } catch (...) { check(false, message); } } [[nodiscard]] nocal::TimePoint at(const int hour, const int minute = 0) { using namespace std::chrono; return nocal::TimePoint{hours{24 * 20'000 + hour} + minutes{minute}}; } [[nodiscard]] nocal::Event complete_event(std::string uid) { using namespace std::chrono; nocal::Event event; event.uid = std::move(uid); event.title = "Planning"; event.start = at(9); event.end = at(10, 30); event.all_day = false; event.location = "Room 3"; event.description = "Discuss launch"; event.calendar_id = "work"; event.color = "blue"; event.time_basis = nocal::TimeBasis::zoned; event.time_zone = "Europe/London"; nocal::RecurrenceRule recurrence; recurrence.frequency = nocal::RecurrenceFrequency::weekly; recurrence.interval = 2; recurrence.count = 6; recurrence.until = at(24); recurrence.by_weekday = {Monday, Wednesday}; recurrence.by_month_day = {1, -1}; recurrence.by_month = {1, 7}; event.recurrence = std::move(recurrence); event.recurrence_exceptions = {at(16), at(18)}; return event; } [[nodiscard]] bool equal(const nocal::Event& left, const nocal::Event& right) { return left.uid == right.uid && left.title == right.title && left.start == right.start && left.end == right.end && left.all_day == right.all_day && left.location == right.location && left.description == right.description && left.calendar_id == right.calendar_id && left.color == right.color && left.time_basis == right.time_basis && left.time_zone == right.time_zone && left.recurrence == right.recurrence && left.recurrence_exceptions == right.recurrence_exceptions; } [[nodiscard]] bool equal(const std::vector& left, const std::vector& right) { if (left.size() != right.size()) return false; for (std::size_t index = 0; index < left.size(); ++index) { if (!equal(left[index], right[index])) return false; } return true; } void test_export_validation() { nocal::validate_events_for_export({}); const std::vector valid{complete_event("one"), complete_event("two")}; nocal::validate_events_for_export(valid); auto missing_uid = valid; missing_uid[0].uid.clear(); check_invalid_argument( [&] { nocal::validate_events_for_export(missing_uid); }, "export rejects an empty UID"); auto duplicate_uid = valid; duplicate_uid[1].uid = duplicate_uid[0].uid; check_invalid_argument( [&] { nocal::validate_events_for_export(duplicate_uid); }, "export rejects duplicate UIDs even when event fields differ"); auto backwards = valid; backwards[0].end = backwards[0].start - std::chrono::minutes{1}; check_invalid_argument([&] { nocal::validate_events_for_export(backwards); }, "export rejects a backwards interval"); auto zero_duration = complete_event("instant"); zero_duration.end = zero_duration.start; nocal::validate_events_for_export(std::vector{zero_duration}); } void test_merge_success_order_and_counts() { auto first = complete_event("target-one"); auto second = complete_event("target-two"); second.title = "Second target"; auto duplicate = second; auto third = complete_event("source-one"); third.title = "First import"; auto fourth = complete_event("source-two"); fourth.title = "Second import"; const std::vector target{first, second}; const std::vector source{third, duplicate, fourth}; const auto result = nocal::merge_events_for_import(target, source); check(result.events.size() == 4, "merge returns target and new source events"); check(result.imported == 2, "merge reports the number imported"); check(result.duplicates_skipped == 1, "merge reports exact duplicates skipped"); check(result.events.size() == 4 && result.events[0].uid == "target-one" && result.events[1].uid == "target-two" && result.events[2].uid == "source-one" && result.events[3].uid == "source-two", "merge preserves target order and appends source order"); check(equal(result.events[1], duplicate), "an all-field exact duplicate is skipped without replacing target"); } void test_every_event_field_participates_in_collision_equality() { using Mutation = std::pair>; const std::vector mutations{ {"title", [](auto& event) { event.title += " changed"; }}, {"start", [](auto& event) { event.start += std::chrono::minutes{1}; }}, {"end", [](auto& event) { event.end += std::chrono::minutes{1}; }}, {"all-day", [](auto& event) { event.all_day = !event.all_day; }}, {"location", [](auto& event) { event.location += " changed"; }}, {"description", [](auto& event) { event.description += " changed"; }}, {"calendar ID", [](auto& event) { event.calendar_id += "-other"; }}, {"color", [](auto& event) { event.color += "-other"; }}, {"time basis", [](auto& event) { event.time_basis = nocal::TimeBasis::utc; }}, {"time zone", [](auto& event) { event.time_zone = "America/New_York"; }}, {"recurrence", [](auto& event) { event.recurrence->interval += 1; }}, {"recurrence exclusions", [](auto& event) { event.recurrence_exceptions.push_back(at(19)); }}, }; const std::vector target{complete_event("same-uid")}; for (const auto& [field, mutate] : mutations) { auto collision = target[0]; mutate(collision); check_invalid_argument( [&] { (void)nocal::merge_events_for_import( target, std::vector{collision}); }, std::string{"UID collision rejects a differing "} + std::string{field}); } auto removed_recurrence = target[0]; removed_recurrence.recurrence.reset(); check_invalid_argument( [&] { (void)nocal::merge_events_for_import( target, std::vector{removed_recurrence}); }, "UID collision rejects recurrence presence mismatch"); } void test_merge_validates_both_collections_before_result() { const auto target_event = complete_event("target"); const auto source_event = complete_event("source"); auto empty_target_uid = std::vector{target_event}; empty_target_uid[0].uid.clear(); check_invalid_argument( [&] { (void)nocal::merge_events_for_import(empty_target_uid, {}); }, "merge rejects an empty target UID"); const std::vector duplicate_target{target_event, target_event}; check_invalid_argument( [&] { (void)nocal::merge_events_for_import(duplicate_target, {}); }, "merge rejects duplicate target UIDs"); auto empty_source_uid = std::vector{source_event}; empty_source_uid[0].uid.clear(); check_invalid_argument( [&] { (void)nocal::merge_events_for_import({}, empty_source_uid); }, "merge rejects an empty source UID"); const std::vector duplicate_source{source_event, source_event}; check_invalid_argument( [&] { (void)nocal::merge_events_for_import({}, duplicate_source); }, "merge rejects duplicate source UIDs"); auto invalid_target = std::vector{target_event}; invalid_target[0].end = invalid_target[0].start - std::chrono::seconds{1}; check_invalid_argument( [&] { (void)nocal::merge_events_for_import(invalid_target, {}); }, "merge rejects a backwards target interval"); auto invalid_source = std::vector{source_event}; invalid_source[0].end = invalid_source[0].start - std::chrono::seconds{1}; check_invalid_argument( [&] { (void)nocal::merge_events_for_import({}, invalid_source); }, "merge rejects a backwards source interval"); } void test_inputs_are_not_mutated_on_failure() { auto target_event = complete_event("collision"); auto source_event = target_event; source_event.description = "Different data"; source_event.recurrence_exceptions.push_back(at(21)); std::vector target{target_event}; std::vector source{source_event}; const auto target_before = target; const auto source_before = source; check_invalid_argument( [&] { (void)nocal::merge_events_for_import(target, source); }, "a differing collision fails the whole merge"); check(equal(target, target_before), "failed merge does not mutate target input"); check(equal(source, source_before), "failed merge does not mutate source input"); source.push_back(complete_event("new-before-invalid")); auto invalid = complete_event("invalid-after-new"); invalid.end = invalid.start - std::chrono::hours{1}; source.push_back(invalid); const auto source_with_invalid_before = source; check_invalid_argument( [&] { (void)nocal::merge_events_for_import(target, source); }, "source validation fails before exposing an earlier import"); check(equal(source, source_with_invalid_before), "validation failure after a valid source item leaves source unchanged"); } } // namespace int main() { test_export_validation(); test_merge_success_order_and_counts(); test_every_event_field_participates_in_collision_equality(); test_merge_validates_both_collections_before_result(); test_inputs_are_not_mutated_on_failure(); if (failures != 0) { std::cerr << failures << " calendar transfer test(s) failed\n"; return EXIT_FAILURE; } std::cout << "calendar transfer tests passed\n"; return EXIT_SUCCESS; }