Refactored impa synchronization.

This commit is contained in:
Burak Kaan Köse
2026-02-14 12:52:17 +01:00
parent 4a0dcd2899
commit 744145be06
26 changed files with 1492 additions and 1243 deletions
@@ -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();
}
}