diff --git a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt index 5d5a64e..2a59cc0 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/MainActivity.kt @@ -324,8 +324,7 @@ private fun LoginScreen(vm: QMaxViewModel) { } private enum class ChatBulkUiAction { - ClearHistory, - Delete + ClearHistory } private enum class MainTab { @@ -389,10 +388,6 @@ private fun ChatListScreen(vm: QMaxViewModel) { onClearSelectedHistory = { selectionMenuOpen = false pendingBulkAction = ChatBulkUiAction.ClearHistory - }, - onDeleteSelectedChats = { - selectionMenuOpen = false - pendingBulkAction = ChatBulkUiAction.Delete } ) @@ -404,7 +399,6 @@ private fun ChatListScreen(vm: QMaxViewModel) { Text( when (action) { ChatBulkUiAction.ClearHistory -> "Очистить историю" - ChatBulkUiAction.Delete -> "Удалить чаты" } ) }, @@ -412,7 +406,6 @@ private fun ChatListScreen(vm: QMaxViewModel) { Text( when (action) { ChatBulkUiAction.ClearHistory -> "Очистить историю в выбранных чатах: $selectedCount?" - ChatBulkUiAction.Delete -> "Удалить выбранные чаты: $selectedCount?" } ) }, @@ -422,14 +415,12 @@ private fun ChatListScreen(vm: QMaxViewModel) { pendingBulkAction = null when (action) { ChatBulkUiAction.ClearHistory -> vm.clearSelectedChatHistories() - ChatBulkUiAction.Delete -> vm.deleteSelectedChats() } } ) { Text( when (action) { ChatBulkUiAction.ClearHistory -> "Очистить" - ChatBulkUiAction.Delete -> "Удалить" } ) } @@ -616,8 +607,7 @@ private fun TelegramChatListContent( onClearSelection: () -> Unit, onToggleChatSelection: (String) -> Unit, onBeginChatSelection: (String) -> Unit, - onClearSelectedHistory: () -> Unit, - onDeleteSelectedChats: () -> Unit + onClearSelectedHistory: () -> Unit ) { val selectedCount = state.selectedChatIds.size val listTab = activeTab != MainTab.Settings @@ -646,8 +636,7 @@ private fun TelegramChatListContent( menuOpen = selectionMenuOpen, onMenuOpenChange = onSelectionMenuOpenChange, onClearSelection = onClearSelection, - onClearHistory = onClearSelectedHistory, - onDelete = onDeleteSelectedChats + onClearHistory = onClearSelectedHistory ) } else { TelegramLikeHeader( @@ -815,8 +804,7 @@ private fun ChatSelectionHeader( menuOpen: Boolean, onMenuOpenChange: (Boolean) -> Unit, onClearSelection: () -> Unit, - onClearHistory: () -> Unit, - onDelete: () -> Unit + onClearHistory: () -> Unit ) { Row( modifier = Modifier @@ -865,20 +853,6 @@ private fun ChatSelectionHeader( onClearHistory() } ) - DropdownMenuItem( - text = { Text("Удалить") }, - leadingIcon = { - Icon( - Icons.Filled.Delete, - contentDescription = null, - tint = Color(0xFFD84343) - ) - }, - onClick = { - onMenuOpenChange(false) - onDelete() - } - ) } } } 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 11452ed..1420823 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 @@ -136,20 +136,6 @@ class QMaxRepository( } } - suspend fun deleteChats(session: QMaxSession, chatIds: Collection) { - val ids = chatIds.filter(String::isNotBlank).distinct() - if (ids.isEmpty()) { - return - } - - 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 { val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) } messages.forEach { messageCache.upsertMessage(session, it) } diff --git a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt index a97334a..11e49f9 100644 --- a/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt +++ b/android/app/src/main/java/xyz/kusoft/qmax/core/network/QMaxApi.kt @@ -91,10 +91,6 @@ class QMaxApi { postNoContent(sessionServerUrl, "/api/chats/clear-history", token, ChatBulkActionRequest(chatIds)) } - suspend fun deleteChats(sessionServerUrl: String, token: String, chatIds: List) { - postNoContent(sessionServerUrl, "/api/chats/delete", token, ChatBulkActionRequest(chatIds)) - } - suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List { val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString()) return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token) 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 b4208f7..11017b9 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 @@ -433,35 +433,6 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() { refreshChats(showLoading = false) } - fun deleteSelectedChats() = launchLoading(false) { - val session = requireSession() - val ids = state.value.selectedChatIds - if (ids.isEmpty()) return@launchLoading - - val selectedChatId = state.value.selectedChat?.id - val selectedChatDeleted = selectedChatId != null && selectedChatId in ids - val updatedChats = normalizeChats(state.value.chats.filterNot { it.id in ids }) - state.value = state.value.copy( - selectedChat = if (selectedChatDeleted) null else state.value.selectedChat, - chats = updatedChats, - messages = if (selectedChatDeleted) emptyList() else state.value.messages, - chatPresenceText = if (selectedChatDeleted) null else state.value.chatPresenceText, - replyTarget = if (selectedChatDeleted) null else state.value.replyTarget, - editTarget = if (selectedChatDeleted) null else state.value.editTarget, - forwardTarget = if (selectedChatDeleted) null else state.value.forwardTarget, - composerText = if (selectedChatDeleted) "" else state.value.composerText, - selectedChatIds = emptySet() - ) - if (selectedChatDeleted) { - messagePollJob?.cancel() - presencePollJob?.cancel() - realtime.joinChat(null) - } - persistCurrentChats() - repository.deleteChats(session, ids) - refreshChats(showLoading = false) - } - fun loadMessages(showLoading: Boolean = true, forceHydrate: Boolean = false) = launchLoading(showLoading) { if (!showLoading && state.value.sendingMessage) { return@launchLoading diff --git a/server/QMax.Api/Controllers/AdminController.cs b/server/QMax.Api/Controllers/AdminController.cs index 2a4d2be..e80d59d 100644 --- a/server/QMax.Api/Controllers/AdminController.cs +++ b/server/QMax.Api/Controllers/AdminController.cs @@ -367,33 +367,11 @@ public sealed class AdminController( } [HttpDelete("api/chats/{id:guid}")] - public async Task DeleteChat(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken) + public IActionResult DeleteChat(Guid id, [FromQuery] string? pairingCode) { if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); - var chat = await db.Chats - .Include(x => x.Messages) - .ThenInclude(x => x.Attachments) - .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); - if (chat is null) - { - return NotFound(); - } - - var files = chat.Messages - .SelectMany(x => x.Attachments) - .Select(x => storage.GetPath(x.StorageFileName)) - .Where(System.IO.File.Exists) - .ToArray(); - - db.Chats.Remove(chat); - await db.SaveChangesAsync(cancellationToken); - foreach (var file in files) - { - System.IO.File.Delete(file); - } - - return NoContent(); + return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client."); } private async Task BuildStatusAsync(CancellationToken cancellationToken) diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs index 11fb0d3..530a4d7 100644 --- a/server/QMax.Api/Controllers/ChatsController.cs +++ b/server/QMax.Api/Controllers/ChatsController.cs @@ -274,7 +274,7 @@ public sealed class ChatsController( } [HttpPost("delete")] - public async Task DeleteChats(ChatBulkActionRequest request, CancellationToken cancellationToken) + public IActionResult DeleteChats(ChatBulkActionRequest request) { var chatIds = request.ChatIds.Distinct().ToArray(); if (chatIds.Length == 0) @@ -282,30 +282,7 @@ public sealed class ChatsController( return BadRequest("chatIds are required."); } - var chats = await LoadChatsWithAttachmentsAsync(chatIds, cancellationToken); - if (chats.Count != chatIds.Length) - { - return NotFound(); - } - - var now = DateTimeOffset.UtcNow; - var files = AttachmentFiles(chats).ToArray(); - foreach (var chat in chats) - { - 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); - } - - await db.SaveChangesAsync(cancellationToken); - DeleteFiles(files); - await hubContext.Clients.All.SendAsync("ChatListInvalidated", cancellationToken); - return NoContent(); + return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client."); } [HttpGet("{chatId:guid}/search")] diff --git a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs index ef33783..eb9965c 100644 --- a/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/MockMaxBridgeClient.cs @@ -68,7 +68,9 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { - return Task.FromResult(new MaxActionResult(true, null)); + return Task.FromResult(new MaxActionResult( + false, + "Chat deletion is disabled because MAX does not remove chats from the web client.")); } public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) diff --git a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs index 58e3289..b319677 100644 --- a/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs +++ b/server/QMax.Api/Infrastructure/Max/WorkerMaxBridgeClient.cs @@ -69,14 +69,11 @@ public sealed class WorkerMaxBridgeClient( ?? new MaxActionResult(false, "Worker returned an empty clear history result."); } - public async Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) + public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { - return await SendAsync( - HttpMethod.Post, - "/chat/delete", - new { externalChatId, chatUrl }, - cancellationToken) - ?? new MaxActionResult(false, "Worker returned an empty delete chat result."); + return Task.FromResult(new MaxActionResult( + false, + "Chat deletion is disabled because MAX does not remove chats from the web client.")); } public async Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) diff --git a/server/QMax.Api/Services/MaxOutboxService.cs b/server/QMax.Api/Services/MaxOutboxService.cs index 693ad16..116f3ec 100644 --- a/server/QMax.Api/Services/MaxOutboxService.cs +++ b/server/QMax.Api/Services/MaxOutboxService.cs @@ -125,10 +125,9 @@ public sealed class MaxOutboxService( chat, (externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken), cancellationToken), - ChatPendingMaxAction.DeleteChat => await ApplyMaxChatActionAsync( - chat, - (externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken), - cancellationToken), + ChatPendingMaxAction.DeleteChat => new MaxActionResult( + false, + "Chat deletion is disabled because MAX does not remove chats from the web client."), _ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.") }; } diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 0532f51..14e7ff6 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -655,7 +655,7 @@ public sealed class ApiSmokeTests : IDisposable } [Fact] - public async Task BulkDeleteQueuesMaxActionAndOutboxDeletesWithChatUrl() + public async Task DeleteChatsIsDisabledAndKeepsChatVisible() { var bridge = new ResolvingDeleteMaxBridgeClient(); using var factory = _factory.WithWebHostBuilder(builder => @@ -670,12 +670,13 @@ public sealed class ApiSmokeTests : IDisposable using var client = factory.CreateClient(); await LoginAsync(client); - var chat = await CreateDirectChatAsync(client, "missing-url-chat", "Missing Url"); + var externalId = $"missing-url-chat-{Guid.NewGuid():N}"; + var chat = await CreateDirectChatAsync(client, externalId, "Missing Url"); var response = await client.PostAsJsonAsync( "/api/chats/delete", new ChatBulkActionRequest([chat.Id]), JsonOptions); - await AssertStatusAsync(HttpStatusCode.NoContent, response); + await AssertStatusAsync(HttpStatusCode.BadRequest, response); Assert.Null(bridge.ResolvedExternalId); Assert.Empty(bridge.DeleteChatUrls); @@ -684,29 +685,17 @@ public sealed class ApiSmokeTests : IDisposable { 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); + Assert.Null(stored.DeletedAt); + Assert.Null(stored.PendingMaxAction); + Assert.Null(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); - } + Assert.Contains(chats!, x => x.Id == chat.Id); } [Fact] - public async Task BulkDeleteProcessesSeveralPendingChatActionsPerOutboxTick() + public async Task DeleteChatsRejectsBulkRequestWithoutPendingActions() { var bridge = new ResolvingDeleteMaxBridgeClient(); using var factory = _factory.WithWebHostBuilder(builder => @@ -721,24 +710,31 @@ public sealed class ApiSmokeTests : IDisposable using var client = factory.CreateClient(); await LoginAsync(client); - var first = await CreateDirectChatAsync(client, "bulk-delete-one", "Bulk Delete One"); - var second = await CreateDirectChatAsync(client, "bulk-delete-two", "Bulk Delete Two"); - var third = await CreateDirectChatAsync(client, "bulk-delete-three", "Bulk Delete Three"); + var suffix = Guid.NewGuid().ToString("N"); + var first = await CreateDirectChatAsync(client, $"bulk-delete-one-{suffix}", "Bulk Delete One"); + var second = await CreateDirectChatAsync(client, $"bulk-delete-two-{suffix}", "Bulk Delete Two"); + var third = await CreateDirectChatAsync(client, $"bulk-delete-three-{suffix}", "Bulk Delete Three"); var response = await client.PostAsJsonAsync( "/api/chats/delete", new ChatBulkActionRequest([first.Id, second.Id, third.Id]), JsonOptions); - await AssertStatusAsync(HttpStatusCode.NoContent, response); + await AssertStatusAsync(HttpStatusCode.BadRequest, response); - Assert.True(await ProcessOutboxAsync(factory) >= 3); - Assert.Equal(3, bridge.DeleteChatUrls.Count); + Assert.Empty(bridge.DeleteChatUrls); await using var scope = factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); - var pendingCount = await db.Chats.CountAsync(x => - (x.Id == first.Id || x.Id == second.Id || x.Id == third.Id) && - x.PendingMaxAction != null); - Assert.Equal(0, pendingCount); + var storedChats = await db.Chats + .Where(x => x.Id == first.Id || x.Id == second.Id || x.Id == third.Id) + .ToListAsync(); + Assert.All(storedChats, stored => + { + Assert.Null(stored.DeletedAt); + Assert.Null(stored.PendingMaxAction); + }); + + var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); + Assert.Equal(3, chats!.Count(x => x.Id == first.Id || x.Id == second.Id || x.Id == third.Id)); } [Fact] @@ -1029,10 +1025,11 @@ public sealed class ApiSmokeTests : IDisposable } [Fact] - public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure() + public async Task DeleteChatsDoesNotRefreshStaleChatUrlOrHideChat() { - const string externalId = "stale-menu-url-chat"; - const string staleUrl = "https://max.test/chat/wrong-chat"; + var suffix = Guid.NewGuid().ToString("N"); + var externalId = $"stale-menu-url-chat-{suffix}"; + var staleUrl = $"https://max.test/chat/wrong-chat-{suffix}"; var bridge = new ResolvingDeleteMaxBridgeClient(staleUrl); using var factory = _factory.WithWebHostBuilder(builder => { @@ -1059,28 +1056,26 @@ public sealed class ApiSmokeTests : IDisposable "/api/chats/delete", new ChatBulkActionRequest([chat.Id]), JsonOptions); - await AssertStatusAsync(HttpStatusCode.NoContent, response); + await AssertStatusAsync(HttpStatusCode.BadRequest, 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); + Assert.Null(bridge.ResolvedExternalId); var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); - Assert.DoesNotContain(chats!, x => x.Id == chat.Id); + Assert.Contains(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); + Assert.Null(stored.DeletedAt); + Assert.Equal(staleUrl, stored.WebUrl); } } [Fact] - public async Task OutboxSendsQueuedMessagesBeforePendingChatActions() + public async Task OutboxSendsQueuedMessagesBeforePendingClearHistoryActions() { var bridge = new ResolvingDeleteMaxBridgeClient(); using var factory = _factory.WithWebHostBuilder(builder => @@ -1095,14 +1090,14 @@ public sealed class ApiSmokeTests : IDisposable using var client = factory.CreateClient(); await LoginAsync(client); - var deleteChat = await CreateDirectChatAsync(client, "delete-after-send-chat", "Delete After Send"); - var deleteResponse = await client.PostAsJsonAsync( - "/api/chats/delete", - new ChatBulkActionRequest([deleteChat.Id]), + var clearChat = await CreateDirectChatAsync(client, "clear-after-send-chat", "Clear After Send"); + var clearResponse = await client.PostAsJsonAsync( + "/api/chats/clear-history", + new ChatBulkActionRequest([clearChat.Id]), JsonOptions); - await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); + await AssertStatusAsync(HttpStatusCode.NoContent, clearResponse); - var sendChat = await CreateDirectChatAsync(client, "send-before-delete-chat", "Send Before Delete"); + var sendChat = await CreateDirectChatAsync(client, "send-before-clear-chat", "Send Before Clear"); var messageResponse = await client.PostAsJsonAsync( $"/api/chats/{sendChat.Id}/messages", new SendMessageRequest("send before chat action"), @@ -1110,13 +1105,13 @@ public sealed class ApiSmokeTests : IDisposable await AssertStatusAsync(HttpStatusCode.OK, messageResponse); Assert.Equal(2, await ProcessOutboxAsync(factory)); - Assert.Equal(["send:text", "delete"], bridge.OperationOrder); + Assert.Equal(["send:text", "clear"], bridge.OperationOrder); } [Fact] public async Task FailedPendingChatActionWaitsBeforeImmediateRetry() { - var bridge = new ResolvingDeleteMaxBridgeClient { AlwaysFailDelete = true }; + var bridge = new ResolvingDeleteMaxBridgeClient { AlwaysFailClear = true }; using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => @@ -1129,26 +1124,26 @@ public sealed class ApiSmokeTests : IDisposable using var client = factory.CreateClient(); await LoginAsync(client); - var chat = await CreateDirectChatAsync(client, "retry-delay-delete-chat", "Retry Delay Delete"); - var deleteResponse = await client.PostAsJsonAsync( - "/api/chats/delete", + var chat = await CreateDirectChatAsync(client, "retry-delay-clear-chat", "Retry Delay Clear"); + var clearResponse = await client.PostAsJsonAsync( + "/api/chats/clear-history", new ChatBulkActionRequest([chat.Id]), JsonOptions); - await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); + await AssertStatusAsync(HttpStatusCode.NoContent, clearResponse); Assert.True(await ProcessOutboxAsync(factory) >= 1); - Assert.Single(bridge.DeleteChatUrls); + Assert.Single(bridge.ClearChatUrls); await ProcessOutboxAsync(factory); - Assert.Single(bridge.DeleteChatUrls); + Assert.Single(bridge.ClearChatUrls); await using var scope = factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); - Assert.Equal(ChatPendingMaxAction.DeleteChat, stored.PendingMaxAction); + Assert.Equal(ChatPendingMaxAction.ClearHistory, stored.PendingMaxAction); Assert.Equal(1, stored.PendingMaxActionAttempts); Assert.NotNull(stored.PendingMaxActionLastAttemptAt); - Assert.Equal("Simulated MAX delete failure.", stored.PendingMaxActionError); + Assert.Equal("Simulated MAX clear failure.", stored.PendingMaxActionError); } [Fact] @@ -1208,9 +1203,10 @@ public sealed class ApiSmokeTests : IDisposable } [Fact] - public async Task SyncDoesNotRecreateLocallyDeletedChat() + public async Task SyncKeepsChatVisibleAfterDisabledDeleteRequest() { - const string externalId = "deleted-sync-chat"; + var suffix = Guid.NewGuid().ToString("N"); + var externalId = $"deleted-sync-chat-{suffix}"; var sentAt = DateTimeOffset.UtcNow; var bridge = new ResolvingDeleteMaxBridgeClient { @@ -1223,7 +1219,7 @@ public sealed class ApiSmokeTests : IDisposable sentAt, [ new MaxMessageUpdate( - "deleted-sync-message", + $"deleted-sync-message-{suffix}", "remote-user", "Deleted Remote", false, @@ -1254,33 +1250,30 @@ public sealed class ApiSmokeTests : IDisposable "/api/chats/delete", new ChatBulkActionRequest([chat.Id]), JsonOptions); - await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); + await AssertStatusAsync(HttpStatusCode.BadRequest, 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); + Assert.Contains(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(); + Assert.Null(stored.DeletedAt); + Assert.Null(stored.PendingMaxAction); + Assert.Contains(stored.Messages, x => x.ExternalId == $"deleted-sync-message-{suffix}"); } [Fact] - public async Task SyncDoesNotRecreateDeletedChatWhenExternalIdChangesButWebUrlMatches() + public async Task SyncUpdatesVisibleChatWhenExternalIdChangesButWebUrlMatches() { - const string oldExternalId = "deleted-sync-old-external"; - const string newExternalId = "deleted-sync-new-external"; - const string title = "Deleted Tombstone"; - const string chatUrl = "https://max.test/chat/deleted-tombstone"; + var suffix = Guid.NewGuid().ToString("N"); + var oldExternalId = $"deleted-sync-old-external-{suffix}"; + var newExternalId = $"deleted-sync-new-external-{suffix}"; + var title = $"Visible WebUrl Match {suffix}"; + var chatUrl = $"https://max.test/chat/visible-web-url-match-{suffix}"; var sentAt = DateTimeOffset.UtcNow; var bridge = new ResolvingDeleteMaxBridgeClient { @@ -1324,25 +1317,21 @@ public sealed class ApiSmokeTests : IDisposable "/api/chats/delete", new ChatBulkActionRequest([chat.Id]), JsonOptions); - await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); + await AssertStatusAsync(HttpStatusCode.BadRequest, 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.Title == title); + Assert.Contains(chats!, x => x.Title == title); await using var verifyScope = factory.Services.CreateAsyncScope(); var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); var storedChats = await verifyDb.Chats.Where(x => x.Title == title).ToListAsync(); - var tombstone = Assert.Single(storedChats); - Assert.Equal(oldExternalId, tombstone.ExternalId); - Assert.NotNull(tombstone.DeletedAt); - tombstone.PendingMaxAction = null; - tombstone.PendingMaxActionRequestedAt = null; - tombstone.PendingMaxActionLastAttemptAt = null; - tombstone.PendingMaxActionError = null; - await verifyDb.SaveChangesAsync(); + var visibleChat = Assert.Single(storedChats); + Assert.Equal(newExternalId, visibleChat.ExternalId); + Assert.Null(visibleChat.DeletedAt); + Assert.Null(visibleChat.PendingMaxAction); } [Fact] diff --git a/worker/src/server.js b/worker/src/server.js index 598c4fa..079a8b8 100644 --- a/worker/src/server.js +++ b/worker/src/server.js @@ -393,16 +393,14 @@ app.post("/chat/clear-history", async (req, res) => { app.post("/chat/delete", async (req, res) => { try { const externalChatId = String(req.body?.externalChatId || "").trim(); - const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl); if (!externalChatId) { return res.json({ success: false, error: "externalChatId is required." }); } - const result = await withPageLock(async () => { - const p = await ensurePage("commands"); - return await deleteChatInMax(p, externalChatId, chatUrl); - }, "commands"); - res.json(result); + res.json({ + success: false, + error: "Chat deletion is disabled because MAX does not remove chats from the web client." + }); } catch (error) { res.json({ success: false, error: String(error?.message || error) }); } @@ -5740,31 +5738,6 @@ async function clearChatHistoryInMax(p, externalChatId, chatUrl) { "clear history"); } -async function deleteChatInMax(p, externalChatId, chatUrl) { - return await applyChatMenuActionInMax( - p, - externalChatId, - chatUrl, - [ - "\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", - "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", - "delete chat", - "delete conversation", - "remove chat" - ], - [ - "\u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0447\u0430\u0442", - "\u0443\u0434\u0430\u043b\u0438\u0442\u044c", - "\u0434\u0430, \u0443\u0434\u0430\u043b\u0438\u0442\u044c", - "\u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c", - "delete", - "confirm", - "yes", - "ok" - ], - "delete chat"); -} - async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) { const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels, actionName === "delete chat"); if (!menuOpened) {