CalDav synchronizer, new IMAP setup/edit page.
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using FluentAssertions;
|
||||
using Wino.Core.Domain.Models.AutoDiscovery;
|
||||
using Wino.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests.Services;
|
||||
|
||||
public class AutoDiscoveryServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetAutoDiscoverySettings_UsesThunderbirdAutoconfig_WhenAvailable()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(request =>
|
||||
{
|
||||
var uri = request.RequestUri!.ToString();
|
||||
|
||||
if (uri.StartsWith("https://autoconfig.example.com/mail/config-v1.1.xml", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateXmlResponse("""
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="example.com">
|
||||
<incomingServer type="imap">
|
||||
<hostname>imap.example.com</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<username>%EMAILLOCALPART%</username>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>smtp.example.com</hostname>
|
||||
<port>587</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
</emailProvider>
|
||||
</clientConfig>
|
||||
""", request);
|
||||
}
|
||||
|
||||
return CreateStatusResponse(HttpStatusCode.NotFound, request);
|
||||
});
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
var sut = new AutoDiscoveryService(client);
|
||||
|
||||
var settings = await sut.GetAutoDiscoverySettings(new AutoDiscoveryMinimalSettings
|
||||
{
|
||||
Email = "user@example.com",
|
||||
DisplayName = "User",
|
||||
Password = "secret"
|
||||
});
|
||||
|
||||
settings.Should().NotBeNull();
|
||||
settings!.Domain.Should().Be("example.com");
|
||||
settings.GetImapSettings()!.Address.Should().Be("imap.example.com");
|
||||
settings.GetImapSettings()!.Username.Should().Be("user");
|
||||
settings.GetSmptpSettings()!.Address.Should().Be("smtp.example.com");
|
||||
settings.GetSmptpSettings()!.Username.Should().Be("user@example.com");
|
||||
handler.RequestedUris.Should().NotContain(uri => uri.Contains("emailsettings.firetrust.com", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAutoDiscoverySettings_FallsBackToFiretrust_WhenThunderbirdMethodsFail()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(request =>
|
||||
{
|
||||
var uri = request.RequestUri!.ToString();
|
||||
|
||||
if (uri.StartsWith("https://emailsettings.firetrust.com/settings?q=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateJsonResponse("""
|
||||
{
|
||||
"domain": "example.com",
|
||||
"settings": [
|
||||
{
|
||||
"protocol": "IMAP",
|
||||
"address": "imap.firetrust.example.com",
|
||||
"port": 993,
|
||||
"secure": "SSL",
|
||||
"username": "user@example.com"
|
||||
},
|
||||
{
|
||||
"protocol": "SMTP",
|
||||
"address": "smtp.firetrust.example.com",
|
||||
"port": 587,
|
||||
"secure": "STARTTLS",
|
||||
"username": "user@example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
""", request);
|
||||
}
|
||||
|
||||
if (uri.StartsWith("https://dns.google/resolve", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return CreateJsonResponse("{\"Status\":0}", request);
|
||||
}
|
||||
|
||||
return CreateStatusResponse(HttpStatusCode.NotFound, request);
|
||||
});
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
var sut = new AutoDiscoveryService(client);
|
||||
|
||||
var settings = await sut.GetAutoDiscoverySettings(new AutoDiscoveryMinimalSettings
|
||||
{
|
||||
Email = "user@example.com"
|
||||
});
|
||||
|
||||
settings.Should().NotBeNull();
|
||||
settings!.GetImapSettings()!.Address.Should().Be("imap.firetrust.example.com");
|
||||
settings.GetSmptpSettings()!.Address.Should().Be("smtp.firetrust.example.com");
|
||||
handler.RequestedUris.Should().Contain(uri => uri.Contains("emailsettings.firetrust.com", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverCalDavServiceUriAsync_ReturnsKnownYahooEndpoint()
|
||||
{
|
||||
var sut = new AutoDiscoveryService(new HttpClient(new StubHttpMessageHandler(_ => throw new InvalidOperationException("No network call expected"))));
|
||||
|
||||
var uri = await sut.DiscoverCalDavServiceUriAsync("user@yahoo.com");
|
||||
|
||||
uri.Should().Be(new Uri("https://caldav.calendar.yahoo.com/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverCalDavServiceUriAsync_ResolvesWellKnownRedirect()
|
||||
{
|
||||
var handler = new StubHttpMessageHandler(request =>
|
||||
{
|
||||
var uri = request.RequestUri!.ToString();
|
||||
|
||||
if (uri.Equals("https://calendar.example.com/.well-known/caldav", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var response = CreateStatusResponse(HttpStatusCode.Found, request);
|
||||
response.Headers.Location = new Uri("https://dav.example.net/caldav/");
|
||||
return response;
|
||||
}
|
||||
|
||||
return CreateStatusResponse(HttpStatusCode.NotFound, request);
|
||||
});
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
var sut = new AutoDiscoveryService(client);
|
||||
|
||||
var uri = await sut.DiscoverCalDavServiceUriAsync("user@calendar.example.com");
|
||||
|
||||
uri.Should().Be(new Uri("https://dav.example.net/caldav/"));
|
||||
}
|
||||
|
||||
private static HttpResponseMessage CreateXmlResponse(string xml, HttpRequestMessage request)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new StringContent(xml, Encoding.UTF8, "application/xml")
|
||||
};
|
||||
|
||||
private static HttpResponseMessage CreateJsonResponse(string json, HttpRequestMessage request)
|
||||
=> new(HttpStatusCode.OK)
|
||||
{
|
||||
RequestMessage = request,
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
private static HttpResponseMessage CreateStatusResponse(HttpStatusCode statusCode, HttpRequestMessage request)
|
||||
=> new(statusCode)
|
||||
{
|
||||
RequestMessage = request
|
||||
};
|
||||
|
||||
private sealed class StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory) : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory = responseFactory;
|
||||
|
||||
public List<string> RequestedUris { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
RequestedUris.Add(request.RequestUri?.ToString() ?? string.Empty);
|
||||
var response = _responseFactory(request);
|
||||
|
||||
// Ensure probing logic sees a request URI even if response factory forgot it.
|
||||
response.RequestMessage ??= request;
|
||||
|
||||
if (response.Headers.Date == null)
|
||||
{
|
||||
response.Headers.Date = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
if (!response.Headers.Contains("DAV") &&
|
||||
response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
response.Headers.Add("DAV", "1, calendar-access");
|
||||
}
|
||||
|
||||
var hasAllowHeader = response.Headers.TryGetValues("Allow", out var allowValues) &&
|
||||
allowValues.Any();
|
||||
|
||||
if (!hasAllowHeader &&
|
||||
response.StatusCode == HttpStatusCode.OK &&
|
||||
request.Method == HttpMethod.Options)
|
||||
{
|
||||
response.Headers.Add("Allow", "PROPFIND");
|
||||
}
|
||||
|
||||
if (response.Content == null)
|
||||
{
|
||||
response.Content = new StringContent(string.Empty, Encoding.UTF8, "text/plain");
|
||||
}
|
||||
|
||||
if (response.Content.Headers.ContentType == null)
|
||||
{
|
||||
response.Content.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
|
||||
}
|
||||
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,79 +117,6 @@ public class CalendarServiceTests : IAsyncLifetime
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarEventsAsync_WithRecurringEventInstances_ReturnsAllInstancesInPeriod()
|
||||
{
|
||||
// Arrange - Simulate synced recurring event instances (as they would come from server)
|
||||
var parentId = Guid.NewGuid();
|
||||
var startDate1 = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||
var startDate2 = new DateTime(2025, 1, 16, 10, 0, 0, DateTimeKind.Utc);
|
||||
var startDate3 = new DateTime(2025, 1, 17, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Parent series master (typically hidden or outside display range)
|
||||
var parentEvent = new CalendarItem
|
||||
{
|
||||
Id = parentId,
|
||||
Title = "Daily Standup",
|
||||
Description = "Daily team sync",
|
||||
StartDate = startDate1,
|
||||
DurationInSeconds = 1800, // 30 minutes
|
||||
CalendarId = _testCalendar.Id,
|
||||
IsHidden = false,
|
||||
Recurrence = "RRULE:FREQ=DAILY;COUNT=3"
|
||||
};
|
||||
|
||||
// Individual occurrence instances (as synced from server)
|
||||
var instance1 = new CalendarItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "Daily Standup",
|
||||
StartDate = startDate1,
|
||||
DurationInSeconds = 1800,
|
||||
CalendarId = _testCalendar.Id,
|
||||
RecurringCalendarItemId = parentId,
|
||||
IsHidden = false
|
||||
};
|
||||
|
||||
var instance2 = new CalendarItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "Daily Standup",
|
||||
StartDate = startDate2,
|
||||
DurationInSeconds = 1800,
|
||||
CalendarId = _testCalendar.Id,
|
||||
RecurringCalendarItemId = parentId,
|
||||
IsHidden = false
|
||||
};
|
||||
|
||||
var instance3 = new CalendarItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Title = "Daily Standup",
|
||||
StartDate = startDate3,
|
||||
DurationInSeconds = 1800,
|
||||
CalendarId = _testCalendar.Id,
|
||||
RecurringCalendarItemId = parentId,
|
||||
IsHidden = false
|
||||
};
|
||||
|
||||
await _calendarService.CreateNewCalendarItemAsync(parentEvent, null);
|
||||
await _calendarService.CreateNewCalendarItemAsync(instance1, null);
|
||||
await _calendarService.CreateNewCalendarItemAsync(instance2, null);
|
||||
await _calendarService.CreateNewCalendarItemAsync(instance3, null);
|
||||
|
||||
var period = new TimeRange(
|
||||
new DateTime(2025, 1, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2025, 1, 18, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
// Act
|
||||
var result = await _calendarService.GetCalendarEventsAsync(_testCalendar, period);
|
||||
|
||||
// Assert - Should return parent + 3 instances = 4 total
|
||||
result.Should().HaveCount(4, "parent event plus 3 instances");
|
||||
result.Where(e => e.Title == "Daily Standup").Should().HaveCount(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetCalendarEventsAsync_WithHiddenEvent_ExcludesFromResults()
|
||||
{
|
||||
@@ -267,7 +194,7 @@ public class CalendarServiceTests : IAsyncLifetime
|
||||
|
||||
// Add events to both calendars
|
||||
var startDate = new DateTime(2025, 1, 15, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
|
||||
var event1 = new CalendarItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
|
||||
Reference in New Issue
Block a user