diff --git a/Wino.Core.Tests/Synchronizers/CalDavServiceLiveTests.cs b/Wino.Core.Tests/Synchronizers/CalDavServiceLiveTests.cs new file mode 100644 index 00000000..4d869f93 --- /dev/null +++ b/Wino.Core.Tests/Synchronizers/CalDavServiceLiveTests.cs @@ -0,0 +1,238 @@ +using System.Net.Http.Headers; +using System.Security; +using System.Text; +using System.Xml.Linq; +using FluentAssertions; +using Wino.Core.Domain.Models.Calendar; +using Wino.Services; +using Xunit; + +namespace Wino.Core.Tests.Synchronizers; + +public sealed class CalDavServiceLiveTests +{ + private const string ManualSkipMessage = "Manual live CalDAV test. Fill ServiceUri/Username/Password placeholders and remove Skip to run."; + + // Replace placeholders with your own credentials when running these live tests. + private const string ServiceUri = "https://caldav.icloud.com/"; + private const string Username = "REPLACE_WITH_USERNAME"; + private const string Password = "REPLACE_WITH_PASSWORD"; + + private static readonly DateTimeOffset SyncWindowStartUtc = new(2026, 01, 01, 0, 0, 0, TimeSpan.Zero); + private static readonly DateTimeOffset SyncWindowEndUtc = new(2026, 12, 31, 23, 59, 59, TimeSpan.Zero); + + [Fact(Skip = ManualSkipMessage)] + [Trait("Category", "Live")] + public async Task InitialSync_ReturnsCalendarEvents() + { + var client = new CalDavClient(); + var settings = BuildConnectionSettings(); + + var calendars = await client.DiscoverCalendarsAsync(settings); + calendars.Should().NotBeEmpty(); + + var calendar = calendars.First(); + var events = await client.GetCalendarEventsAsync(settings, calendar, SyncWindowStartUtc, SyncWindowEndUtc); + + events.Should().NotBeNull(); + } + + [Fact(Skip = ManualSkipMessage)] + [Trait("Category", "Live")] + public async Task AddThenRemoveEvent_ChangesServerState() + { + var client = new CalDavClient(); + var settings = BuildConnectionSettings(); + var calendar = await GetTargetCalendarAsync(client, settings); + + var eventId = $"wino-live-add-delete-{Guid.NewGuid():N}"; + var resourceUri = BuildEventResourceUri(calendar, eventId); + + await PutEventAsync(settings, resourceUri, BuildIcs(eventId, "Wino Live Add/Delete", new DateTimeOffset(2026, 04, 01, 10, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 04, 01, 11, 0, 0, TimeSpan.Zero))); + + var afterAdd = await client.GetCalendarEventsAsync(settings, calendar, SyncWindowStartUtc, SyncWindowEndUtc); + afterAdd.Should().Contain(e => e.Uid == eventId); + + await DeleteEventAsync(settings, resourceUri); + + var afterDelete = await client.GetCalendarEventsAsync(settings, calendar, SyncWindowStartUtc, SyncWindowEndUtc); + afterDelete.Should().NotContain(e => e.Uid == eventId); + } + + [Fact(Skip = ManualSkipMessage)] + [Trait("Category", "Live")] + public async Task UpdateExistingEvent_ChangesStartAndEndDates() + { + var client = new CalDavClient(); + var settings = BuildConnectionSettings(); + var calendar = await GetTargetCalendarAsync(client, settings); + + var eventId = $"wino-live-update-{Guid.NewGuid():N}"; + var resourceUri = BuildEventResourceUri(calendar, eventId); + + var initialStart = new DateTimeOffset(2026, 05, 01, 8, 0, 0, TimeSpan.Zero); + var initialEnd = new DateTimeOffset(2026, 05, 01, 9, 0, 0, TimeSpan.Zero); + + await PutEventAsync(settings, resourceUri, BuildIcs(eventId, "Wino Live Update", initialStart, initialEnd)); + + var updatedStart = new DateTimeOffset(2026, 05, 02, 14, 30, 0, TimeSpan.Zero); + var updatedEnd = new DateTimeOffset(2026, 05, 02, 16, 0, 0, TimeSpan.Zero); + + await PutEventAsync(settings, resourceUri, BuildIcs(eventId, "Wino Live Update", updatedStart, updatedEnd)); + + var events = await client.GetCalendarEventsAsync(settings, calendar, SyncWindowStartUtc, SyncWindowEndUtc); + var updatedEvent = events.First(e => e.Uid == eventId); + + updatedEvent.Start.Should().Be(updatedStart); + updatedEvent.End.Should().Be(updatedEnd); + + await DeleteEventAsync(settings, resourceUri); + } + + [Fact(Skip = ManualSkipMessage)] + [Trait("Category", "Live")] + public async Task DeltaSync_AfterAdd_ReturnsChangedResource() + { + var client = new CalDavClient(); + var settings = BuildConnectionSettings(); + var calendar = await GetTargetCalendarAsync(client, settings); + + var initialSyncToken = await GetCalendarSyncTokenAsync(settings, new Uri(calendar.RemoteCalendarId)); + initialSyncToken.Should().NotBeNullOrWhiteSpace(); + + var eventId = $"wino-live-delta-{Guid.NewGuid():N}"; + var resourceUri = BuildEventResourceUri(calendar, eventId); + + await PutEventAsync(settings, resourceUri, BuildIcs(eventId, "Wino Live Delta", new DateTimeOffset(2026, 06, 01, 12, 0, 0, TimeSpan.Zero), new DateTimeOffset(2026, 06, 01, 13, 0, 0, TimeSpan.Zero))); + + var deltaResponse = await ReportSyncCollectionAsync(settings, new Uri(calendar.RemoteCalendarId), initialSyncToken); + var changedHrefs = ExtractChangedHrefs(deltaResponse); + + changedHrefs.Should().Contain(h => h.Contains($"{eventId}.ics", StringComparison.OrdinalIgnoreCase)); + + await DeleteEventAsync(settings, resourceUri); + } + + private static CalDavConnectionSettings BuildConnectionSettings() + => new() + { + ServiceUri = new Uri(ServiceUri), + Username = Username, + Password = Password + }; + + private static async Task GetTargetCalendarAsync(CalDavClient client, CalDavConnectionSettings settings) + { + var calendars = await client.DiscoverCalendarsAsync(settings); + calendars.Should().NotBeEmpty(); + return calendars.First(); + } + + private static Uri BuildEventResourceUri(CalDavCalendar calendar, string eventId) + => new($"{calendar.RemoteCalendarId.TrimEnd('/')}/{eventId}.ics"); + + private static string BuildIcs(string uid, string summary, DateTimeOffset startUtc, DateTimeOffset endUtc) + { + return $""" + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Wino Mail//CalDAV Live Tests//EN + CALSCALE:GREGORIAN + BEGIN:VEVENT + UID:{uid} + DTSTAMP:{DateTimeOffset.UtcNow:yyyyMMdd'T'HHmmss'Z'} + DTSTART:{startUtc:yyyyMMdd'T'HHmmss'Z'} + DTEND:{endUtc:yyyyMMdd'T'HHmmss'Z'} + SUMMARY:{summary} + END:VEVENT + END:VCALENDAR + """; + } + + private static async Task PutEventAsync(CalDavConnectionSettings settings, Uri eventUri, string icsContent) + { + using var client = CreateAuthenticatedHttpClient(settings); + using var request = new HttpRequestMessage(HttpMethod.Put, eventUri) + { + Content = new StringContent(icsContent, Encoding.UTF8, "text/calendar") + }; + + using var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + } + + private static async Task DeleteEventAsync(CalDavConnectionSettings settings, Uri eventUri) + { + using var client = CreateAuthenticatedHttpClient(settings); + using var response = await client.DeleteAsync(eventUri); + response.EnsureSuccessStatusCode(); + } + + private static async Task GetCalendarSyncTokenAsync(CalDavConnectionSettings settings, Uri calendarUri) + { + const string body = """ + + + + + + """; + + using var client = CreateAuthenticatedHttpClient(settings); + using var request = new HttpRequestMessage(new HttpMethod("PROPFIND"), calendarUri) + { + Content = new StringContent(body, Encoding.UTF8, "application/xml") + }; + + request.Headers.Add("Depth", "0"); + using var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var xml = await response.Content.ReadAsStringAsync(); + var doc = XDocument.Parse(xml); + + return doc.Descendants().FirstOrDefault(x => x.Name.LocalName == "sync-token")?.Value ?? string.Empty; + } + + private static async Task ReportSyncCollectionAsync(CalDavConnectionSettings settings, Uri calendarUri, string syncToken) + { + var body = $""" + + {SecurityElement.Escape(syncToken)} + 1 + + + + + """; + + using var client = CreateAuthenticatedHttpClient(settings); + using var request = new HttpRequestMessage(new HttpMethod("REPORT"), calendarUri) + { + Content = new StringContent(body, Encoding.UTF8, "application/xml") + }; + + request.Headers.Add("Depth", "1"); + using var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + + var xml = await response.Content.ReadAsStringAsync(); + return XDocument.Parse(xml); + } + + private static IReadOnlyList ExtractChangedHrefs(XDocument deltaXml) + => deltaXml + .Descendants() + .Where(x => x.Name.LocalName == "href") + .Select(x => x.Value) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .ToList(); + + private static HttpClient CreateAuthenticatedHttpClient(CalDavConnectionSettings settings) + { + var client = new HttpClient(); + var basicAuth = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{settings.Username}:{settings.Password}")); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", basicAuth); + return client; + } +}