using System.Net; using System.Net.Http; using System.Reflection; using FluentAssertions; using Google.Apis.Requests; using Moq; using Wino.Core.Domain.Entities.Mail; using Wino.Core.Domain.Exceptions; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Synchronization; using Wino.Core.Integration.Processors; using Wino.Core.Requests.Bundles; using Wino.Core.Requests.Mail; using Wino.Core.Synchronizers.Mail; using Xunit; namespace Wino.Core.Tests.Synchronizers; public sealed class GmailSynchronizerRequestSuccessTests { [Fact] public async Task ProcessSingleNativeRequestResponseAsync_BatchMarkReadRequest_PersistsLocalReadStateForEachMail() { var changeProcessor = new Mock(MockBehavior.Strict); changeProcessor .Setup(x => x.ChangeMailReadStatusAsync("mail-1", true)) .Returns(Task.CompletedTask); changeProcessor .Setup(x => x.ChangeMailReadStatusAsync("mail-2", true)) .Returns(Task.CompletedTask); var synchronizer = CreateSynchronizer(changeProcessor.Object); var request = new BatchMarkReadRequest( [ new MarkReadRequest(CreateMailCopy("mail-1"), IsRead: true), new MarkReadRequest(CreateMailCopy("mail-2"), IsRead: true) ]); var bundle = new HttpRequestBundle(Mock.Of(), request); using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }; await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response); changeProcessor.Verify(x => x.ChangeMailReadStatusAsync("mail-1", true), Times.Once); changeProcessor.Verify(x => x.ChangeMailReadStatusAsync("mail-2", true), Times.Once); } [Fact] public async Task ProcessSingleNativeRequestResponseAsync_BatchChangeFlagRequest_PersistsLocalFlagStateForEachMail() { var changeProcessor = new Mock(MockBehavior.Strict); changeProcessor .Setup(x => x.ChangeFlagStatusAsync("mail-1", true)) .Returns(Task.CompletedTask); changeProcessor .Setup(x => x.ChangeFlagStatusAsync("mail-2", true)) .Returns(Task.CompletedTask); var synchronizer = CreateSynchronizer(changeProcessor.Object); var request = new BatchChangeFlagRequest( [ new ChangeFlagRequest(CreateMailCopy("mail-1"), IsFlagged: true), new ChangeFlagRequest(CreateMailCopy("mail-2"), IsFlagged: true) ]); var bundle = new HttpRequestBundle(Mock.Of(), request); using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }; await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response); changeProcessor.Verify(x => x.ChangeFlagStatusAsync("mail-1", true), Times.Once); changeProcessor.Verify(x => x.ChangeFlagStatusAsync("mail-2", true), Times.Once); } [Fact] public async Task ProcessSingleNativeRequestResponseAsync_HandledRequestError_DoesNotPersistLocalReadState() { var changeProcessor = new Mock(MockBehavior.Strict); var errorFactory = new Mock(MockBehavior.Strict); errorFactory .Setup(x => x.HandleErrorAsync(It.IsAny())) .ReturnsAsync(true); var synchronizer = CreateSynchronizer(changeProcessor.Object, errorFactory.Object); var request = new BatchMarkReadRequest( [ new MarkReadRequest(CreateMailCopy("mail-1"), IsRead: true) ]); var bundle = new HttpRequestBundle(Mock.Of(), request); using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }; var error = new RequestError { Code = 429, Message = "rate limit" }; await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response, error); changeProcessor.Verify(x => x.ChangeMailReadStatusAsync(It.IsAny(), It.IsAny()), Times.Never); errorFactory.Verify(x => x.HandleErrorAsync(It.IsAny()), Times.Once); } [Fact] public async Task ProcessSingleNativeRequestResponseAsync_HandledRequestError_RevertsOptimisticReadState() { var changeProcessor = new Mock(MockBehavior.Strict); var errorFactory = new Mock(MockBehavior.Strict); errorFactory .Setup(x => x.HandleErrorAsync(It.IsAny())) .ReturnsAsync(true); var mail = CreateMailCopy("mail-1"); var request = new BatchMarkReadRequest( [ new MarkReadRequest(mail, IsRead: true) ]); request.ApplyUIChanges(); var synchronizer = CreateSynchronizer(changeProcessor.Object, errorFactory.Object); var bundle = new HttpRequestBundle(Mock.Of(), request); using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }; var error = new RequestError { Code = 429, Message = "rate limit" }; await InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response, error); mail.IsRead.Should().BeFalse(); changeProcessor.Verify(x => x.ChangeMailReadStatusAsync(It.IsAny(), It.IsAny()), Times.Never); errorFactory.Verify(x => x.HandleErrorAsync(It.IsAny()), Times.Once); } [Fact] public async Task ProcessSingleNativeRequestResponseAsync_Generic404Error_DoesNotClassifyAsEntityNotFound() { var changeProcessor = new Mock(MockBehavior.Strict); SynchronizerErrorContext? capturedContext = null; var errorFactory = new Mock(MockBehavior.Strict); errorFactory .Setup(x => x.HandleErrorAsync(It.IsAny())) .Callback(context => capturedContext = context) .ReturnsAsync(false); var synchronizer = CreateSynchronizer(changeProcessor.Object, errorFactory.Object); var request = new DeleteRequest(CreateMailCopy("mail-1")); var bundle = new HttpRequestBundle(Mock.Of(), request, request); using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }; var error = new RequestError { Code = 404, Message = "Not Found" }; var act = () => InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response, error); await act.Should().ThrowAsync(); capturedContext.Should().NotBeNull(); capturedContext!.IsEntityNotFound.Should().BeFalse(); } [Fact] public async Task ProcessSingleNativeRequestResponseAsync_Entity404Error_ClassifiesAsEntityNotFound() { var changeProcessor = new Mock(MockBehavior.Strict); SynchronizerErrorContext? capturedContext = null; var errorFactory = new Mock(MockBehavior.Strict); errorFactory .Setup(x => x.HandleErrorAsync(It.IsAny())) .Callback(context => capturedContext = context) .ReturnsAsync(false); var synchronizer = CreateSynchronizer(changeProcessor.Object, errorFactory.Object); var request = new DeleteRequest(CreateMailCopy("mail-1")); var bundle = new HttpRequestBundle(Mock.Of(), request, request); using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(string.Empty) }; var error = new RequestError { Code = 404, Message = "Requested entity was not found." }; var act = () => InvokeProcessSingleNativeRequestResponseAsync(synchronizer, bundle, response, error); await act.Should().ThrowAsync(); capturedContext.Should().NotBeNull(); capturedContext!.IsEntityNotFound.Should().BeTrue(); } private static GmailSynchronizer CreateSynchronizer( IGmailChangeProcessor changeProcessor, IGmailSynchronizerErrorHandlerFactory? errorFactory = null) { var account = new MailAccount { Id = Guid.NewGuid(), Name = "Gmail", Address = "user@example.com" }; var authenticator = new Mock(MockBehavior.Loose); return new GmailSynchronizer(account, authenticator.Object, changeProcessor, errorFactory ?? Mock.Of()); } private static MailCopy CreateMailCopy(string id) => new() { UniqueId = Guid.NewGuid(), Id = id, FolderId = Guid.NewGuid(), IsRead = false, IsFlagged = false }; private static async Task InvokeProcessSingleNativeRequestResponseAsync( GmailSynchronizer synchronizer, HttpRequestBundle bundle, HttpResponseMessage response, RequestError? error = null) { var method = typeof(GmailSynchronizer).GetMethod( "ProcessSingleNativeRequestResponseAsync", BindingFlags.Instance | BindingFlags.NonPublic); method.Should().NotBeNull(); var task = method!.Invoke(synchronizer, [bundle, error, response, CancellationToken.None]) as Task; task.Should().NotBeNull(); await task!; } }