Files
nocal/tests/cli_transfer_test.sh
Bernardo Magri b524e6e33d 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.
2026-07-18 09:32:54 +01:00

281 lines
9.8 KiB
Bash

#!/bin/sh
set -eu
if [ "$#" -ne 1 ]; then
echo "usage: cli_transfer_test.sh /path/to/nocal" >&2
exit 2
fi
nocal=$1
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/nocal-cli-transfer.XXXXXX")
trap 'rm -rf "$tmpdir"' EXIT HUP INT TERM
fail()
{
echo "$1" >&2
exit 1
}
expect_failure()
{
description=$1
shift
if "$@" >"$tmpdir/out" 2>"$tmpdir/err"; then
fail "$description unexpectedly succeeded"
fi
}
write_target()
{
path=$1
cat >"$path" <<'EOF'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nocal transfer CLI test//EN
BEGIN:VEVENT
UID:existing@nocal.test
DTSTART:20260720T090000
DTEND:20260720T100000
SUMMARY:Existing appointment
LOCATION:Desk
END:VEVENT
END:VCALENDAR
EOF
}
source_calendar=$tmpdir/source.ics
cat >"$source_calendar" <<'EOF'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nocal transfer CLI test//EN
BEGIN:VEVENT
UID:existing@nocal.test
DTSTART:20260720T090000
DTEND:20260720T100000
SUMMARY:Existing appointment
LOCATION:Desk
END:VEVENT
BEGIN:VEVENT
UID:imported@nocal.test
DTSTART:20260721T140000Z
DTEND:20260721T150000Z
SUMMARY:Imported appointment
DESCRIPTION:Transferred deterministically
X-NOCAL-CALENDAR-ID:work
END:VEVENT
END:VCALENDAR
EOF
# Import merges new appointments, skips exact duplicates, and preserves the
# exact pre-import bytes as the adjacent backup.
target=$tmpdir/target.ics
write_target "$target"
cp "$target" "$tmpdir/target-before.ics"
import_summary=$(
"$nocal" --import "$source_calendar" --calendar "$target"
)
printf '%s\n' "$import_summary" | grep -E 'Imported[[:space:]]+1([^0-9]|$)' >/dev/null ||
fail "import summary does not report one imported appointment"
printf '%s\n' "$import_summary" | grep -E '1[[:space:]]+duplicate' >/dev/null ||
fail "import summary does not report one skipped duplicate"
cmp "$target.bak" "$tmpdir/target-before.ics" >/dev/null ||
fail "successful import backup does not contain the exact prior target bytes"
grep -F 'UID:existing@nocal.test' "$target" >/dev/null
grep -F 'UID:imported@nocal.test' "$target" >/dev/null
# A source containing only an exact duplicate must not rewrite the target or
# create a backup.
duplicate_source=$tmpdir/duplicate-source.ics
write_target "$duplicate_source"
duplicate_target=$tmpdir/duplicate-target.ics
write_target "$duplicate_target"
cp "$duplicate_target" "$tmpdir/duplicate-target-before.ics"
duplicate_summary=$(
"$nocal" --import "$duplicate_source" --calendar "$duplicate_target"
)
printf '%s\n' "$duplicate_summary" | grep -E 'Imported[[:space:]]+0([^0-9]|$)' >/dev/null ||
fail "no-op import summary does not report zero imported appointments"
printf '%s\n' "$duplicate_summary" | grep -E '1[[:space:]]+duplicate' >/dev/null ||
fail "no-op import summary does not report one skipped duplicate"
cmp "$duplicate_target" "$tmpdir/duplicate-target-before.ics" >/dev/null ||
fail "exact-duplicate import rewrote the target"
[ ! -e "$duplicate_target.bak" ] ||
fail "exact-duplicate import created a backup despite performing no write"
# A differing appointment with the same UID is a conflict. No target or
# backup may be produced by the failed mutation.
conflict_source=$tmpdir/conflict-source.ics
cat >"$conflict_source" <<'EOF'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nocal transfer CLI test//EN
BEGIN:VEVENT
UID:existing@nocal.test
DTSTART:20260720T090000
DTEND:20260720T100000
SUMMARY:Conflicting appointment
LOCATION:Desk
END:VEVENT
END:VCALENDAR
EOF
conflict_target=$tmpdir/conflict-target.ics
write_target "$conflict_target"
cp "$conflict_target" "$tmpdir/conflict-target-before.ics"
expect_failure "conflicting-UID import" \
"$nocal" --import "$conflict_source" --calendar "$conflict_target"
cmp "$conflict_target" "$tmpdir/conflict-target-before.ics" >/dev/null ||
fail "conflicting-UID import changed the target"
[ ! -e "$conflict_target.bak" ] ||
fail "conflicting-UID import created a backup"
missing_uid=$tmpdir/missing-uid.ics
cat >"$missing_uid" <<'EOF'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nocal transfer CLI test//EN
BEGIN:VEVENT
DTSTART:20260722T110000
DTEND:20260722T120000
SUMMARY:Missing identity
END:VEVENT
END:VCALENDAR
EOF
duplicate_uid=$tmpdir/duplicate-uid.ics
cat >"$duplicate_uid" <<'EOF'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nocal transfer CLI test//EN
BEGIN:VEVENT
UID:duplicate@nocal.test
DTSTART:20260722T110000
DTEND:20260722T120000
SUMMARY:First duplicate identity
END:VEVENT
BEGIN:VEVENT
UID:duplicate@nocal.test
DTSTART:20260723T110000
DTEND:20260723T120000
SUMMARY:Second duplicate identity
END:VEVENT
END:VCALENDAR
EOF
unsupported=$tmpdir/unsupported.ics
cat >"$unsupported" <<'EOF'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Nocal transfer CLI test//EN
BEGIN:VEVENT
UID:unsupported@nocal.test
DTSTART:20260722T110000
DTEND:20260722T120000
SUMMARY:Unsupported appointment
ATTENDEE:mailto:person@example.test
END:VEVENT
END:VCALENDAR
EOF
# Unsafe import sources are rejected before the target persistence boundary.
for unsafe_source in "$missing_uid" "$duplicate_uid" "$unsupported"; do
unsafe_name=$(basename "$unsafe_source" .ics)
unsafe_target=$tmpdir/import-$unsafe_name-target.ics
write_target "$unsafe_target"
cp "$unsafe_target" "$tmpdir/import-$unsafe_name-before.ics"
expect_failure "$unsafe_name import" \
"$nocal" --import "$unsafe_source" --calendar "$unsafe_target"
cmp "$unsafe_target" "$tmpdir/import-$unsafe_name-before.ics" >/dev/null ||
fail "$unsafe_name import changed the target"
[ ! -e "$unsafe_target.bak" ] ||
fail "$unsafe_name import created a backup"
done
# Identity validation applies equally to the target and leaves its exact bytes
# untouched when it fails.
duplicate_uid_target=$tmpdir/duplicate-uid-target.ics
cp "$duplicate_uid" "$duplicate_uid_target"
cp "$duplicate_uid_target" "$tmpdir/duplicate-uid-target-before.ics"
expect_failure "duplicate-UID target import" \
"$nocal" --import "$source_calendar" --calendar "$duplicate_uid_target"
cmp "$duplicate_uid_target" "$tmpdir/duplicate-uid-target-before.ics" >/dev/null ||
fail "import changed a duplicate-UID target"
[ ! -e "$duplicate_uid_target.bak" ] ||
fail "import into a duplicate-UID target created a backup"
# An unsafe target is read-only even when the source is valid.
read_only_target=$tmpdir/read-only-target.ics
cp "$unsupported" "$read_only_target"
cp "$read_only_target" "$tmpdir/read-only-target-before.ics"
expect_failure "read-only target import" \
"$nocal" --import "$source_calendar" --calendar "$read_only_target"
cmp "$read_only_target" "$tmpdir/read-only-target-before.ics" >/dev/null ||
fail "import changed a read-only target"
[ ! -e "$read_only_target.bak" ] ||
fail "import into a read-only target created a backup"
# Export creates a new parseable calendar. Re-exporting that calendar yields
# identical canonical bytes, demonstrating equivalent parsed appointments.
exported=$tmpdir/exported.ics
export_summary=$(
"$nocal" --export "$exported" --calendar "$source_calendar"
)
printf '%s\n' "$export_summary" | grep -E 'Exported[[:space:]]+2([^0-9]|$)' >/dev/null ||
fail "export summary does not report two exported appointments"
[ -f "$exported" ] || fail "successful export did not create its destination"
[ ! -e "$exported.bak" ] || fail "successful new export unexpectedly created a backup"
canonical_copy=$tmpdir/exported-again.ics
"$nocal" --export "$canonical_copy" --calendar "$exported" >"$tmpdir/out"
cmp "$exported" "$canonical_copy" >/dev/null ||
fail "exported calendar did not parse and round-trip equivalently"
# Existing export destinations are never overwritten or backed up.
existing_destination=$tmpdir/existing-destination.ics
printf '%s\n' 'sentinel destination bytes' >"$existing_destination"
cp "$existing_destination" "$tmpdir/existing-destination-before"
expect_failure "export to existing destination" \
"$nocal" --export "$existing_destination" --calendar "$source_calendar"
cmp "$existing_destination" "$tmpdir/existing-destination-before" >/dev/null ||
fail "failed export changed its existing destination"
[ ! -e "$existing_destination.bak" ] ||
fail "failed export created a backup for its existing destination"
# Unsafe export sources fail without creating any destination or backup.
for unsafe_source in "$missing_uid" "$duplicate_uid" "$unsupported"; do
unsafe_name=$(basename "$unsafe_source" .ics)
unsafe_destination=$tmpdir/export-$unsafe_name-output.ics
expect_failure "$unsafe_name export" \
"$nocal" --export "$unsafe_destination" --calendar "$unsafe_source"
[ ! -e "$unsafe_destination" ] ||
fail "$unsafe_name export created an output file"
[ ! -e "$unsafe_destination.bak" ] ||
fail "$unsafe_name export created an output backup"
done
# Transfer actions are exclusive and cannot be combined with interactive,
# print, demo, or recovery actions. These parse failures must occur without a
# destination write or target backup.
option_target=$tmpdir/option-target.ics
write_target "$option_target"
cp "$option_target" "$tmpdir/option-target-before.ics"
option_export=$tmpdir/option-export.ics
expect_failure "combined import and export" \
"$nocal" --import "$source_calendar" --export "$option_export" \
--calendar "$option_target"
for conflicting_option in --demo --print --restore-backup; do
expect_failure "import with $conflicting_option" \
"$nocal" --import "$source_calendar" "$conflicting_option" \
--calendar "$option_target"
expect_failure "export with $conflicting_option" \
"$nocal" --export "$option_export" "$conflicting_option" \
--calendar "$option_target"
done
cmp "$option_target" "$tmpdir/option-target-before.ics" >/dev/null ||
fail "invalid transfer option combination changed the calendar"
[ ! -e "$option_target.bak" ] ||
fail "invalid transfer option combination created a calendar backup"
[ ! -e "$option_export" ] ||
fail "invalid transfer option combination created an export destination"