Fixing UI thread issues with bulk operations and request queue refactoring.
This commit is contained in:
@@ -27,10 +27,10 @@ public sealed class MailRequestStateTests
|
||||
request.RevertUIChanges();
|
||||
|
||||
mailCopy.IsRead.Should().BeFalse();
|
||||
recipient.Updated.Should().HaveCount(2);
|
||||
recipient.Updated[0].Source.Should().Be(EntityUpdateSource.ClientUpdated);
|
||||
recipient.Updated[1].Source.Should().Be(EntityUpdateSource.ClientReverted);
|
||||
recipient.Updated[1].UpdatedMail.IsRead.Should().BeFalse();
|
||||
recipient.StateUpdates.Should().HaveCount(2);
|
||||
recipient.StateUpdates[0].Source.Should().Be(EntityUpdateSource.ClientUpdated);
|
||||
recipient.StateUpdates[1].Source.Should().Be(EntityUpdateSource.ClientReverted);
|
||||
recipient.StateUpdates[1].UpdatedState.IsRead.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -55,10 +55,10 @@ public sealed class MailRequestStateTests
|
||||
request.RevertUIChanges();
|
||||
|
||||
mailCopy.IsFlagged.Should().BeFalse();
|
||||
recipient.Updated.Should().HaveCount(2);
|
||||
recipient.Updated[0].Source.Should().Be(EntityUpdateSource.ClientUpdated);
|
||||
recipient.Updated[1].Source.Should().Be(EntityUpdateSource.ClientReverted);
|
||||
recipient.Updated[1].UpdatedMail.IsFlagged.Should().BeFalse();
|
||||
recipient.StateUpdates.Should().HaveCount(2);
|
||||
recipient.StateUpdates[0].Source.Should().Be(EntityUpdateSource.ClientUpdated);
|
||||
recipient.StateUpdates[1].Source.Should().Be(EntityUpdateSource.ClientReverted);
|
||||
recipient.StateUpdates[1].UpdatedState.IsFlagged.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -76,10 +76,10 @@ public sealed class MailRequestStateTests
|
||||
IsFlagged = isFlagged
|
||||
};
|
||||
|
||||
internal sealed class MailRequestRecipient : IRecipient<MailUpdatedMessage>
|
||||
internal sealed class MailRequestRecipient : IRecipient<MailStateUpdatedMessage>
|
||||
{
|
||||
public List<MailUpdatedMessage> Updated { get; } = [];
|
||||
public List<MailStateUpdatedMessage> StateUpdates { get; } = [];
|
||||
|
||||
public void Receive(MailUpdatedMessage message) => Updated.Add(message);
|
||||
public void Receive(MailStateUpdatedMessage message) => StateUpdates.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using FluentAssertions;
|
||||
using MimeKit;
|
||||
using Moq;
|
||||
@@ -8,6 +9,7 @@ using Wino.Core.Domain.Extensions;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Tests.Helpers;
|
||||
using Wino.Messaging.UI;
|
||||
using Wino.Services;
|
||||
using Xunit;
|
||||
|
||||
@@ -229,6 +231,102 @@ public class MailThreadingTests : IAsyncLifetime
|
||||
mimeMessage.ReplyTo.Mailboxes.Should().ContainSingle(m => m.Address == "support@test.local");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyMailStateUpdatesAsync_ForBatchReadStateChange_SendsBulkMailUpdatedMessage()
|
||||
{
|
||||
var mail1 = new MailCopy
|
||||
{
|
||||
UniqueId = Guid.NewGuid(),
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
FolderId = _draftFolder.Id,
|
||||
IsRead = true,
|
||||
Subject = "First"
|
||||
};
|
||||
var mail2 = new MailCopy
|
||||
{
|
||||
UniqueId = Guid.NewGuid(),
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
FolderId = _draftFolder.Id,
|
||||
IsRead = true,
|
||||
Subject = "Second"
|
||||
};
|
||||
|
||||
await _databaseService.Connection.InsertAllAsync(new[] { mail1, mail2 }, typeof(MailCopy));
|
||||
|
||||
var recipient = new MailUpdateRecipient();
|
||||
WeakReferenceMessenger.Default.Register<MailUpdatedMessage>(recipient);
|
||||
WeakReferenceMessenger.Default.Register<BulkMailUpdatedMessage>(recipient);
|
||||
|
||||
try
|
||||
{
|
||||
await _mailService.ApplyMailStateUpdatesAsync(
|
||||
[
|
||||
new MailCopyStateUpdate(mail1.Id, IsRead: false),
|
||||
new MailCopyStateUpdate(mail2.Id, IsRead: false)
|
||||
]);
|
||||
|
||||
recipient.SingleUpdates.Should().BeEmpty();
|
||||
recipient.BulkUpdates.Should().ContainSingle();
|
||||
recipient.BulkUpdates[0].Source.Should().Be(EntityUpdateSource.Server);
|
||||
recipient.BulkUpdates[0].ChangedProperties.Should().Be(MailCopyChangeFlags.IsRead);
|
||||
recipient.BulkUpdates[0].UpdatedMails.Should().HaveCount(2);
|
||||
recipient.BulkUpdates[0].UpdatedMails.Should().OnlyContain(x => !x.IsRead);
|
||||
|
||||
(await _databaseService.Connection.FindAsync<MailCopy>(mail1.UniqueId))!.IsRead.Should().BeFalse();
|
||||
(await _databaseService.Connection.FindAsync<MailCopy>(mail2.UniqueId))!.IsRead.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
WeakReferenceMessenger.Default.Unregister<MailUpdatedMessage>(recipient);
|
||||
WeakReferenceMessenger.Default.Unregister<BulkMailUpdatedMessage>(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyMailStateUpdatesAsync_ForBatchMarkRead_SendsBulkMailReadStatusChanged()
|
||||
{
|
||||
var mail1 = new MailCopy
|
||||
{
|
||||
UniqueId = Guid.NewGuid(),
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
FolderId = _draftFolder.Id,
|
||||
IsRead = false,
|
||||
Subject = "First unread"
|
||||
};
|
||||
var mail2 = new MailCopy
|
||||
{
|
||||
UniqueId = Guid.NewGuid(),
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
FolderId = _draftFolder.Id,
|
||||
IsRead = false,
|
||||
Subject = "Second unread"
|
||||
};
|
||||
|
||||
await _databaseService.Connection.InsertAllAsync(new[] { mail1, mail2 }, typeof(MailCopy));
|
||||
|
||||
var recipient = new MailReadStatusRecipient();
|
||||
WeakReferenceMessenger.Default.Register<MailReadStatusChanged>(recipient);
|
||||
WeakReferenceMessenger.Default.Register<BulkMailReadStatusChanged>(recipient);
|
||||
|
||||
try
|
||||
{
|
||||
await _mailService.ApplyMailStateUpdatesAsync(
|
||||
[
|
||||
new MailCopyStateUpdate(mail1.Id, IsRead: true),
|
||||
new MailCopyStateUpdate(mail2.Id, IsRead: true)
|
||||
]);
|
||||
|
||||
recipient.SingleUpdates.Should().BeEmpty();
|
||||
recipient.BulkUpdates.Should().ContainSingle();
|
||||
recipient.BulkUpdates[0].UniqueIds.Should().BeEquivalentTo([mail1.UniqueId, mail2.UniqueId]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
WeakReferenceMessenger.Default.Unregister<MailReadStatusChanged>(recipient);
|
||||
WeakReferenceMessenger.Default.Unregister<BulkMailReadStatusChanged>(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
private static MimeMessage CreateReferencedMimeMessage(string subject, string? messageId = null)
|
||||
{
|
||||
var message = new MimeMessage();
|
||||
@@ -243,6 +341,24 @@ public class MailThreadingTests : IAsyncLifetime
|
||||
return message;
|
||||
}
|
||||
|
||||
internal sealed class MailUpdateRecipient : IRecipient<MailUpdatedMessage>, IRecipient<BulkMailUpdatedMessage>
|
||||
{
|
||||
public List<MailUpdatedMessage> SingleUpdates { get; } = [];
|
||||
public List<BulkMailUpdatedMessage> BulkUpdates { get; } = [];
|
||||
|
||||
public void Receive(MailUpdatedMessage message) => SingleUpdates.Add(message);
|
||||
public void Receive(BulkMailUpdatedMessage message) => BulkUpdates.Add(message);
|
||||
}
|
||||
|
||||
internal sealed class MailReadStatusRecipient : IRecipient<MailReadStatusChanged>, IRecipient<BulkMailReadStatusChanged>
|
||||
{
|
||||
public List<MailReadStatusChanged> SingleUpdates { get; } = [];
|
||||
public List<BulkMailReadStatusChanged> BulkUpdates { get; } = [];
|
||||
|
||||
public void Receive(MailReadStatusChanged message) => SingleUpdates.Add(message);
|
||||
public void Receive(BulkMailReadStatusChanged message) => BulkUpdates.Add(message);
|
||||
}
|
||||
|
||||
private static MailService BuildMailService(InMemoryDatabaseService db)
|
||||
{
|
||||
var signatureService = new Mock<ISignatureService>();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using FluentAssertions;
|
||||
using Wino.Core.Domain.Entities.Mail;
|
||||
using Wino.Core.Domain.Entities.Shared;
|
||||
using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Requests.Bundles;
|
||||
using Wino.Core.Requests.Mail;
|
||||
using Wino.Core.Synchronizers;
|
||||
using Wino.Messaging.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace Wino.Core.Tests.Synchronizers;
|
||||
|
||||
public sealed class BaseSynchronizerUiChangeTests
|
||||
{
|
||||
[Fact]
|
||||
public void ApplyOptimisticUiChanges_UsesBundleUiChangeRequest_ForBatchBundle()
|
||||
{
|
||||
var folderId = Guid.NewGuid();
|
||||
var account = new MailAccount { Id = Guid.NewGuid(), Name = "Test account" };
|
||||
var synchronizer = new TestSynchronizer(account);
|
||||
var recipient = new UiChangeRecipient();
|
||||
|
||||
var request1 = new MarkReadRequest(CreateMailCopy(folderId, isRead: false), IsRead: true);
|
||||
var request2 = new MarkReadRequest(CreateMailCopy(folderId, isRead: false), IsRead: true);
|
||||
var batchRequest = new BatchMarkReadRequest([request1, request2]);
|
||||
var bundle = new HttpRequestBundle<object>(new object(), batchRequest, request1);
|
||||
|
||||
WeakReferenceMessenger.Default.Register<MailStateUpdatedMessage>(recipient);
|
||||
WeakReferenceMessenger.Default.Register<BulkMailStateUpdatedMessage>(recipient);
|
||||
|
||||
try
|
||||
{
|
||||
synchronizer.ApplyUiChanges([bundle]);
|
||||
|
||||
recipient.SingleUpdates.Should().BeEmpty();
|
||||
recipient.BulkUpdates.Should().ContainSingle();
|
||||
recipient.BulkUpdates[0].UpdatedStates.Should().HaveCount(2);
|
||||
request1.Item.IsRead.Should().BeFalse();
|
||||
request2.Item.IsRead.Should().BeFalse();
|
||||
}
|
||||
finally
|
||||
{
|
||||
WeakReferenceMessenger.Default.Unregister<MailStateUpdatedMessage>(recipient);
|
||||
WeakReferenceMessenger.Default.Unregister<BulkMailStateUpdatedMessage>(recipient);
|
||||
}
|
||||
}
|
||||
|
||||
private static MailCopy CreateMailCopy(Guid folderId, bool isRead) =>
|
||||
new()
|
||||
{
|
||||
UniqueId = Guid.NewGuid(),
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
FolderId = folderId,
|
||||
IsRead = isRead
|
||||
};
|
||||
|
||||
private sealed class TestSynchronizer : BaseSynchronizer<object>
|
||||
{
|
||||
public TestSynchronizer(MailAccount account)
|
||||
: base(account, WeakReferenceMessenger.Default)
|
||||
{
|
||||
}
|
||||
|
||||
public void ApplyUiChanges(List<IRequestBundle<object>> bundles) => ApplyOptimisticUiChanges(bundles);
|
||||
|
||||
public override Task ExecuteNativeRequestsAsync(List<IRequestBundle<object>> batchedRequests, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
|
||||
internal sealed class UiChangeRecipient : IRecipient<MailStateUpdatedMessage>, IRecipient<BulkMailStateUpdatedMessage>
|
||||
{
|
||||
public List<MailStateUpdatedMessage> SingleUpdates { get; } = [];
|
||||
public List<BulkMailStateUpdatedMessage> BulkUpdates { get; } = [];
|
||||
|
||||
public void Receive(MailStateUpdatedMessage message) => SingleUpdates.Add(message);
|
||||
public void Receive(BulkMailStateUpdatedMessage message) => BulkUpdates.Add(message);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ using FluentAssertions;
|
||||
using MailKit;
|
||||
using MailKit.Net.Imap;
|
||||
using Moq;
|
||||
using System.Linq;
|
||||
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 Wino.Services.Extensions;
|
||||
using Xunit;
|
||||
using IMailService = Wino.Core.Domain.Interfaces.IMailService;
|
||||
|
||||
@@ -223,4 +225,61 @@ public class UnifiedImapSynchronizerTests
|
||||
capturedPackage.Should().NotBeNull();
|
||||
capturedPackage!.MimeMessage.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessSummariesAsync_ShouldBatchStateUpdates_ForExistingMailCopies()
|
||||
{
|
||||
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.Seen | MessageFlags.Flagged);
|
||||
|
||||
var existingMailCopy = new MailCopy
|
||||
{
|
||||
Id = MailkitClientExtensions.CreateUid(localFolder.Id, 42),
|
||||
IsRead = false,
|
||||
IsFlagged = false
|
||||
};
|
||||
|
||||
var mailServiceMock = new Mock<IMailService>();
|
||||
mailServiceMock
|
||||
.Setup(x => x.GetExistingMailsAsync(localFolder.Id, It.IsAny<IEnumerable<UniqueId>>()))
|
||||
.ReturnsAsync([existingMailCopy]);
|
||||
mailServiceMock
|
||||
.Setup(x => x.ApplyMailStateUpdatesAsync(It.IsAny<IEnumerable<MailCopyStateUpdate>>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sut = new UnifiedImapSynchronizer(
|
||||
Mock.Of<IFolderService>(),
|
||||
mailServiceMock.Object,
|
||||
Mock.Of<IImapSynchronizerErrorHandlerFactory>());
|
||||
|
||||
var imapSynchronizerMock = new Mock<IImapSynchronizer>();
|
||||
|
||||
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().BeEmpty();
|
||||
mailServiceMock.Verify(
|
||||
x => x.ApplyMailStateUpdatesAsync(It.Is<IEnumerable<MailCopyStateUpdate>>(updates =>
|
||||
updates.Count() == 1
|
||||
&& updates.First().MailCopyId == existingMailCopy.Id
|
||||
&& updates.First().IsRead == true
|
||||
&& updates.First().IsFlagged == true)),
|
||||
Times.Once);
|
||||
mailServiceMock.Verify(x => x.CreateMailAsync(It.IsAny<Guid>(), It.IsAny<NewMailItemPackage>()), Times.Never);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ using Wino.Core.Domain.Enums;
|
||||
using Wino.Core.Domain.Interfaces;
|
||||
using Wino.Core.Domain.Models.MailItem;
|
||||
using Wino.Core.Domain.Models.Synchronization;
|
||||
using Wino.Core.Requests.Mail;
|
||||
using Wino.Core.Requests.Folder;
|
||||
using Wino.Core.Synchronizers;
|
||||
using Xunit;
|
||||
@@ -49,6 +50,38 @@ public sealed class WinoSynchronizerMailRequestTests
|
||||
synchronizer.ExecuteNativeRequestsInvocationCount.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteRequests_should_dispatch_grouped_mark_read_requests_with_composite_grouping_key()
|
||||
{
|
||||
var synchronizer = new TestMailSynchronizer();
|
||||
var folderId = Guid.NewGuid();
|
||||
|
||||
synchronizer.QueueRequest(new MarkReadRequest(new MailCopy
|
||||
{
|
||||
UniqueId = Guid.NewGuid(),
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
FolderId = folderId
|
||||
}, IsRead: true));
|
||||
|
||||
synchronizer.QueueRequest(new MarkReadRequest(new MailCopy
|
||||
{
|
||||
UniqueId = Guid.NewGuid(),
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
FolderId = folderId
|
||||
}, IsRead: true));
|
||||
|
||||
var result = await synchronizer.SynchronizeMailsAsync(new MailSynchronizationOptions
|
||||
{
|
||||
AccountId = synchronizer.Account.Id,
|
||||
Type = MailSynchronizationType.ExecuteRequests
|
||||
});
|
||||
|
||||
result.CompletedState.Should().Be(SynchronizationCompletedState.Success);
|
||||
synchronizer.MarkReadInvocationCount.Should().Be(1);
|
||||
synchronizer.LastMarkReadBatchCount.Should().Be(2);
|
||||
synchronizer.ExecuteNativeRequestsInvocationCount.Should().Be(1);
|
||||
}
|
||||
|
||||
private sealed class TestMailSynchronizer
|
||||
: WinoSynchronizer<object, object, object>
|
||||
{
|
||||
@@ -60,6 +93,8 @@ public sealed class WinoSynchronizerMailRequestTests
|
||||
public override uint BatchModificationSize => 1;
|
||||
public override uint InitialMessageDownloadCountPerFolder => 0;
|
||||
public int CreateRootFolderInvocationCount { get; private set; }
|
||||
public int MarkReadInvocationCount { get; private set; }
|
||||
public int LastMarkReadBatchCount { get; private set; }
|
||||
public int ExecuteNativeRequestsInvocationCount { get; private set; }
|
||||
|
||||
public override List<IRequestBundle<object>> CreateRootFolder(CreateRootFolderRequest request)
|
||||
@@ -68,6 +103,13 @@ public sealed class WinoSynchronizerMailRequestTests
|
||||
return [new TestRequestBundle(new object(), request)];
|
||||
}
|
||||
|
||||
public override List<IRequestBundle<object>> MarkRead(BatchMarkReadRequest request)
|
||||
{
|
||||
MarkReadInvocationCount++;
|
||||
LastMarkReadBatchCount = request.Count;
|
||||
return [new TestRequestBundle(new object(), request[0])];
|
||||
}
|
||||
|
||||
public override Task ExecuteNativeRequestsAsync(List<IRequestBundle<object>> batchedRequests, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ExecuteNativeRequestsInvocationCount++;
|
||||
|
||||
Reference in New Issue
Block a user