Refactored impa synchronization.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
using FluentAssertions;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Exceptions;
|
||||
using Wino.Core.Domain.Models.Connectivity;
|
||||
using Wino.Core.Integration;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public class ImapClientPoolTests
|
||||
{
|
||||
[Fact]
|
||||
public void CalculateMaxConnections_ShouldUseDefault_WhenConfiguredValueIsNonPositive()
|
||||
{
|
||||
ImapClientPool.CalculateMaxConnections(0).Should().Be(5);
|
||||
ImapClientPool.CalculateMaxConnections(-4).Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateMaxConnections_ShouldClampToTen_WhenConfiguredValueIsTooHigh()
|
||||
{
|
||||
ImapClientPool.CalculateMaxConnections(40).Should().Be(10);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTargetMinimumConnections_ShouldRespectConservativeMode()
|
||||
{
|
||||
ImapClientPool.CalculateTargetMinimumConnections(maxConnections: 5, useConservativeConnections: true).Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateTargetMinimumConnections_ShouldBeTwo_WhenNotConservativeAndCapacityAllows()
|
||||
{
|
||||
ImapClientPool.CalculateTargetMinimumConnections(maxConnections: 5, useConservativeConnections: false).Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RentAsync_ShouldThrowImapClientPoolException_WhenAcquireTimesOut()
|
||||
{
|
||||
var serverInformation = new CustomServerInformation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IncomingServer = "127.0.0.1",
|
||||
IncomingServerPort = "1",
|
||||
IncomingServerUsername = "user",
|
||||
IncomingServerPassword = "password",
|
||||
IncomingServerSocketOption = ImapConnectionSecurity.None,
|
||||
IncomingAuthenticationMethod = ImapAuthenticationMethod.Auto,
|
||||
MaxConcurrentClients = 2
|
||||
};
|
||||
|
||||
using var pool = new ImapClientPool(ImapClientPoolOptions.CreateTestPool(serverInformation, protocolLog: null));
|
||||
|
||||
var act = async () => await pool.RentAsync(TimeSpan.FromMilliseconds(400));
|
||||
var exception = await act.Should().ThrowAsync<ImapClientPoolException>();
|
||||
|
||||
exception.Which.CustomServerInformation.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_ShouldBeSafe_WhenCalledConcurrently()
|
||||
{
|
||||
var serverInformation = new CustomServerInformation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IncomingServer = "127.0.0.1",
|
||||
IncomingServerPort = "1",
|
||||
IncomingServerUsername = "user",
|
||||
IncomingServerPassword = "password",
|
||||
IncomingServerSocketOption = ImapConnectionSecurity.None,
|
||||
IncomingAuthenticationMethod = ImapAuthenticationMethod.Auto,
|
||||
MaxConcurrentClients = 2
|
||||
};
|
||||
|
||||
using var pool = new ImapClientPool(ImapClientPoolOptions.CreateTestPool(serverInformation, protocolLog: null));
|
||||
|
||||
var init1 = pool.InitializeAsync();
|
||||
var init2 = pool.InitializeAsync();
|
||||
|
||||
await Task.WhenAll(init1, init2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Integration.Processors;
|
||||
using Wino.Core.Synchronizers.ImapSync;
|
||||
using Wino.Core.Synchronizers.Mail;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public class ImapSynchronizerIdleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ShouldTriggerIdleSynchronization_ShouldDebounceBurstSignals()
|
||||
{
|
||||
var tempDirectory = Path.Combine(Path.GetTempPath(), "wino-imap-idle-tests", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(tempDirectory);
|
||||
|
||||
var synchronizer = CreateSynchronizer(tempDirectory);
|
||||
|
||||
try
|
||||
{
|
||||
var baseTime = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
synchronizer.ShouldTriggerIdleSynchronization(baseTime).Should().BeTrue();
|
||||
synchronizer.ShouldTriggerIdleSynchronization(baseTime.AddSeconds(5)).Should().BeFalse();
|
||||
synchronizer.ShouldTriggerIdleSynchronization(baseTime.AddSeconds(16)).Should().BeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await synchronizer.KillSynchronizerAsync();
|
||||
|
||||
if (Directory.Exists(tempDirectory))
|
||||
{
|
||||
Directory.Delete(tempDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ImapSynchronizer CreateSynchronizer(string appDataFolder)
|
||||
{
|
||||
var account = new MailAccount
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "IMAP Test",
|
||||
Address = "test@example.com",
|
||||
ProviderType = MailProviderType.IMAP4,
|
||||
ServerInformation = new CustomServerInformation
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
IncomingServer = "imap.example.com",
|
||||
IncomingServerPort = "993",
|
||||
IncomingServerUsername = "user",
|
||||
IncomingServerPassword = "password",
|
||||
MaxConcurrentClients = 5
|
||||
}
|
||||
};
|
||||
|
||||
var applicationConfiguration = new Mock<IApplicationConfiguration>();
|
||||
applicationConfiguration.SetupProperty(x => x.ApplicationDataFolderPath, appDataFolder);
|
||||
applicationConfiguration.SetupProperty(x => x.PublisherSharedFolderPath, appDataFolder);
|
||||
applicationConfiguration.SetupProperty(x => x.ApplicationTempFolderPath, appDataFolder);
|
||||
applicationConfiguration.SetupGet(x => x.SentryDNS).Returns(string.Empty);
|
||||
|
||||
var unifiedSynchronizer = new UnifiedImapSynchronizer(
|
||||
Mock.Of<IFolderService>(),
|
||||
Mock.Of<IMailService>(),
|
||||
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
|
||||
|
||||
return new ImapSynchronizer(
|
||||
account,
|
||||
Mock.Of<IImapChangeProcessor>(),
|
||||
applicationConfiguration.Object,
|
||||
unifiedSynchronizer,
|
||||
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using FluentAssertions;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using Moq;
|
||||
using System.Reflection;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Synchronizers.ImapSync;
|
||||
using Xunit;
|
||||
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
||||
|
||||
namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public class UnifiedImapSynchronizerTests
|
||||
{
|
||||
private static UnifiedImapSynchronizer CreateSut()
|
||||
{
|
||||
return new UnifiedImapSynchronizer(
|
||||
Mock.Of<IFolderService>(),
|
||||
Mock.Of<IMailService>(),
|
||||
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldPrioritizeQResync_WhenEnabledAndSupported()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.QuickResync | ImapCapabilities.CondStore,
|
||||
isQResyncEnabled: true,
|
||||
serverHost: "imap.example.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.QResync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldFallbackToCondstore_WhenQResyncNotEnabled()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.QuickResync | ImapCapabilities.CondStore,
|
||||
isQResyncEnabled: false,
|
||||
serverHost: "imap.example.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.Condstore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldUseUidFallback_WhenNoAdvancedCapability()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.None,
|
||||
isQResyncEnabled: false,
|
||||
serverHost: "imap.example.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.UidBased);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldRespectQuirkOverride_ForStrictProviders()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.QuickResync | ImapCapabilities.CondStore,
|
||||
isQResyncEnabled: true,
|
||||
serverHost: "imap.qq.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.Condstore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldFallbackToUid_WhenCondstoreIsUnavailable()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.QuickResync,
|
||||
isQResyncEnabled: false,
|
||||
serverHost: "imap.example.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.UidBased);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetermineSyncStrategy_ShouldFallbackToUid_WhenQuirkDisablesQresyncAndNoCondstore()
|
||||
{
|
||||
var sut = CreateSut();
|
||||
|
||||
var strategy = sut.DetermineSyncStrategy(
|
||||
ImapCapabilities.QuickResync,
|
||||
isQResyncEnabled: true,
|
||||
serverHost: "imap.163.com");
|
||||
|
||||
strategy.Should().Be(ImapSyncStrategy.UidBased);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateHighestKnownUid_ShouldUseMaxOfCurrentObservedAndUidNext()
|
||||
{
|
||||
var result = UnifiedImapSynchronizer.CalculateHighestKnownUid(
|
||||
currentHighestKnownUid: 100,
|
||||
uidNext: new MailKit.UniqueId(151),
|
||||
observedUids: new uint[] { 120, 140, 130 });
|
||||
|
||||
result.Should().Be(150);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateHighestKnownUid_ShouldNotRegress_WhenObservedUidsAreLower()
|
||||
{
|
||||
var result = UnifiedImapSynchronizer.CalculateHighestKnownUid(
|
||||
currentHighestKnownUid: 500,
|
||||
uidNext: null,
|
||||
observedUids: new uint[] { 110, 120, 130 });
|
||||
|
||||
result.Should().Be(500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateHighestKnownUid_ShouldUseUidNextMinusOne_WhenNoObservedUids()
|
||||
{
|
||||
var result = UnifiedImapSynchronizer.CalculateHighestKnownUid(
|
||||
currentHighestKnownUid: 0,
|
||||
uidNext: new MailKit.UniqueId(901),
|
||||
observedUids: null);
|
||||
|
||||
result.Should().Be(900);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRunUidReconcile_ShouldReturnTrue_WhenNeverReconciled()
|
||||
{
|
||||
var shouldRun = UnifiedImapSynchronizer.ShouldRunUidReconcile(
|
||||
lastUidReconcileUtc: null,
|
||||
utcNow: DateTime.UtcNow,
|
||||
reconcileInterval: TimeSpan.FromHours(12));
|
||||
|
||||
shouldRun.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRunUidReconcile_ShouldReturnFalse_WhenWithinInterval()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var shouldRun = UnifiedImapSynchronizer.ShouldRunUidReconcile(
|
||||
lastUidReconcileUtc: now.AddHours(-1),
|
||||
utcNow: now,
|
||||
reconcileInterval: TimeSpan.FromHours(12));
|
||||
|
||||
shouldRun.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRunUidReconcile_ShouldReturnTrue_WhenIntervalElapsed()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var shouldRun = UnifiedImapSynchronizer.ShouldRunUidReconcile(
|
||||
lastUidReconcileUtc: now.AddHours(-13),
|
||||
utcNow: now,
|
||||
reconcileInterval: TimeSpan.FromHours(12));
|
||||
|
||||
shouldRun.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessSummariesAsync_ShouldUseMetadataOnlyPackage()
|
||||
{
|
||||
var localFolder = new MailItemFolder
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MailAccountId = Guid.NewGuid(),
|
||||
FolderName = "Inbox",
|
||||
RemoteFolderId = "INBOX"
|
||||
};
|
||||
|
||||
var summaryMock = new Mock<IMessageSummary>();
|
||||
summaryMock.SetupGet(x => x.UniqueId).Returns(new UniqueId(42));
|
||||
summaryMock.SetupGet(x => x.Flags).Returns(MessageFlags.None);
|
||||
|
||||
var mailServiceMock = new Mock<IMailService>();
|
||||
mailServiceMock
|
||||
.Setup(x => x.GetExistingMailsAsync(localFolder.Id, It.IsAny<IEnumerable<UniqueId>>()))
|
||||
.ReturnsAsync(new List<MailCopy>());
|
||||
mailServiceMock
|
||||
.Setup(x => x.CreateMailAsync(localFolder.MailAccountId, It.IsAny<NewMailItemPackage>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var sut = new UnifiedImapSynchronizer(
|
||||
Mock.Of<IFolderService>(),
|
||||
mailServiceMock.Object,
|
||||
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
|
||||
|
||||
ImapMessageCreationPackage? capturedPackage = null;
|
||||
|
||||
var imapSynchronizerMock = new Mock<IImapSynchronizer>();
|
||||
imapSynchronizerMock
|
||||
.Setup(x => x.CreateNewMailPackagesAsync(It.IsAny<ImapMessageCreationPackage>(), localFolder, It.IsAny<CancellationToken>()))
|
||||
.Callback<ImapMessageCreationPackage, MailItemFolder, CancellationToken>((package, _, _) => capturedPackage = package)
|
||||
.ReturnsAsync(new List<NewMailItemPackage>
|
||||
{
|
||||
new(new MailCopy { Id = "mail-id" }, null, localFolder.RemoteFolderId, Array.Empty<AccountContact>())
|
||||
});
|
||||
|
||||
var processMethod = typeof(UnifiedImapSynchronizer).GetMethod("ProcessSummariesAsync", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
processMethod.Should().NotBeNull();
|
||||
|
||||
var task = (Task<List<string>>)processMethod!.Invoke(
|
||||
sut,
|
||||
[imapSynchronizerMock.Object, localFolder, new List<IMessageSummary> { summaryMock.Object }, CancellationToken.None])!;
|
||||
|
||||
var result = await task;
|
||||
|
||||
result.Should().ContainSingle().Which.Should().Be("mail-id");
|
||||
capturedPackage.Should().NotBeNull();
|
||||
capturedPackage!.MimeMessage.Should().BeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user