using System; using System.Text.Json; using System.Threading.Tasks; using FluentAssertions; using Moq; using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Enums; using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Models.Accounts; using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Common; using Wino.Services; using Wino.Core.Tests.Helpers; using Xunit; namespace Wino.Core.Tests.Services; public class WinoAccountProfileServiceTests : IAsyncLifetime { private readonly Mock _apiClient = new(); private readonly Mock _storeManagementService = new(); private InMemoryDatabaseService _databaseService = null!; private WinoAccountProfileService _service = null!; public async Task InitializeAsync() { _databaseService = new InMemoryDatabaseService(); await _databaseService.InitializeAsync(); _service = new WinoAccountProfileService(_databaseService, _apiClient.Object, _storeManagementService.Object); } public async Task DisposeAsync() { await _databaseService.DisposeAsync(); } [Fact] public async Task LoginAsync_ShouldPersistSingleActiveAccount() { var authResult = CreateAuthResult("first@example.com"); _apiClient .Setup(x => x.LoginAsync("first@example.com", "pw", default)) .ReturnsAsync(WinoAccountApiResult.Success(authResult)); var result = await _service.LoginAsync("first@example.com", "pw"); result.IsSuccess.Should().BeTrue(); result.Account.Should().NotBeNull(); var persisted = await _databaseService.Connection.Table().ToListAsync(); persisted.Should().ContainSingle(); persisted[0].Email.Should().Be("first@example.com"); persisted[0].AccessToken.Should().Be(authResult.AccessToken); persisted[0].RefreshToken.Should().Be(authResult.RefreshToken); } [Fact] public async Task LoginAsync_ShouldReplaceExistingActiveAccount() { _apiClient .Setup(x => x.LoginAsync("first@example.com", "pw", default)) .ReturnsAsync(WinoAccountApiResult.Success(CreateAuthResult("first@example.com"))); _apiClient .Setup(x => x.LoginAsync("second@example.com", "pw", default)) .ReturnsAsync(WinoAccountApiResult.Success(CreateAuthResult("second@example.com"))); await _service.LoginAsync("first@example.com", "pw"); await _service.LoginAsync("second@example.com", "pw"); var persisted = await _databaseService.Connection.Table().ToListAsync(); persisted.Should().ContainSingle(); persisted[0].Email.Should().Be("second@example.com"); } [Fact] public async Task SignOutAsync_ShouldDeletePersistedAccount() { var authResult = CreateAuthResult("signout@example.com"); _apiClient .Setup(x => x.LoginAsync("signout@example.com", "pw", default)) .ReturnsAsync(WinoAccountApiResult.Success(authResult)); _apiClient .Setup(x => x.LogoutAsync(authResult.RefreshToken, default)) .ReturnsAsync(ApiEnvelope.Success(default)); await _service.LoginAsync("signout@example.com", "pw"); await _service.SignOutAsync(); var persisted = await _databaseService.Connection.Table().ToListAsync(); persisted.Should().BeEmpty(); } [Fact] public async Task LoginAsync_ShouldPreserveEnvelopeErrorMessage() { _apiClient .Setup(x => x.LoginAsync("first@example.com", "pw", default)) .ReturnsAsync(WinoAccountApiResult.Failure(ApiErrorCodes.InvalidCredentials, "Password does not match this account.")); var result = await _service.LoginAsync("first@example.com", "pw"); result.IsSuccess.Should().BeFalse(); result.ErrorCode.Should().Be(ApiErrorCodes.InvalidCredentials); result.ErrorMessage.Should().Be("Password does not match this account."); } [Fact] public async Task LoginAsync_ShouldPreserveErrorDetails() { var details = JsonSerializer.SerializeToElement(new EmailConfirmationRequiredDetailsDto( "/api/v1/auth/confirm-email/resend", "ticket", DateTimeOffset.UtcNow.AddMinutes(-2), DateTimeOffset.UtcNow.AddMinutes(8))); _apiClient .Setup(x => x.LoginAsync("first@example.com", "pw", default)) .ReturnsAsync(WinoAccountApiResult.Failure(ApiErrorCodes.EmailNotConfirmed, null, details)); var result = await _service.LoginAsync("first@example.com", "pw"); result.IsSuccess.Should().BeFalse(); result.ErrorDetails.Should().NotBeNull(); JsonSerializer.Deserialize(result.ErrorDetails!.Value.GetRawText())! .ResendConfirmationTicket.Should().Be("ticket"); } [Fact] public async Task RegisterAsync_ShouldNotPersistAccountUntilEmailIsConfirmed() { var authResult = CreateAuthResult("register@example.com"); _apiClient .Setup(x => x.RegisterAsync("register@example.com", "pw", default)) .ReturnsAsync(WinoAccountApiResult.Success(authResult)); var result = await _service.RegisterAsync("register@example.com", "pw"); result.IsSuccess.Should().BeTrue(); result.Account.Should().NotBeNull(); var persisted = await _databaseService.Connection.Table().ToListAsync(); persisted.Should().BeEmpty(); } [Fact] public async Task ForgotPasswordAsync_ShouldForwardApiResponse() { _apiClient .Setup(x => x.ForgotPasswordAsync("reset@example.com", default)) .ReturnsAsync(ApiEnvelope.Success(default)); var result = await _service.ForgotPasswordAsync("reset@example.com"); result.IsSuccess.Should().BeTrue(); } [Fact] public async Task RefreshProfileAsync_ShouldPersistLatestProfileData() { var authResult = CreateAuthResult("first@example.com"); _apiClient .Setup(x => x.LoginAsync("first@example.com", "pw", default)) .ReturnsAsync(WinoAccountApiResult.Success(authResult)); _apiClient .Setup(x => x.GetCurrentUserAsync(default)) .ReturnsAsync(ApiEnvelope.Success(new AuthUserDto( authResult.User.UserId, "updated@example.com", "Premium", authResult.User.HasPassword, authResult.User.HasGoogleLogin, authResult.User.HasFacebookLogin))); await _service.LoginAsync("first@example.com", "pw"); var result = await _service.RefreshProfileAsync(); result.IsSuccess.Should().BeTrue(); result.Account.Should().NotBeNull(); result.Account!.Email.Should().Be("updated@example.com"); result.Account.AccountStatus.Should().Be("Premium"); var persisted = await _databaseService.Connection.Table().FirstOrDefaultAsync(); persisted.Should().NotBeNull(); persisted!.Email.Should().Be("updated@example.com"); persisted.AccountStatus.Should().Be("Premium"); persisted.AccessToken.Should().Be(authResult.AccessToken); persisted.RefreshToken.Should().Be(authResult.RefreshToken); } [Fact] public async Task ProcessBillingCallbackAsync_ShouldConfirmPurchasedAddOn() { var authResult = CreateAuthResult("first@example.com"); var callbackUri = new Uri("wino://billing/success?productCode=UNLIMITED_ACCOUNTS"); _apiClient .Setup(x => x.LoginAsync("first@example.com", "pw", default)) .ReturnsAsync(WinoAccountApiResult.Success(authResult)); _apiClient .Setup(x => x.GetCurrentUserAsync(default)) .ReturnsAsync(ApiEnvelope.Success(authResult.User)); _storeManagementService .Setup(x => x.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS)) .ReturnsAsync(true); await _service.LoginAsync("first@example.com", "pw"); var processed = await _service.ProcessBillingCallbackAsync(callbackUri); processed.Should().BeTrue(); _apiClient.Verify(x => x.GetCurrentUserAsync(default), Times.AtLeastOnce); } [Fact] public async Task SummarizeAsync_ShouldReturnQuotaBackedResponse() { var authResult = CreateAuthResult("first@example.com"); _apiClient .Setup(x => x.LoginAsync("first@example.com", "pw", default)) .ReturnsAsync(WinoAccountApiResult.Success(authResult)); _apiClient .Setup(x => x.SummarizeAsync("

Hello

", "en", default)) .ReturnsAsync(ApiEnvelope.Success( new AiTextResultDto("

Summary

"), new QuotaInfoDto( "Active", DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(29), 1000, 4, 996, new AiPackProductInfoDto("AI_PACK", 1000, 4.99m, "USD", "month")))); await _service.LoginAsync("first@example.com", "pw"); var response = await _service.SummarizeAsync("

Hello

", "en"); response.IsSuccess.Should().BeTrue(); response.Result?.Html.Should().Be("

Summary

"); } private static AuthResultDto CreateAuthResult(string email) { return new AuthResultDto( new AuthUserDto(Guid.NewGuid(), email, "Active", true, false, false), "access-token", DateTimeOffset.UtcNow.AddMinutes(30), "refresh-token", DateTimeOffset.UtcNow.AddDays(30)); } }