diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt index 9f09bd9..da8bcd7 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/QMaxRepository.kt @@ -122,9 +122,6 @@ class QMaxRepository( return } - withFreshSession(session) { - api.clearChatHistories(it.serverUrl, it.accessToken, ids) - } messageCache.clearMessages(session, ids) val updatedChats = cachedChats(session).map { chat -> if (chat.id in ids) { @@ -134,6 +131,9 @@ class QMaxRepository( } } messageCache.saveChats(session, orderedChats(updatedChats)) + withFreshSession(session) { + api.clearChatHistories(it.serverUrl, it.accessToken, ids) + } } suspend fun deleteChats(session: QMaxSession, chatIds: Collection) { @@ -142,12 +142,12 @@ class QMaxRepository( return } - withFreshSession(session) { - api.deleteChats(it.serverUrl, it.accessToken, ids) - } messageCache.clearMessages(session, ids) val updatedChats = cachedChats(session).filterNot { it.id in ids } messageCache.saveChats(session, orderedChats(updatedChats)) + withFreshSession(session) { + api.deleteChats(it.serverUrl, it.accessToken, ids) + } } suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List { diff --git a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt index 2c31d0c..76dc453 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/ui/QMaxViewModel.kt @@ -401,7 +401,6 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { val ids = state.value.selectedChatIds if (ids.isEmpty()) return@launchLoading - repository.clearChatHistories(session, ids) val updatedChats = normalizeChats( state.value.chats.map { chat -> if (chat.id in ids) { @@ -422,6 +421,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { selectedChatIds = emptySet() ) persistCurrentChats() + repository.clearChatHistories(session, ids) refreshChats(showLoading = false) } @@ -430,7 +430,6 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { val ids = state.value.selectedChatIds if (ids.isEmpty()) return@launchLoading - repository.deleteChats(session, ids) val selectedChatId = state.value.selectedChat?.id val selectedChatDeleted = selectedChatId != null && selectedChatId in ids val updatedChats = normalizeChats(state.value.chats.filterNot { it.id in ids }) @@ -451,6 +450,7 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { realtime.joinChat(null) } persistCurrentChats() + repository.deleteChats(session, ids) refreshChats(showLoading = false) } diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs index 1a9ca47..7cf77f3 100644 --- a/server/QMax.Api/Controllers/ChatsController.cs +++ b/server/QMax.Api/Controllers/ChatsController.cs @@ -26,7 +26,9 @@ public sealed class ChatsController( [HttpGet] public async Task>> GetChats(CancellationToken cancellationToken) { - var chats = (await db.Chats.ToArrayAsync(cancellationToken)) + var chats = (await db.Chats + .Where(x => x.DeletedAt == null) + .ToArrayAsync(cancellationToken)) .OrderByDescending(x => x.IsPinned) .ThenByDescending(x => x.UnreadCount > 0) .ThenByDescending(x => x.LastMessageAt ?? x.UpdatedAt) @@ -59,7 +61,7 @@ public sealed class ChatsController( [HttpGet("{chatId:guid}/messages")] public async Task>> GetMessages(Guid chatId, int take = 80, DateTimeOffset? before = null, CancellationToken cancellationToken = default) { - var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); + var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken); if (chat is null) { return NotFound(); @@ -69,7 +71,7 @@ public sealed class ChatsController( { await syncService.SyncChatHistoryAsync(chat.ExternalId, chat.WebUrl, cancellationToken); db.ChangeTracker.Clear(); - chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); + chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken); if (chat is null) { return NotFound(); @@ -104,7 +106,7 @@ public sealed class ChatsController( [HttpPost("{chatId:guid}/read")] public async Task MarkRead(Guid chatId, CancellationToken cancellationToken) { - var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); + var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken); if (chat is null) { return NotFound(); @@ -136,19 +138,7 @@ public sealed class ChatsController( return NotFound(); } - foreach (var chat in chats) - { - var maxFailure = await TryApplyMaxChatActionAsync( - chat, - (externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken), - "clear history", - cancellationToken); - if (maxFailure is not null) - { - return maxFailure; - } - } - + var now = DateTimeOffset.UtcNow; var files = AttachmentFiles(chats).ToArray(); foreach (var chat in chats) { @@ -156,7 +146,12 @@ public sealed class ChatsController( chat.LastMessageAt = null; chat.LastMessagePreview = null; chat.UnreadCount = 0; - chat.UpdatedAt = DateTimeOffset.UtcNow; + chat.HistoryClearedAt = now; + chat.UpdatedAt = now; + if (chat.DeletedAt is null) + { + QueuePendingMaxAction(chat, ChatPendingMaxAction.ClearHistory, now); + } } await db.SaveChangesAsync(cancellationToken); @@ -180,21 +175,20 @@ public sealed class ChatsController( return NotFound(); } + var now = DateTimeOffset.UtcNow; + var files = AttachmentFiles(chats).ToArray(); foreach (var chat in chats) { - var maxFailure = await TryApplyMaxChatActionAsync( - chat, - (externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken), - "delete chat", - cancellationToken); - if (maxFailure is not null) - { - return maxFailure; - } + db.Messages.RemoveRange(chat.Messages); + chat.DeletedAt ??= now; + chat.HistoryClearedAt = now; + chat.LastMessageAt = null; + chat.LastMessagePreview = null; + chat.UnreadCount = 0; + chat.UpdatedAt = now; + QueuePendingMaxAction(chat, ChatPendingMaxAction.DeleteChat, now); } - var files = AttachmentFiles(chats).ToArray(); - db.Chats.RemoveRange(chats); await db.SaveChangesAsync(cancellationToken); DeleteFiles(files); await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); @@ -214,7 +208,7 @@ public sealed class ChatsController( return BadRequest("Search query is required."); } - var chatExists = await db.Chats.AnyAsync(x => x.Id == chatId, cancellationToken); + var chatExists = await db.Chats.AnyAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken); if (!chatExists) { return NotFound(); @@ -242,7 +236,7 @@ public sealed class ChatsController( { var chat = await db.Chats .AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); + .FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken); if (chat is null || string.IsNullOrWhiteSpace(chat.AvatarUrl)) { @@ -263,7 +257,7 @@ public sealed class ChatsController( { var chat = await db.Chats .AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == chatId, cancellationToken); + .FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken); if (chat is null) { @@ -285,7 +279,7 @@ public sealed class ChatsController( [HttpPost("{chatId:guid}/messages")] public async Task> SendMessage(Guid chatId, SendMessageRequest request, CancellationToken cancellationToken) { - var chat = await db.Chats.FindAsync([chatId], cancellationToken); + var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken); if (chat is null) { return NotFound(); @@ -527,7 +521,7 @@ public sealed class ChatsController( return NotFound(); } - var target = await db.Chats.FindAsync([request.TargetChatId], cancellationToken); + var target = await db.Chats.FirstOrDefaultAsync(x => x.Id == request.TargetChatId && x.DeletedAt == null, cancellationToken); if (target is null) { return NotFound(); @@ -600,7 +594,7 @@ public sealed class ChatsController( [FromForm] Guid? replyToMessageId, CancellationToken cancellationToken) { - var chat = await db.Chats.FindAsync([chatId], cancellationToken); + var chat = await db.Chats.FirstOrDefaultAsync(x => x.Id == chatId && x.DeletedAt == null, cancellationToken); if (chat is null) { return NotFound(); @@ -809,6 +803,15 @@ public sealed class ChatsController( error?.Contains("menu was not opened", StringComparison.OrdinalIgnoreCase) == true; } + private static void QueuePendingMaxAction(Chat chat, ChatPendingMaxAction action, DateTimeOffset requestedAt) + { + chat.PendingMaxAction = action; + chat.PendingMaxActionRequestedAt = requestedAt; + chat.PendingMaxActionLastAttemptAt = null; + chat.PendingMaxActionAttempts = 0; + chat.PendingMaxActionError = null; + } + private async Task> LoadChatsWithAttachmentsAsync(IReadOnlyCollection chatIds, CancellationToken cancellationToken) { return await db.Chats diff --git a/server/QMax.Api/Data/Entities/Chat.cs b/server/QMax.Api/Data/Entities/Chat.cs index c0970d4..c721c8c 100644 --- a/server/QMax.Api/Data/Entities/Chat.cs +++ b/server/QMax.Api/Data/Entities/Chat.cs @@ -14,6 +14,13 @@ public sealed class Chat public bool IsPinned { get; set; } public bool IsMuted { get; set; } public bool IsArchived { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public DateTimeOffset? HistoryClearedAt { get; set; } + public ChatPendingMaxAction? PendingMaxAction { get; set; } + public DateTimeOffset? PendingMaxActionRequestedAt { get; set; } + public DateTimeOffset? PendingMaxActionLastAttemptAt { get; set; } + public int PendingMaxActionAttempts { get; set; } + public string? PendingMaxActionError { get; set; } public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public ICollection Messages { get; set; } = new List(); diff --git a/server/QMax.Api/Data/Entities/Enums.cs b/server/QMax.Api/Data/Entities/Enums.cs index 47bd166..c478c0c 100644 --- a/server/QMax.Api/Data/Entities/Enums.cs +++ b/server/QMax.Api/Data/Entities/Enums.cs @@ -8,6 +8,12 @@ public enum ChatKind MaxDialog = 3 } +public enum ChatPendingMaxAction +{ + ClearHistory = 0, + DeleteChat = 1 +} + public enum MessageDirection { Incoming = 0, diff --git a/server/QMax.Api/Data/QMaxDbContext.cs b/server/QMax.Api/Data/QMaxDbContext.cs index 218b976..b12354c 100644 --- a/server/QMax.Api/Data/QMaxDbContext.cs +++ b/server/QMax.Api/Data/QMaxDbContext.cs @@ -19,6 +19,8 @@ public sealed class QMaxDbContext(DbContextOptions options) : DbC modelBuilder.Entity().HasIndex(x => x.PhoneNumber); modelBuilder.Entity().HasIndex(x => x.RefreshTokenHash).IsUnique(); modelBuilder.Entity().HasIndex(x => x.ExternalId).IsUnique(); + modelBuilder.Entity().HasIndex(x => x.DeletedAt); + modelBuilder.Entity().HasIndex(x => new { x.PendingMaxAction, x.PendingMaxActionRequestedAt }); modelBuilder.Entity().HasIndex(x => new { x.ChatId, x.SentAt }); modelBuilder.Entity().HasIndex(x => x.ExternalId); modelBuilder.Entity().HasIndex(x => x.StorageFileName).IsUnique(); @@ -30,6 +32,10 @@ public sealed class QMaxDbContext(DbContextOptions options) : DbC .Property(x => x.Kind) .HasConversion(); + modelBuilder.Entity() + .Property(x => x.PendingMaxAction) + .HasConversion(); + modelBuilder.Entity() .Property(x => x.Direction) .HasConversion(); diff --git a/server/QMax.Api/Program.cs b/server/QMax.Api/Program.cs index df28399..23bed3a 100644 --- a/server/QMax.Api/Program.cs +++ b/server/QMax.Api/Program.cs @@ -157,6 +157,41 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db) await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN WebUrl TEXT;"); } + if (!chatColumns.Contains("DeletedAt")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN DeletedAt TEXT;"); + } + + if (!chatColumns.Contains("HistoryClearedAt")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN HistoryClearedAt TEXT;"); + } + + if (!chatColumns.Contains("PendingMaxAction")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxAction TEXT;"); + } + + if (!chatColumns.Contains("PendingMaxActionRequestedAt")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionRequestedAt TEXT;"); + } + + if (!chatColumns.Contains("PendingMaxActionLastAttemptAt")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionLastAttemptAt TEXT;"); + } + + if (!chatColumns.Contains("PendingMaxActionAttempts")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionAttempts INTEGER NOT NULL DEFAULT 0;"); + } + + if (!chatColumns.Contains("PendingMaxActionError")) + { + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Chats ADD COLUMN PendingMaxActionError TEXT;"); + } + var attachmentColumns = new HashSet(StringComparer.OrdinalIgnoreCase); await using (var command = connection.CreateCommand()) { @@ -189,6 +224,16 @@ static async Task EnsureCompatibilitySchemaAsync(QMaxDbContext db) WHERE ExternalId IS NOT NULL AND ExternalId <> ''; """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE INDEX IF NOT EXISTS IX_Chats_DeletedAt + ON Chats (DeletedAt); + """); + + await db.Database.ExecuteSqlRawAsync(""" + CREATE INDEX IF NOT EXISTS IX_Chats_PendingMaxAction_PendingMaxActionRequestedAt + ON Chats (PendingMaxAction, PendingMaxActionRequestedAt); + """); + await db.Database.ExecuteSqlRawAsync(""" CREATE INDEX IF NOT EXISTS IX_Messages_ChatId_ExternalId ON Messages (ChatId, ExternalId); diff --git a/server/QMax.Api/Services/MaxBridgeSyncService.cs b/server/QMax.Api/Services/MaxBridgeSyncService.cs index fa319f0..8dd06a6 100644 --- a/server/QMax.Api/Services/MaxBridgeSyncService.cs +++ b/server/QMax.Api/Services/MaxBridgeSyncService.cs @@ -89,6 +89,12 @@ public sealed class MaxBridgeSyncService( } } + if (chat?.DeletedAt is not null) + { + trackedChatsByExternalId[update.ExternalId] = chat; + continue; + } + var chatWasCreated = chat is null; if (chat is null) { @@ -189,6 +195,11 @@ public sealed class MaxBridgeSyncService( continue; } + if (chat.HistoryClearedAt is { } historyClearedAt && incoming.SentAt <= historyClearedAt) + { + continue; + } + if (IsPresencePreview(incoming.Text) && (incoming.Attachments?.Count ?? 0) == 0) { continue; @@ -347,6 +358,11 @@ public sealed class MaxBridgeSyncService( var previousPreview = chat.LastMessagePreview; var previousPreviewAt = chat.LastMessageAt; var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt); + if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt) + { + return false; + } + var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal); var samePreviewAt = previousPreviewAt is not null && Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1; diff --git a/server/QMax.Api/Services/MaxOutboxService.cs b/server/QMax.Api/Services/MaxOutboxService.cs index 09cc7a5..d52d2b0 100644 --- a/server/QMax.Api/Services/MaxOutboxService.cs +++ b/server/QMax.Api/Services/MaxOutboxService.cs @@ -18,6 +18,8 @@ public sealed class MaxOutboxService( { public async Task ProcessOnceAsync(CancellationToken cancellationToken) { + var processed = await ProcessPendingChatActionsAsync(cancellationToken); + var candidates = await db.Messages .Include(message => message.Chat) .Include(message => message.Attachments) @@ -25,6 +27,8 @@ public sealed class MaxOutboxService( .Where(message => message.Direction == MessageDirection.Outgoing && message.DeliveryState == MessageDeliveryState.Sending && + message.Chat != null && + message.Chat.DeletedAt == null && message.DeletedAt == null) .ToListAsync(cancellationToken); @@ -34,7 +38,6 @@ public sealed class MaxOutboxService( .Take(10) .ToArray(); - var processed = 0; foreach (var message in pending) { cancellationToken.ThrowIfCancellationRequested(); @@ -47,6 +50,147 @@ public sealed class MaxOutboxService( return processed; } + private async Task ProcessPendingChatActionsAsync(CancellationToken cancellationToken) + { + var pending = await db.Chats + .Where(chat => chat.PendingMaxAction != null) + .ToListAsync(cancellationToken); + pending = pending + .OrderBy(chat => chat.PendingMaxActionRequestedAt ?? chat.UpdatedAt) + .ThenBy(chat => chat.Id) + .Take(10) + .ToList(); + + var processed = 0; + foreach (var chat in pending) + { + cancellationToken.ThrowIfCancellationRequested(); + if (await ProcessChatActionAsync(chat, cancellationToken)) + { + processed++; + } + } + + return processed; + } + + private async Task ProcessChatActionAsync(Chat chat, CancellationToken cancellationToken) + { + var pendingAction = chat.PendingMaxAction; + if (pendingAction is null) + { + return false; + } + + MaxActionResult result; + try + { + result = pendingAction.Value switch + { + ChatPendingMaxAction.ClearHistory => await ApplyMaxChatActionAsync( + chat, + (externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken), + cancellationToken), + ChatPendingMaxAction.DeleteChat => await ApplyMaxChatActionAsync( + chat, + (externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken), + cancellationToken), + _ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.") + }; + } + catch (Exception ex) + { + logger.LogWarning(ex, "MAX chat action {Action} failed for chat {ChatId}.", pendingAction.Value, chat.Id); + result = new MaxActionResult(false, ex.Message); + } + + var now = DateTimeOffset.UtcNow; + chat.PendingMaxActionLastAttemptAt = now; + if (result.Success) + { + chat.PendingMaxAction = null; + chat.PendingMaxActionRequestedAt = null; + chat.PendingMaxActionError = null; + } + else + { + chat.PendingMaxActionAttempts++; + chat.PendingMaxActionError = result.Error ?? $"MAX chat action {pendingAction.Value} failed."; + } + + chat.UpdatedAt = now; + await db.SaveChangesAsync(cancellationToken); + if (!result.Success) + { + logger.LogWarning( + "MAX chat action {Action} failed for chat {ChatId}: {Error}", + pendingAction.Value, + chat.Id, + chat.PendingMaxActionError); + } + + return true; + } + + private async Task ApplyMaxChatActionAsync( + Chat chat, + Func> action, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(chat.ExternalId)) + { + return new MaxActionResult(true, null); + } + + var chatUrl = chat.WebUrl; + if (string.IsNullOrWhiteSpace(chatUrl)) + { + chatUrl = await ResolveAndPersistChatUrlAsync(chat, cancellationToken); + } + + var result = await action(chat.ExternalId, chatUrl); + if (!result.Success && IsChatOpenFailure(result.Error)) + { + var previousUrl = chatUrl; + chatUrl = await ResolveAndPersistChatUrlAsync(chat, cancellationToken); + if (!string.IsNullOrWhiteSpace(chatUrl) && + !string.Equals(previousUrl, chatUrl, StringComparison.Ordinal)) + { + result = await action(chat.ExternalId, chatUrl); + } + } + + return result; + } + + private async Task ResolveAndPersistChatUrlAsync(Chat chat, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(chat.ExternalId)) + { + return chat.WebUrl; + } + + var result = await maxBridge.ResolveChatUrlAsync(chat.ExternalId, cancellationToken); + if (!result.Success || string.IsNullOrWhiteSpace(result.ChatUrl)) + { + return chat.WebUrl; + } + + var nextWebUrl = result.ChatUrl.Trim(); + if (!string.Equals(chat.WebUrl, nextWebUrl, StringComparison.Ordinal)) + { + chat.WebUrl = nextWebUrl; + } + + return chat.WebUrl; + } + + private static bool IsChatOpenFailure(string? error) + { + return error?.Contains("not found or did not open", StringComparison.OrdinalIgnoreCase) == true || + error?.Contains("menu was not opened", StringComparison.OrdinalIgnoreCase) == true; + } + private async Task ProcessMessageAsync(Message message, CancellationToken cancellationToken) { var chat = message.Chat; diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 18e4351..6a12404 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -654,7 +654,7 @@ public sealed class ApiSmokeTests : IDisposable } [Fact] - public async Task BulkDeleteResolvesMissingChatUrlBeforeMaxAction() + public async Task BulkDeleteQueuesMaxActionAndOutboxDeletesWithChatUrl() { var bridge = new ResolvingDeleteMaxBridgeClient(); using var factory = _factory.WithWebHostBuilder(builder => @@ -675,15 +675,36 @@ public sealed class ApiSmokeTests : IDisposable JsonOptions); await AssertStatusAsync(HttpStatusCode.NoContent, response); - Assert.Equal("missing-url-chat", bridge.ResolvedExternalId); - Assert.Equal("https://max.test/chat/missing-url-chat", bridge.DeleteChatUrl); + Assert.Null(bridge.ResolvedExternalId); + Assert.Empty(bridge.DeleteChatUrls); + + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); + Assert.NotNull(stored.DeletedAt); + Assert.Equal(ChatPendingMaxAction.DeleteChat, stored.PendingMaxAction); + Assert.NotNull(stored.PendingMaxActionRequestedAt); + } var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); Assert.DoesNotContain(chats!, x => x.Id == chat.Id); + + Assert.True(await ProcessOutboxAsync(factory) >= 1); + Assert.Equal("https://max.test/chat/missing-url-chat", bridge.DeleteChatUrl); + + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); + Assert.Null(stored.PendingMaxAction); + Assert.Equal("https://max.test/chat/missing-url-chat", stored.WebUrl); + Assert.NotNull(stored.DeletedAt); + } } [Fact] - public async Task BulkDeleteRefreshesStaleChatUrlAfterMenuOpenFailure() + public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure() { const string externalId = "stale-menu-url-chat"; const string staleUrl = "https://max.test/chat/wrong-chat"; @@ -714,11 +735,144 @@ public sealed class ApiSmokeTests : IDisposable JsonOptions); await AssertStatusAsync(HttpStatusCode.NoContent, response); + Assert.Empty(bridge.DeleteChatUrls); + + Assert.True(await ProcessOutboxAsync(factory) >= 1); Assert.Equal(externalId, bridge.ResolvedExternalId); Assert.Equal([staleUrl, $"https://max.test/chat/{externalId}"], bridge.DeleteChatUrls); var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); Assert.DoesNotContain(chats!, x => x.Id == chat.Id); + + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); + Assert.Null(stored.PendingMaxAction); + Assert.Equal($"https://max.test/chat/{externalId}", stored.WebUrl); + } + } + + [Fact] + public async Task BulkClearHistoryQueuesMaxActionAndClearsLocalMessages() + { + const string externalId = "clear-history-chat"; + var bridge = new ResolvingDeleteMaxBridgeClient(); + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(bridge); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var chat = await CreateDirectChatAsync(client, externalId, "Clear History"); + var messageResponse = await client.PostAsJsonAsync( + $"/api/chats/{chat.Id}/messages", + new SendMessageRequest("local message before clear"), + JsonOptions); + await AssertStatusAsync(HttpStatusCode.OK, messageResponse); + + var response = await client.PostAsJsonAsync( + "/api/chats/clear-history", + new ChatBulkActionRequest([chat.Id]), + JsonOptions); + await AssertStatusAsync(HttpStatusCode.NoContent, response); + + Assert.Empty(bridge.ClearChatUrls); + var messages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); + Assert.Empty(messages!); + + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); + Assert.Null(stored.DeletedAt); + Assert.NotNull(stored.HistoryClearedAt); + Assert.Null(stored.LastMessagePreview); + Assert.Equal(ChatPendingMaxAction.ClearHistory, stored.PendingMaxAction); + } + + Assert.True(await ProcessOutboxAsync(factory) >= 1); + Assert.Equal($"https://max.test/chat/{externalId}", bridge.ClearChatUrl); + + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); + Assert.Null(stored.PendingMaxAction); + Assert.Equal($"https://max.test/chat/{externalId}", stored.WebUrl); + } + } + + [Fact] + public async Task SyncDoesNotRecreateLocallyDeletedChat() + { + const string externalId = "deleted-sync-chat"; + var sentAt = DateTimeOffset.UtcNow; + var bridge = new ResolvingDeleteMaxBridgeClient + { + Updates = + [ + new MaxChatUpdate( + externalId, + "Deleted Remote", + null, + sentAt, + [ + new MaxMessageUpdate( + "deleted-sync-message", + "remote-user", + "Deleted Remote", + false, + "remote hello after delete", + sentAt) + ], + "remote hello after delete", + false, + sentAt, + sentAt.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"), + $"https://max.test/chat/{externalId}") + ] + }; + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.AddSingleton(bridge); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var chat = await CreateDirectChatAsync(client, externalId, "Deleted Remote"); + var deleteResponse = await client.PostAsJsonAsync( + "/api/chats/delete", + new ChatBulkActionRequest([chat.Id]), + JsonOptions); + await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); + + var sync = await client.PostAsync("/api/max/sync", null); + await AssertStatusAsync(HttpStatusCode.OK, sync); + + var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); + Assert.DoesNotContain(chats!, x => x.ExternalId == externalId); + + await using var scope = factory.Services.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Chats.Include(x => x.Messages).SingleAsync(x => x.Id == chat.Id); + Assert.NotNull(stored.DeletedAt); + Assert.Empty(stored.Messages); + stored.PendingMaxAction = null; + stored.PendingMaxActionRequestedAt = null; + stored.PendingMaxActionLastAttemptAt = null; + stored.PendingMaxActionError = null; + await db.SaveChangesAsync(); } [Fact] @@ -1506,6 +1660,9 @@ public sealed class ApiSmokeTests : IDisposable public string? ResolvedExternalId { get; private set; } public string? DeleteChatUrl { get; private set; } public List DeleteChatUrls { get; } = []; + public string? ClearChatUrl { get; private set; } + public List ClearChatUrls { get; } = []; + public IReadOnlyList Updates { get; set; } = []; public Task GetStatusAsync(CancellationToken cancellationToken) { @@ -1529,7 +1686,7 @@ public sealed class ApiSmokeTests : IDisposable public Task> FetchUpdatesAsync(CancellationToken cancellationToken) { - return Task.FromResult>(Array.Empty()); + return Task.FromResult(Updates); } public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) @@ -1550,7 +1707,16 @@ public sealed class ApiSmokeTests : IDisposable public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { - return Task.FromResult(new MaxActionResult(true, null)); + ClearChatUrls.Add(chatUrl); + ClearChatUrl = chatUrl; + if (string.Equals(chatUrl, _staleUrlToReject, StringComparison.Ordinal)) + { + return Task.FromResult(new MaxActionResult(false, "MAX chat menu was not opened.")); + } + + return Task.FromResult(string.IsNullOrWhiteSpace(chatUrl) + ? new MaxActionResult(false, "MAX chat was not found or did not open.") + : new MaxActionResult(true, null)); } public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)