test: unit tests for CalDAV protocol logic

This commit is contained in:
Jannis committed 2026-10-10 22:05:30 +02:00
1 parent 88a6375d96
commit 8bd463ed55
1 file changed
+479
@@ -0,0 +1,479 @@
using System.Text;
using System.Xml.Linq;
using FamilyPlanner.Api.Features.Calendar.Ics;
using FamilyPlanner.Api.Features.CalDav;
using FamilyPlanner.Domain.Calendar;
using FamilyPlanner.Domain.Common;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace FamilyPlanner.UnitTests.CalDav;
public sealed class CalDavProtocolTests
{
private static readonly Guid CalendarId = Guid.NewGuid();
private static readonly DateTimeOffset T0 = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero);
private static CalendarEvent Row(string uid, DateTimeOffset updated, Guid? master = null, bool deleted = false, byte version = 1, DateTimeOffset? recurrenceId = null) => new()
{
Id = Guid.NewGuid(),
CalendarId = CalendarId,
Uid = uid,
Title = uid,
SeriesMasterId = master,
RecurrenceId = recurrenceId,
UpdatedAt = updated,
IsDeleted = deleted,
RowVersion = [version],
StartUtc = T0,
EndUtc = T0.AddHours(1),
};
// ---------- Adressen ----------
[Theory]
[InlineData("/caldav", DavKind.Root)]
[InlineData("/caldav/", DavKind.Root)]
[InlineData("/caldav/principals/", DavKind.PrincipalsRoot)]
[InlineData("/caldav/calendars/", DavKind.Home)]
[InlineData("/caldav/calendars", DavKind.Home)]
[InlineData("/caldav/unbekannt/x", DavKind.Unknown)]
[InlineData("/caldav/calendars/kein-guid/", DavKind.Unknown)]
[InlineData("/ics/abc", DavKind.Unknown)]
[InlineData("", DavKind.Unknown)]
public void Parse_recognizes_collections(string path, DavKind expected) => Assert.Equal(expected, DavPath.Parse(path).Kind);
[Fact]
public void Parse_extracts_principal_calendar_and_event()
{
var user = Guid.NewGuid();
Assert.Equal(new DavPath(DavKind.Principal, PrincipalId: user), DavPath.Parse(DavPath.Principal(user)));
Assert.Equal(new DavPath(DavKind.Calendar, CalendarId: CalendarId), DavPath.Parse(DavPath.Calendar(CalendarId)));
Assert.Equal(new DavPath(DavKind.Event, CalendarId: CalendarId, Uid: "abc@familyplanner"), DavPath.Parse(DavPath.Event(CalendarId, "abc@familyplanner")));
}
[Theory]
[InlineData("0f7e3c2a8b1d4e5f9a6b7c8d9e0f1a2b@familyplanner")]
[InlineData("A1B2C3D4-E5F6-47A8-9B0C-D1E2F3A4B5C6")]
[InlineData("termin mit leerzeichen")]
[InlineData("a/b\\c:d?e#f%g+h&i=j")]
[InlineData("Müllabfuhr–Ärger €")]
[InlineData("100%25")]
public void Event_href_roundtrips_odd_uids_as_single_segment(string uid)
{
var href = DavPath.Event(CalendarId, uid);
Assert.DoesNotContain('/', href[DavPath.Calendar(CalendarId).Length..]);
Assert.Equal(uid, DavPath.Parse(href).Uid);
}
[Fact]
public void Parse_accepts_absolute_urls_and_queries_and_decodes_only_once()
{
var href = "https://familie.example/" + DavPath.Event(CalendarId, "x%25y")[1..] + "?a=b";
Assert.Equal("x%25y", DavPath.Parse(href).Uid);
Assert.Equal(DavKind.Unknown, DavPath.Parse(DavPath.Calendar(CalendarId) + "ohne-endung").Kind);
}
// ---------- ETag, Gruppierung, Sync ----------
[Fact]
public void ETag_is_stable_quoted_and_changes_with_any_row()
{
var master = Row("u1", T0);
var change = Row("u1", T0, master.Id, recurrenceId: T0.AddDays(1));
var etag = CalDavResources.ETag([master, change]);
Assert.StartsWith("\"", etag);
Assert.EndsWith("\"", etag);
Assert.Equal(etag, CalDavResources.ETag([change, master]));
change.RowVersion = [2];
Assert.NotEqual(etag, CalDavResources.ETag([master, change]));
Assert.NotEqual(CalDavResources.ETag([master]), CalDavResources.ETag([master, change]));
}
[Theory]
[InlineData("\"a\"", "\"a\"", true)]
[InlineData("\"b\", \"a\"", "\"a\"", true)]
[InlineData("*", "\"a\"", true)]
[InlineData("W/\"a\"", "\"a\"", true)]
[InlineData("\"b\"", "\"a\"", false)]
[InlineData("", "\"a\"", false)]
[InlineData(null, "\"a\"", false)]
public void MatchesAny_handles_lists_wildcards_and_weak_tags(string? header, string etag, bool expected) =>
Assert.Equal(expected, CalDavResources.MatchesAny(header, etag));
[Fact]
public void Build_groups_overrides_into_one_resource_and_reports_deleted_series_as_tombstones()
{
var master = Row("serie", T0);
var change = Row("serie", T0.AddMinutes(5), master.Id, recurrenceId: T0);
var gone = Row("weg", T0.AddMinutes(10), deleted: true);
var single = Row("einzel", T0);
var state = CalDavResources.Build(CalendarId, T0.AddDays(-1), [master, change, gone, single]);
Assert.Equal(["einzel", "serie"], state.Resources.Select(r => r.Uid));
Assert.Equal(change, Assert.Single(state.Resources.Single(r => r.Uid == "serie").Overrides));
Assert.Equal(T0.AddMinutes(5), state.Resources.Single(r => r.Uid == "serie").LastModified);
Assert.Equal("weg", Assert.Single(state.Tombstones).Uid);
Assert.Equal(T0.AddMinutes(10), state.Stamp);
}
[Fact]
public void Deleted_override_changes_the_resource_but_is_not_listed()
{
var master = Row("serie", T0);
var removed = Row("serie", T0.AddHours(1), master.Id, deleted: true, recurrenceId: T0);
var resource = Assert.Single(CalDavResources.Build(CalendarId, T0, [master, removed]).Resources);
Assert.Empty(resource.Overrides);
Assert.Equal(T0.AddHours(1), resource.LastModified);
}
[Fact]
public void Recreated_uid_is_a_live_resource_not_a_tombstone()
{
var old = Row("u", T0, deleted: true);
var fresh = Row("u", T0.AddHours(1));
var state = CalDavResources.Build(CalendarId, T0, [old, fresh]);
Assert.Single(state.Resources);
Assert.Empty(state.Tombstones);
}
[Fact]
public void Sync_token_roundtrips_and_rejects_garbage()
{
var token = CalDavSyncToken.Create(T0);
Assert.True(CalDavSyncToken.TryParse(token, out var since));
Assert.Equal(T0, since);
Assert.True(CalDavSyncToken.TryParse(null, out var initial));
Assert.Null(initial);
Assert.True(CalDavSyncToken.TryParse(" ", out _));
Assert.False(CalDavSyncToken.TryParse("urn:familyplanner:sync:abc", out _));
Assert.False(CalDavSyncToken.TryParse("urn:familyplanner:sync:-5", out _));
Assert.False(CalDavSyncToken.TryParse("http://example.com/token", out _));
Assert.False(CalDavSyncToken.TryParse("urn:familyplanner:sync:99999999999999999999", out _));
}
[Fact]
public void Delta_returns_everything_initially_and_only_newer_changes_afterwards()
{
var old = Row("alt", T0);
var fresh = Row("neu", T0.AddMinutes(1));
var gone = Row("weg", T0.AddMinutes(2), deleted: true);
var earlierGone = Row("frueher-weg", T0.AddSeconds(-5), deleted: true);
var state = CalDavResources.Build(CalendarId, T0, [old, fresh, gone, earlierGone]);
var (all, noTombstones) = CalDavSyncToken.Delta(state, null);
var (changed, removed) = CalDavSyncToken.Delta(state, T0);
Assert.Equal(["alt", "neu"], all.Select(r => r.Uid));
Assert.Empty(noTombstones);
Assert.Equal(["neu"], changed.Select(r => r.Uid)); // strikt größer: Gleichstand gilt als gesehen
Assert.Equal(["weg"], removed.Select(t => t.Uid));
Assert.Empty(CalDavSyncToken.Delta(state, state.Stamp).Changed);
}
// ---------- App-Passwörter ----------
[Fact]
public void Secret_has_expected_format_and_is_hashed_independent_of_formatting()
{
var secret = CalDavSecret.Generate();
Assert.Matches(@"^([a-z2-9]{4}-){4}[a-z2-9]{4}$", secret);
Assert.NotEqual(secret, CalDavSecret.Generate());
Assert.True(CalDavSecret.IsWellFormed(secret));
Assert.True(CalDavSecret.IsWellFormed(secret.ToUpperInvariant().Replace('-', ' ')));
Assert.Equal(CalDavSecret.Hash(secret), CalDavSecret.Hash(secret.ToUpperInvariant().Replace("-", string.Empty)));
Assert.Equal(64, CalDavSecret.Hash(secret).Length);
Assert.DoesNotContain(secret.Replace("-", string.Empty), CalDavSecret.Hash(secret));
Assert.Equal(secret[..4], CalDavSecret.Prefix(secret));
Assert.False(CalDavSecret.IsWellFormed("Eigenes Passwort 456"));
Assert.False(CalDavSecret.IsWellFormed(""));
}
[Theory]
[InlineData("Basic YW5uYTpzZWNyZXQ=", true, "anna", "secret")]
[InlineData("basic YW5uYTpzZWNyZXQ=", true, "anna", "secret")]
[InlineData("Basic YW5uYTpzZTpjcmV0", true, "anna", "se:cret")] // Doppelpunkt im Passwort bleibt erhalten
[InlineData("Basic OnNlY3JldA==", false, "", "")] // leerer Benutzer
[InlineData("Basic YW5uYTo=", false, "", "")] // leeres Passwort
[InlineData("Basic !!!", false, "", "")]
[InlineData("Bearer abc", false, "", "")]
public void Basic_header_is_decoded(string header, bool ok, string user, string secret)
{
Assert.Equal(ok, CalDavBasicHandler.TryDecode(header, out var u, out var s));
Assert.Equal((user, secret), (u, s));
}
[Fact]
public void Failure_throttle_blocks_per_user_and_ip_then_expires_and_resets()
{
var clock = new MutableTimeProvider(T0);
var throttle = new CalDavFailureThrottle(clock, Options.Create(new CalDavOptions { MaxFailuresPerUser = 3, MaxFailuresPerIp = 5, FailureWindowMinutes = 10 }));
for (var i = 0; i < 2; i++)
{
throttle.RegisterFailure("1.1.1.1", "anna");
}
Assert.Null(throttle.BlockedFor("1.1.1.1", "anna"));
throttle.RegisterFailure("1.1.1.1", "Anna");
Assert.NotNull(throttle.BlockedFor("1.1.1.1", "ANNA"));
Assert.Null(throttle.BlockedFor("1.1.1.1", "ben")); // andere Benutzer derselben IP bleiben möglich
Assert.Null(throttle.BlockedFor("2.2.2.2", "anna")); // Fremde sperren niemanden von anderen Adressen aus
clock.Now = T0.AddMinutes(11);
Assert.Null(throttle.BlockedFor("1.1.1.1", "anna"));
for (var i = 0; i < 5; i++)
{
throttle.RegisterFailure("3.3.3.3", $"user{i}");
}
Assert.NotNull(throttle.BlockedFor("3.3.3.3", "irgendwer"));
throttle.Reset("3.3.3.3", "user0");
}
private sealed class MutableTimeProvider(DateTimeOffset start) : TimeProvider
{
public DateTimeOffset Now { get; set; } = start;
public override DateTimeOffset GetUtcNow() => Now;
}
// ---------- XML ----------
[Fact]
public void Propfind_body_variants_are_understood()
{
var prop = CalDavXml.TryParse("<D:propfind xmlns:D='DAV:' xmlns:C='urn:ietf:params:xml:ns:caldav'><D:prop><D:getetag/><C:calendar-data/><x:foo xmlns:x='urn:x'/></D:prop></D:propfind>");
var request = DavPropRequest.FromPropfind(prop);
Assert.False(request.AllProp);
Assert.Equal([CalDavXml.D + "getetag", CalDavXml.C + "calendar-data", XName.Get("foo", "urn:x")], request.Names);
Assert.True(DavPropRequest.FromPropfind(null).AllProp);
Assert.True(DavPropRequest.FromPropfind(CalDavXml.TryParse("<D:propfind xmlns:D='DAV:'><D:allprop/></D:propfind>")).AllProp);
Assert.True(DavPropRequest.FromPropfind(CalDavXml.TryParse("<D:propfind xmlns:D='DAV:'><D:propname/></D:propfind>")).PropNameOnly);
}
[Fact]
public void Xml_parser_rejects_dtd_and_garbage()
{
Assert.Null(CalDavXml.TryParse("<?xml version='1.0'?><!DOCTYPE x [<!ENTITY e SYSTEM 'file:///etc/passwd'>]><x>&e;</x>"));
Assert.Null(CalDavXml.TryParse("<unclosed"));
Assert.Null(CalDavXml.TryParse(""));
Assert.Null(CalDavXml.TryParse(null));
}
[Fact]
public void Multistatus_has_found_and_missing_props_status_responses_and_sync_token()
{
var xml = CalDavXml.Multistatus(
[
CalDavXml.Response("/caldav/calendars/x/a.ics", [new XElement(CalDavXml.D + "getetag", "\"e\"")], [CalDavXml.C + "unbekannt"]),
CalDavXml.StatusResponse("/caldav/calendars/x/b.ics", 404),
],
"urn:familyplanner:sync:1");
var doc = XDocument.Parse(xml);
var responses = doc.Root!.Elements(CalDavXml.D + "response").ToList();
Assert.Equal(CalDavXml.D + "multistatus", doc.Root.Name);
Assert.Equal(2, responses.Count);
Assert.Equal(["HTTP/1.1 200 OK", "HTTP/1.1 404 Not Found"], responses[0].Elements(CalDavXml.D + "propstat").Select(p => (string)p.Element(CalDavXml.D + "status")!));
Assert.Equal("\"e\"", (string?)responses[0].Descendants(CalDavXml.D + "getetag").Single());
Assert.Equal("HTTP/1.1 404 Not Found", (string?)responses[1].Element(CalDavXml.D + "status"));
Assert.Equal("urn:familyplanner:sync:1", (string?)doc.Root.Element(CalDavXml.D + "sync-token"));
Assert.StartsWith("<?xml version=\"1.0\" encoding=\"utf-8\"?>", xml);
}
// ---------- calendar-query ----------
private static DavResource Resource(CalendarEvent master, params CalendarEvent[] overrides) =>
new(CalendarId, master.Uid, master, overrides, T0, CalDavResources.ETag(overrides.Prepend(master)));
private static CalendarQuery Query(string filterXml) => CalendarQuery.Parse(XDocument.Parse(
$"<C:calendar-query xmlns:C='urn:ietf:params:xml:ns:caldav' xmlns:D='DAV:'><D:prop><D:getetag/></D:prop><C:filter>{filterXml}</C:filter></C:calendar-query>").Root!);
[Fact]
public void Query_time_range_matches_single_events_and_expands_series()
{
var zone = FamilyTime.Berlin;
var single = Row("einzel", T0);
single.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 11, 5, 10, 0, 0), zone);
single.EndUtc = single.StartUtc.AddHours(1);
var weekly = Row("woechentlich", T0);
weekly.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 10, 1, 9, 0, 0), zone);
weekly.EndUtc = weekly.StartUtc.AddHours(1);
weekly.RecurrenceRule = "FREQ=WEEKLY";
var november = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20261101T000000Z' end='20261201T000000Z'/></C:comp-filter></C:comp-filter>");
var october = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20261001T000000Z' end='20261031T000000Z'/></C:comp-filter></C:comp-filter>");
var farFuture = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20300101T000000Z'/></C:comp-filter></C:comp-filter>");
Assert.True(november.Matches(Resource(single), T0));
Assert.False(october.Matches(Resource(single), T0));
Assert.True(october.Matches(Resource(weekly), T0));
Assert.True(farFuture.Matches(Resource(weekly), T0));
Assert.False(farFuture.Matches(Resource(single), T0));
}
[Fact]
public void Query_respects_exdate_and_prop_filters_and_other_components()
{
var zone = FamilyTime.Berlin;
var daily = Row("taeglich", T0);
daily.Title = "Gassi gehen";
daily.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 10, 1, 9, 0, 0), zone);
daily.EndUtc = daily.StartUtc.AddHours(1);
daily.RecurrenceRule = "FREQ=DAILY;COUNT=2";
daily.ExceptionDates = [daily.StartUtc.AddDays(1)];
var day2 = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:time-range start='20261002T000000Z' end='20261003T000000Z'/></C:comp-filter></C:comp-filter>");
var summary = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:prop-filter name='SUMMARY'><C:text-match>gassi</C:text-match></C:prop-filter></C:comp-filter></C:comp-filter>");
var notSummary = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:prop-filter name='SUMMARY'><C:text-match negate-condition='yes'>gassi</C:text-match></C:prop-filter></C:comp-filter></C:comp-filter>");
var uid = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VEVENT'><C:prop-filter name='UID'><C:text-match match-type='equals'>taeglich</C:text-match></C:prop-filter></C:comp-filter></C:comp-filter>");
var todos = Query("<C:comp-filter name='VCALENDAR'><C:comp-filter name='VTODO'/></C:comp-filter>");
Assert.False(day2.Matches(Resource(daily), T0)); // EXDATE
Assert.True(summary.Matches(Resource(daily), T0));
Assert.False(notSummary.Matches(Resource(daily), T0));
Assert.True(uid.Matches(Resource(daily), T0));
Assert.False(todos.Matches(Resource(daily), T0));
Assert.True(CalendarQuery.Parse(XDocument.Parse("<C:calendar-query xmlns:C='urn:ietf:params:xml:ns:caldav'/>").Root!).Matches(Resource(daily), T0));
}
// ---------- PUT-Prüfung ----------
private static string Ics(params string[] events) =>
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//EN\r\n" + string.Concat(events) + "END:VCALENDAR\r\n";
private static string Vevent(string uid, string extra = "", string start = "20261110T100000Z") =>
$"BEGIN:VEVENT\r\nUID:{uid}\r\nDTSTAMP:20261001T000000Z\r\nDTSTART:{start}\r\nDTEND:20261110T110000Z\r\nSUMMARY:Test\r\n{extra}END:VEVENT\r\n";
private static async Task<(int Status, string Body)> Execute(IResult result)
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
await result.ExecuteAsync(context);
context.Response.Body.Position = 0;
return (context.Response.StatusCode, await new StreamReader(context.Response.Body).ReadToEndAsync());
}
private static IResult? Validate(string text, string uid, out CalendarEvent? master, out List<CalendarEvent> overrides) =>
CalDavEndpoints.Validate(text, uid, FamilyTime.Berlin, out master, out overrides);
[Fact]
public void Validate_accepts_single_event_and_series_with_override()
{
Assert.Null(Validate(Ics(Vevent("u1")), "u1", out var single, out var none));
Assert.Equal("Test", single!.Title);
Assert.Empty(none);
var series = Ics(
Vevent("u2", "RRULE:FREQ=DAILY;COUNT=3\r\n"),
Vevent("u2", "RECURRENCE-ID:20261111T100000Z\r\n", "20261111T120000Z"));
Assert.Null(Validate(series, "u2", out var master, out var overrides));
Assert.Equal("FREQ=DAILY;COUNT=3", master!.RecurrenceRule);
var change = Assert.Single(overrides);
Assert.Equal(new DateTimeOffset(2026, 11, 11, 10, 0, 0, TimeSpan.Zero), change.RecurrenceId);
}
[Theory]
[InlineData("todo")]
[InlineData("journal")]
[InlineData("garbage")]
[InlineData("rule")]
[InlineData("uid")]
[InlineData("two-uids")]
[InlineData("override-only")]
[InlineData("override-without-series")]
[InlineData("empty")]
public async Task Validate_rejects_with_403_and_precondition(string kind)
{
var text = kind switch
{
"todo" => Ics("BEGIN:VTODO\r\nUID:u1\r\nDTSTAMP:20261001T000000Z\r\nSUMMARY:x\r\nEND:VTODO\r\n"),
"journal" => Ics(Vevent("u1"), "BEGIN:VJOURNAL\r\nUID:j\r\nDTSTAMP:20261001T000000Z\r\nEND:VJOURNAL\r\n"),
"garbage" => "das ist kein iCalendar",
"rule" => Ics(Vevent("u1", "RRULE:FREQ=HOURLY;COUNT=3\r\n")),
"uid" => Ics(Vevent("anders")),
"two-uids" => Ics(Vevent("u1"), Vevent("u2")),
"override-only" => Ics(Vevent("u1", "RECURRENCE-ID:20261111T100000Z\r\n")),
"override-without-series" => Ics(Vevent("u1"), Vevent("u1", "RECURRENCE-ID:20261111T100000Z\r\n")),
_ => Ics(),
};
var result = Validate(text, "u1", out var master, out _);
Assert.NotNull(result);
Assert.Null(master);
var (status, body) = await Execute(result);
Assert.Equal(403, status);
Assert.Contains("<D:error", body);
}
[Fact]
public async Task Validate_reports_specific_preconditions()
{
var todo = await Execute(Validate(Ics("BEGIN:VTODO\r\nUID:u1\r\nEND:VTODO\r\n"), "u1", out _, out _)!);
var rule = await Execute(Validate(Ics(Vevent("u1", "RRULE:FREQ=HOURLY\r\n")), "u1", out _, out _)!);
var garbage = await Execute(Validate("nope", "u1", out _, out _)!);
Assert.Contains("supported-calendar-component", todo.Body);
Assert.Contains("valid-calendar-object-resource", rule.Body);
Assert.Contains("valid-calendar-data", garbage.Body);
}
// ---------- Export als CalDAV-Ressource ----------
[Fact]
public void Resource_export_has_no_person_line_feed_headers_or_categories_so_edits_do_not_accumulate()
{
var anna = Guid.NewGuid();
var e = Row("u1", T0);
e.Notes = "Bitte Kuchen mitbringen";
e.PersonIds = [anna];
e.ForWholeFamily = true;
var names = new Dictionary<Guid, string> { [CalendarId] = "Familie" };
var resource = IcsBuilder.Build(string.Empty, "Europe/Berlin", [e], names, new Dictionary<Guid, string> { [anna] = "Anna" }, [], IcsTaskMode.None, resourceMode: true);
var feed = IcsBuilder.Build("Familie", "Europe/Berlin", [e], names, new Dictionary<Guid, string> { [anna] = "Anna" }, [], IcsTaskMode.None);
Assert.Contains("DESCRIPTION:Bitte Kuchen mitbringen", resource);
Assert.DoesNotContain("Für:", resource);
Assert.DoesNotContain("X-WR-CALNAME", resource);
Assert.DoesNotContain("REFRESH-INTERVAL", resource);
Assert.DoesNotContain("CATEGORIES", resource);
Assert.Contains("BEGIN:VTIMEZONE", resource);
Assert.Contains("Für:", feed); // Feed unverändert
}
[Fact]
public void Resource_export_roundtrips_through_the_importer_unchanged()
{
var e = Row("u1", T0);
e.Title = "Zahnarzt";
e.Location = "Praxis";
e.Notes = "Karte mitnehmen";
e.StartUtc = FamilyTime.ToUtc(new DateTime(2026, 11, 3, 14, 0, 0), FamilyTime.Berlin);
e.EndUtc = e.StartUtc.AddHours(1);
e.RecurrenceRule = "FREQ=WEEKLY;BYDAY=TU;COUNT=4";
var ics = IcsBuilder.Build(string.Empty, "Europe/Berlin", [e], new Dictionary<Guid, string>(), new Dictionary<Guid, string>(), [], IcsTaskMode.None, resourceMode: true);
Assert.Null(Validate(ics, "u1", out var back, out _));
Assert.Equal((e.Title, e.Location, e.Notes, e.StartUtc, e.EndUtc), (back!.Title, back.Location, back.Notes, back.StartUtc, back.EndUtc));
Assert.Equal(e.RecurrenceRule, back.RecurrenceRule);
Assert.Equal("Europe/Berlin", back.TimeZoneId);
}
}