feat: add safe calendar import and export

Add explicit noninteractive CLI transfer modes with full event validation, idempotent duplicate handling, collision refusal, atomic import backups, and no-overwrite export creation.

Cover pure merge semantics and end-to-end rollback paths for unsupported content, invalid identities, conflicts, option combinations, and existing destinations.
This commit is contained in:
2026-07-18 09:32:54 +01:00
parent 8b6cf2e877
commit b524e6e33d
11 changed files with 867 additions and 8 deletions

View File

@@ -0,0 +1,279 @@
#include "nocal/domain/calendar_transfer.hpp"
#include <chrono>
#include <cstdlib>
#include <functional>
#include <iostream>
#include <optional>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
namespace {
int failures = 0;
void check(const bool condition, const std::string_view message)
{
if (!condition) {
++failures;
std::cerr << "FAIL: " << message << '\n';
}
}
template <class Function>
void check_invalid_argument(Function&& function, const std::string_view message)
{
try {
std::forward<Function>(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<nocal::Event>& left,
const std::vector<nocal::Event>& 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<std::string_view, std::function<void(nocal::Event&)>>;
const std::vector<Mutation> 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<nocal::Event>{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<nocal::Event>{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;
}