blob: 7fa83b96e49edd3acc9b9ed77d0a942817fa6bea [file]
"""Reading events out of an uploaded iCalendar file."""
from __future__ import annotations
import pytest
from asfcalendar.icsimport import (
MAX_IMPORT_BYTES,
UNTITLED,
ImportCandidate,
decode,
parse,
)
from asfcalendar.models import (
MAX_DESCRIPTION_LENGTH,
MAX_TITLE_LENGTH,
ValidationError,
parse_event_payload,
to_iso,
)
def calendar(*events: str) -> str:
body = "\r\n".join(events)
return f"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n{body}\r\nEND:VCALENDAR\r\n"
def event(**properties: str) -> str:
lines = ["BEGIN:VEVENT"]
lines.extend(f"{name.replace('_', '-')}:{value}" for name, value in properties.items())
lines.append("END:VEVENT")
return "\r\n".join(lines)
TIMED = event(UID="1@test", SUMMARY="Community call", DTSTART="20260710T090000Z", DTEND="20260710T100000Z")
def only(document: str) -> ImportCandidate:
result = parse(document)
assert len(result.candidates) == 1
return result.candidates[0]
class TestDecoding:
def test_plain_utf8(self) -> None:
assert decode(b"BEGIN:VCALENDAR") == "BEGIN:VCALENDAR"
def test_a_byte_order_mark_is_stripped(self) -> None:
assert decode("BEGIN".encode("utf-8-sig")) == "BEGIN"
assert decode("\ufeffBEGIN") == "BEGIN"
def test_latin1_survives(self) -> None:
# Not spec-compliant, but exporters do it.
assert "\u00c5sa" in decode("SUMMARY:\u00c5sa".encode("latin-1"))
def test_an_oversized_upload_is_refused(self) -> None:
with pytest.raises(ValidationError, match="import limit"):
decode(b"x" * (MAX_IMPORT_BYTES + 1))
class TestRejectingRubbish:
def test_something_that_is_not_a_calendar(self) -> None:
with pytest.raises(ValidationError, match="iCalendar"):
parse("hello, I am a text file")
def test_a_calendar_with_no_events(self) -> None:
with pytest.raises(ValidationError, match="No events"):
parse(calendar())
def test_a_calendar_of_nothing_but_unusable_entries(self) -> None:
with pytest.raises(ValidationError, match="No events"):
parse(calendar(event(UID="1@t", SUMMARY="No start time at all")))
def test_more_events_than_the_limit(self) -> None:
many = calendar(
*[event(UID=f"{index}@t", SUMMARY=f"Event {index}", DTSTART="20260710T090000Z") for index in range(6)]
)
with pytest.raises(ValidationError, match="more than 5 events"):
parse(many, max_events=5)
def test_the_limit_itself_is_allowed(self) -> None:
many = calendar(
*[event(UID=f"{index}@t", SUMMARY=f"Event {index}", DTSTART="20260710T090000Z") for index in range(5)]
)
assert len(parse(many, max_events=5).candidates) == 5
class TestTimes:
def test_a_zulu_timestamp(self) -> None:
candidate = only(calendar(TIMED))
assert to_iso(candidate.start) == "2026-07-10T09:00:00Z"
assert to_iso(candidate.end) == "2026-07-10T10:00:00Z"
assert candidate.all_day is False
assert candidate.timezone == "UTC"
def test_a_zone_is_kept_as_the_organisers_timezone(self) -> None:
document = calendar(
"BEGIN:VEVENT\r\nUID:1@t\r\nSUMMARY:Berlin\r\n"
"DTSTART;TZID=Europe/Berlin:20260710T150000\r\n"
"DTEND;TZID=Europe/Berlin:20260710T160000\r\nEND:VEVENT"
)
candidate = only(document)
assert candidate.timezone == "Europe/Berlin"
# 15:00 in Berlin in July is 13:00 UTC.
assert to_iso(candidate.start) == "2026-07-10T13:00:00Z"
def test_an_all_day_event(self) -> None:
document = calendar(
"BEGIN:VEVENT\r\nUID:1@t\r\nSUMMARY:ApacheCon\r\n"
"DTSTART;VALUE=DATE:20260710\r\nDTEND;VALUE=DATE:20260713\r\nEND:VEVENT"
)
candidate = only(document)
assert candidate.all_day is True
assert to_iso(candidate.start) == "2026-07-10T00:00:00Z"
assert to_iso(candidate.end) == "2026-07-13T00:00:00Z"
assert candidate.timezone == "UTC"
def test_a_duration_instead_of_an_end(self) -> None:
document = calendar(event(UID="1@t", SUMMARY="Sync", DTSTART="20260710T090000Z", DURATION="PT90M"))
assert only(document).end - only(document).start == 5400
def test_no_end_at_all_gets_an_hour(self) -> None:
document = calendar(event(UID="1@t", SUMMARY="Sync", DTSTART="20260710T090000Z"))
assert only(document).end - only(document).start == 3600
def test_an_all_day_event_with_no_end_gets_a_day(self) -> None:
document = calendar("BEGIN:VEVENT\r\nUID:1@t\r\nSUMMARY:x\r\nDTSTART;VALUE=DATE:20260710\r\nEND:VEVENT")
assert only(document).end - only(document).start == 86400
def test_a_floating_time_is_read_as_utc_and_says_so(self) -> None:
document = calendar(event(UID="1@t", SUMMARY="Floating", DTSTART="20260710T090000"))
candidate = only(document)
assert to_iso(candidate.start) == "2026-07-10T09:00:00Z"
assert any("no timezone" in warning for warning in candidate.warnings)
def test_an_end_before_the_start_is_repaired(self) -> None:
document = calendar(event(UID="1@t", SUMMARY="Backwards", DTSTART="20260710T100000Z", DTEND="20260710T090000Z"))
candidate = only(document)
assert candidate.end > candidate.start
assert any("ended before it started" in warning for warning in candidate.warnings)
class TestFields:
def test_the_ordinary_ones(self) -> None:
document = calendar(
event(
UID="1@t",
SUMMARY="Community call",
DTSTART="20260710T090000Z",
LOCATION="Room 3",
DESCRIPTION="Agenda attached",
URL="https://apache.org",
)
)
candidate = only(document)
assert candidate.title == "Community call"
assert candidate.location == "Room 3"
assert candidate.description == "Agenda attached"
assert candidate.url == "https://apache.org"
assert candidate.uid == "1@t"
def test_escaped_text_is_unescaped(self) -> None:
document = calendar(
"BEGIN:VEVENT\r\nUID:1@t\r\nSUMMARY:Tea\\, biscuits\r\n"
"DESCRIPTION:Line one\\nLine two\r\nDTSTART:20260710T090000Z\r\nEND:VEVENT"
)
candidate = only(document)
assert candidate.title == "Tea, biscuits"
assert candidate.description == "Line one\nLine two"
def test_folded_lines_are_joined(self) -> None:
document = calendar(
"BEGIN:VEVENT\r\nUID:1@t\r\nSUMMARY:A title that was too long for one\r\n"
" line in the file\r\nDTSTART:20260710T090000Z\r\nEND:VEVENT"
)
assert only(document).title == "A title that was too long for one line in the file"
def test_a_missing_title_gets_a_placeholder(self) -> None:
document = calendar(event(UID="1@t", DTSTART="20260710T090000Z"))
candidate = only(document)
assert candidate.title == UNTITLED
assert any("no title" in warning for warning in candidate.warnings)
def test_an_overlong_title_is_trimmed_rather_than_rejected(self) -> None:
document = calendar(event(UID="1@t", SUMMARY="x" * 500, DTSTART="20260710T090000Z"))
candidate = only(document)
assert len(candidate.title) <= MAX_TITLE_LENGTH
assert any("cut short" in warning for warning in candidate.warnings)
def test_an_overlong_description_is_trimmed_too(self) -> None:
document = calendar(event(UID="1@t", SUMMARY="x", DTSTART="20260710T090000Z", DESCRIPTION="y" * 30000))
assert len(only(document).description) <= MAX_DESCRIPTION_LENGTH
def test_a_link_that_is_not_http_is_dropped(self) -> None:
document = calendar(event(UID="1@t", SUMMARY="x", DTSTART="20260710T090000Z", URL="mailto:nobody@apache.org"))
candidate = only(document)
assert candidate.url == ""
assert any("not http" in warning for warning in candidate.warnings)
class TestThingsThisCalendarCannotDo:
def test_a_repeating_event_comes_in_once_and_says_so(self) -> None:
document = calendar(event(UID="1@t", SUMMARY="Weekly", DTSTART="20260710T090000Z", RRULE="FREQ=WEEKLY;COUNT=5"))
result = parse(document)
assert len(result.candidates) == 1
assert any("repeats" in warning for warning in result.candidates[0].warnings)
def test_a_modified_occurrence_keeps_the_uid_of_the_event_it_belongs_to(self) -> None:
"""UIDs are not unique, so nothing downstream may treat one as an identifier.
This is how every exporter writes a repeating event with an exception:
the series, then one VEVENT per moved occurrence, all under one UID. The
import preview keyed its list on the UID and fell over on a real file.
"""
document = calendar(
event(UID="1@t", SUMMARY="Weekly", DTSTART="20260710T090000Z", RRULE="FREQ=WEEKLY;COUNT=5"),
event(UID="1@t", SUMMARY="Weekly (moved)", DTSTART="20260717T110000Z", RECURRENCE_ID="20260717T090000Z"),
)
candidates = parse(document).candidates
assert [candidate.title for candidate in candidates] == ["Weekly", "Weekly (moved)"]
assert {candidate.uid for candidate in candidates} == {"1@t"}
def test_an_event_with_no_uid_still_imports(self) -> None:
assert only(calendar(event(SUMMARY="Anonymous", DTSTART="20260710T090000Z"))).uid is None
def test_cancelled_events_are_left_out(self) -> None:
document = calendar(
TIMED,
event(UID="2@t", SUMMARY="Called off", DTSTART="20260710T090000Z", STATUS="CANCELLED"),
)
result = parse(document)
assert [candidate.title for candidate in result.candidates] == ["Community call"]
assert any("Skipped 1 entry" in warning for warning in result.warnings)
def test_entries_with_no_start_are_left_out(self) -> None:
result = parse(calendar(TIMED, event(UID="2@t", SUMMARY="No start")))
assert len(result.candidates) == 1
assert any("Skipped 1 entry" in warning for warning in result.warnings)
def test_several_skipped_entries_are_counted_together(self) -> None:
result = parse(calendar(TIMED, event(UID="2@t", SUMMARY="a"), event(UID="3@t", SUMMARY="b")))
assert any("Skipped 2 entries" in warning for warning in result.warnings)
def test_a_todo_or_journal_is_not_an_event(self) -> None:
document = (
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\n"
"BEGIN:VTODO\r\nUID:1@t\r\nSUMMARY:Not an event\r\nEND:VTODO\r\n" + TIMED + "\r\nEND:VCALENDAR\r\n"
)
assert [candidate.title for candidate in parse(document).candidates] == ["Community call"]
class TestHandingOverToTheNormalValidation:
"""A candidate is only useful if the ordinary event validation accepts it."""
def test_a_candidate_becomes_a_valid_payload(self) -> None:
candidate = only(calendar(TIMED))
payload = parse_event_payload(candidate.to_payload("project", "public", "httpd"))
assert payload.title == "Community call"
assert payload.category == "project"
assert payload.project == "httpd"
assert payload.visibility == "public"
def test_every_candidate_from_an_awkward_file_still_validates(self) -> None:
document = calendar(
TIMED,
event(UID="2@t", SUMMARY="x" * 500, DTSTART="20260710T090000Z"),
event(UID="3@t", DTSTART="20260710T090000Z", URL="mailto:x@y"),
"BEGIN:VEVENT\r\nUID:4@t\r\nSUMMARY:All day\r\nDTSTART;VALUE=DATE:20260710\r\nEND:VEVENT",
event(UID="5@t", SUMMARY="Backwards", DTSTART="20260710T100000Z", DTEND="20260710T090000Z"),
)
for candidate in parse(document).candidates:
parse_event_payload(candidate.to_payload("foundation", "private", None))
def test_the_timezone_survives_into_the_payload(self) -> None:
document = calendar(
"BEGIN:VEVENT\r\nUID:1@t\r\nSUMMARY:Berlin\r\nDTSTART;TZID=Europe/Berlin:20260710T150000\r\nEND:VEVENT"
)
payload = parse_event_payload(only(document).to_payload("foundation", "public", None))
assert payload.timezone == "Europe/Berlin"
# The instant is unchanged by the round trip.
assert to_iso(payload.start) == "2026-07-10T13:00:00Z"
def test_the_preview_shape_is_what_the_ui_expects(self) -> None:
as_json = only(calendar(TIMED)).to_json()
assert set(as_json) == {
"title",
"start",
"end",
"all_day",
"description",
"location",
"url",
"timezone",
"uid",
"warnings",
}