Disable chat deletion

This commit is contained in:
sevenhill
2026-07-06 22:46:55 +03:00
parent 52ae43c25d
commit 183ec8d90f
11 changed files with 95 additions and 253 deletions
@@ -324,8 +324,7 @@ private fun LoginScreen(vm: QMaxViewModel) {
} }
private enum class ChatBulkUiAction { private enum class ChatBulkUiAction {
ClearHistory, ClearHistory
Delete
} }
private enum class MainTab { private enum class MainTab {
@@ -389,10 +388,6 @@ private fun ChatListScreen(vm: QMaxViewModel) {
onClearSelectedHistory = { onClearSelectedHistory = {
selectionMenuOpen = false selectionMenuOpen = false
pendingBulkAction = ChatBulkUiAction.ClearHistory pendingBulkAction = ChatBulkUiAction.ClearHistory
},
onDeleteSelectedChats = {
selectionMenuOpen = false
pendingBulkAction = ChatBulkUiAction.Delete
} }
) )
@@ -404,7 +399,6 @@ private fun ChatListScreen(vm: QMaxViewModel) {
Text( Text(
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить историю" ChatBulkUiAction.ClearHistory -> "Очистить историю"
ChatBulkUiAction.Delete -> "Удалить чаты"
} }
) )
}, },
@@ -412,7 +406,6 @@ private fun ChatListScreen(vm: QMaxViewModel) {
Text( Text(
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить историю в выбранных чатах: $selectedCount?" ChatBulkUiAction.ClearHistory -> "Очистить историю в выбранных чатах: $selectedCount?"
ChatBulkUiAction.Delete -> "Удалить выбранные чаты: $selectedCount?"
} }
) )
}, },
@@ -422,14 +415,12 @@ private fun ChatListScreen(vm: QMaxViewModel) {
pendingBulkAction = null pendingBulkAction = null
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> vm.clearSelectedChatHistories() ChatBulkUiAction.ClearHistory -> vm.clearSelectedChatHistories()
ChatBulkUiAction.Delete -> vm.deleteSelectedChats()
} }
} }
) { ) {
Text( Text(
when (action) { when (action) {
ChatBulkUiAction.ClearHistory -> "Очистить" ChatBulkUiAction.ClearHistory -> "Очистить"
ChatBulkUiAction.Delete -> "Удалить"
} }
) )
} }
@@ -616,8 +607,7 @@ private fun TelegramChatListContent(
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onToggleChatSelection: (String) -> Unit, onToggleChatSelection: (String) -> Unit,
onBeginChatSelection: (String) -> Unit, onBeginChatSelection: (String) -> Unit,
onClearSelectedHistory: () -> Unit, onClearSelectedHistory: () -> Unit
onDeleteSelectedChats: () -> Unit
) { ) {
val selectedCount = state.selectedChatIds.size val selectedCount = state.selectedChatIds.size
val listTab = activeTab != MainTab.Settings val listTab = activeTab != MainTab.Settings
@@ -646,8 +636,7 @@ private fun TelegramChatListContent(
menuOpen = selectionMenuOpen, menuOpen = selectionMenuOpen,
onMenuOpenChange = onSelectionMenuOpenChange, onMenuOpenChange = onSelectionMenuOpenChange,
onClearSelection = onClearSelection, onClearSelection = onClearSelection,
onClearHistory = onClearSelectedHistory, onClearHistory = onClearSelectedHistory
onDelete = onDeleteSelectedChats
) )
} else { } else {
TelegramLikeHeader( TelegramLikeHeader(
@@ -815,8 +804,7 @@ private fun ChatSelectionHeader(
menuOpen: Boolean, menuOpen: Boolean,
onMenuOpenChange: (Boolean) -> Unit, onMenuOpenChange: (Boolean) -> Unit,
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onClearHistory: () -> Unit, onClearHistory: () -> Unit
onDelete: () -> Unit
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -865,20 +853,6 @@ private fun ChatSelectionHeader(
onClearHistory() onClearHistory()
} }
) )
DropdownMenuItem(
text = { Text("Удалить") },
leadingIcon = {
Icon(
Icons.Filled.Delete,
contentDescription = null,
tint = Color(0xFFD84343)
)
},
onClick = {
onMenuOpenChange(false)
onDelete()
}
)
} }
} }
} }
@@ -136,20 +136,6 @@ class QMaxRepository(
} }
} }
suspend fun deleteChats(session: QMaxSession, chatIds: Collection<String>) {
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<MessageDto> { suspend fun searchMessages(session: QMaxSession, chatId: String, query: String): List<MessageDto> {
val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) } val messages = withFreshSession(session) { api.searchMessages(it.serverUrl, it.accessToken, chatId, query) }
messages.forEach { messageCache.upsertMessage(session, it) } messages.forEach { messageCache.upsertMessage(session, it) }
@@ -91,10 +91,6 @@ class QMaxApi {
postNoContent(sessionServerUrl, "/api/chats/clear-history", token, ChatBulkActionRequest(chatIds)) postNoContent(sessionServerUrl, "/api/chats/clear-history", token, ChatBulkActionRequest(chatIds))
} }
suspend fun deleteChats(sessionServerUrl: String, token: String, chatIds: List<String>) {
postNoContent(sessionServerUrl, "/api/chats/delete", token, ChatBulkActionRequest(chatIds))
}
suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List<MessageDto> { suspend fun searchMessages(sessionServerUrl: String, token: String, chatId: String, query: String): List<MessageDto> {
val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString()) val encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8.toString())
return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token) return get(sessionServerUrl, "/api/chats/$chatId/search?q=$encodedQuery", token)
@@ -433,35 +433,6 @@ class QMaxViewModel(private val repository: QMaxRepository) : ViewModel() {
refreshChats(showLoading = false) 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) { fun loadMessages(showLoading: Boolean = true, forceHydrate: Boolean = false) = launchLoading(showLoading) {
if (!showLoading && state.value.sendingMessage) { if (!showLoading && state.value.sendingMessage) {
return@launchLoading return@launchLoading
+2 -24
View File
@@ -367,33 +367,11 @@ public sealed class AdminController(
} }
[HttpDelete("api/chats/{id:guid}")] [HttpDelete("api/chats/{id:guid}")]
public async Task<IActionResult> DeleteChat(Guid id, [FromQuery] string? pairingCode, CancellationToken cancellationToken) public IActionResult DeleteChat(Guid id, [FromQuery] string? pairingCode)
{ {
if (!IsPairingCodeValid(pairingCode)) return Unauthorized(); if (!IsPairingCodeValid(pairingCode)) return Unauthorized();
var chat = await db.Chats return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client.");
.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();
} }
private async Task<AdminStatusDto> BuildStatusAsync(CancellationToken cancellationToken) private async Task<AdminStatusDto> BuildStatusAsync(CancellationToken cancellationToken)
+2 -25
View File
@@ -274,7 +274,7 @@ public sealed class ChatsController(
} }
[HttpPost("delete")] [HttpPost("delete")]
public async Task<IActionResult> DeleteChats(ChatBulkActionRequest request, CancellationToken cancellationToken) public IActionResult DeleteChats(ChatBulkActionRequest request)
{ {
var chatIds = request.ChatIds.Distinct().ToArray(); var chatIds = request.ChatIds.Distinct().ToArray();
if (chatIds.Length == 0) if (chatIds.Length == 0)
@@ -282,30 +282,7 @@ public sealed class ChatsController(
return BadRequest("chatIds are required."); return BadRequest("chatIds are required.");
} }
var chats = await LoadChatsWithAttachmentsAsync(chatIds, cancellationToken); return BadRequest("Chat deletion is disabled because MAX does not remove chats from the web client.");
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();
} }
[HttpGet("{chatId:guid}/search")] [HttpGet("{chatId:guid}/search")]
@@ -68,7 +68,9 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public Task<MaxActionResult> 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<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
@@ -69,14 +69,11 @@ public sealed class WorkerMaxBridgeClient(
?? new MaxActionResult(false, "Worker returned an empty clear history result."); ?? new MaxActionResult(false, "Worker returned an empty clear history result.");
} }
public async Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{ {
return await SendAsync<MaxActionResult>( return Task.FromResult(new MaxActionResult(
HttpMethod.Post, false,
"/chat/delete", "Chat deletion is disabled because MAX does not remove chats from the web client."));
new { externalChatId, chatUrl },
cancellationToken)
?? new MaxActionResult(false, "Worker returned an empty delete chat result.");
} }
public async Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) public async Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
+3 -4
View File
@@ -125,10 +125,9 @@ public sealed class MaxOutboxService(
chat, chat,
(externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken), (externalChatId, chatUrl) => maxBridge.ClearChatHistoryAsync(externalChatId, chatUrl, cancellationToken),
cancellationToken), cancellationToken),
ChatPendingMaxAction.DeleteChat => await ApplyMaxChatActionAsync( ChatPendingMaxAction.DeleteChat => new MaxActionResult(
chat, false,
(externalChatId, chatUrl) => maxBridge.DeleteChatAsync(externalChatId, chatUrl, cancellationToken), "Chat deletion is disabled because MAX does not remove chats from the web client."),
cancellationToken),
_ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.") _ => new MaxActionResult(false, $"Unsupported chat action: {pendingAction.Value}.")
}; };
} }
+73 -84
View File
@@ -655,7 +655,7 @@ public sealed class ApiSmokeTests : IDisposable
} }
[Fact] [Fact]
public async Task BulkDeleteQueuesMaxActionAndOutboxDeletesWithChatUrl() public async Task DeleteChatsIsDisabledAndKeepsChatVisible()
{ {
var bridge = new ResolvingDeleteMaxBridgeClient(); var bridge = new ResolvingDeleteMaxBridgeClient();
using var factory = _factory.WithWebHostBuilder(builder => using var factory = _factory.WithWebHostBuilder(builder =>
@@ -670,12 +670,13 @@ public sealed class ApiSmokeTests : IDisposable
using var client = factory.CreateClient(); using var client = factory.CreateClient();
await LoginAsync(client); 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( var response = await client.PostAsJsonAsync(
"/api/chats/delete", "/api/chats/delete",
new ChatBulkActionRequest([chat.Id]), new ChatBulkActionRequest([chat.Id]),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, response); await AssertStatusAsync(HttpStatusCode.BadRequest, response);
Assert.Null(bridge.ResolvedExternalId); Assert.Null(bridge.ResolvedExternalId);
Assert.Empty(bridge.DeleteChatUrls); Assert.Empty(bridge.DeleteChatUrls);
@@ -684,29 +685,17 @@ public sealed class ApiSmokeTests : IDisposable
{ {
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
Assert.NotNull(stored.DeletedAt); Assert.Null(stored.DeletedAt);
Assert.Equal(ChatPendingMaxAction.DeleteChat, stored.PendingMaxAction); Assert.Null(stored.PendingMaxAction);
Assert.NotNull(stored.PendingMaxActionRequestedAt); Assert.Null(stored.PendingMaxActionRequestedAt);
} }
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions); var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.DoesNotContain(chats!, x => x.Id == chat.Id); Assert.Contains(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<QMaxDbContext>();
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] [Fact]
public async Task BulkDeleteProcessesSeveralPendingChatActionsPerOutboxTick() public async Task DeleteChatsRejectsBulkRequestWithoutPendingActions()
{ {
var bridge = new ResolvingDeleteMaxBridgeClient(); var bridge = new ResolvingDeleteMaxBridgeClient();
using var factory = _factory.WithWebHostBuilder(builder => using var factory = _factory.WithWebHostBuilder(builder =>
@@ -721,24 +710,31 @@ public sealed class ApiSmokeTests : IDisposable
using var client = factory.CreateClient(); using var client = factory.CreateClient();
await LoginAsync(client); await LoginAsync(client);
var first = await CreateDirectChatAsync(client, "bulk-delete-one", "Bulk Delete One"); var suffix = Guid.NewGuid().ToString("N");
var second = await CreateDirectChatAsync(client, "bulk-delete-two", "Bulk Delete Two"); var first = await CreateDirectChatAsync(client, $"bulk-delete-one-{suffix}", "Bulk Delete One");
var third = await CreateDirectChatAsync(client, "bulk-delete-three", "Bulk Delete Three"); 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( var response = await client.PostAsJsonAsync(
"/api/chats/delete", "/api/chats/delete",
new ChatBulkActionRequest([first.Id, second.Id, third.Id]), new ChatBulkActionRequest([first.Id, second.Id, third.Id]),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, response); await AssertStatusAsync(HttpStatusCode.BadRequest, response);
Assert.True(await ProcessOutboxAsync(factory) >= 3); Assert.Empty(bridge.DeleteChatUrls);
Assert.Equal(3, bridge.DeleteChatUrls.Count);
await using var scope = factory.Services.CreateAsyncScope(); await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var pendingCount = await db.Chats.CountAsync(x => var storedChats = await db.Chats
(x.Id == first.Id || x.Id == second.Id || x.Id == third.Id) && .Where(x => x.Id == first.Id || x.Id == second.Id || x.Id == third.Id)
x.PendingMaxAction != null); .ToListAsync();
Assert.Equal(0, pendingCount); Assert.All(storedChats, stored =>
{
Assert.Null(stored.DeletedAt);
Assert.Null(stored.PendingMaxAction);
});
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.Equal(3, chats!.Count(x => x.Id == first.Id || x.Id == second.Id || x.Id == third.Id));
} }
[Fact] [Fact]
@@ -1029,10 +1025,11 @@ public sealed class ApiSmokeTests : IDisposable
} }
[Fact] [Fact]
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure() public async Task DeleteChatsDoesNotRefreshStaleChatUrlOrHideChat()
{ {
const string externalId = "stale-menu-url-chat"; var suffix = Guid.NewGuid().ToString("N");
const string staleUrl = "https://max.test/chat/wrong-chat"; var externalId = $"stale-menu-url-chat-{suffix}";
var staleUrl = $"https://max.test/chat/wrong-chat-{suffix}";
var bridge = new ResolvingDeleteMaxBridgeClient(staleUrl); var bridge = new ResolvingDeleteMaxBridgeClient(staleUrl);
using var factory = _factory.WithWebHostBuilder(builder => using var factory = _factory.WithWebHostBuilder(builder =>
{ {
@@ -1059,28 +1056,26 @@ public sealed class ApiSmokeTests : IDisposable
"/api/chats/delete", "/api/chats/delete",
new ChatBulkActionRequest([chat.Id]), new ChatBulkActionRequest([chat.Id]),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, response); await AssertStatusAsync(HttpStatusCode.BadRequest, response);
Assert.Empty(bridge.DeleteChatUrls); Assert.Empty(bridge.DeleteChatUrls);
Assert.Null(bridge.ResolvedExternalId);
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<ChatDto[]>("/api/chats", JsonOptions); var chats = await client.GetFromJsonAsync<ChatDto[]>("/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()) await using (var scope = factory.Services.CreateAsyncScope())
{ {
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
Assert.Null(stored.PendingMaxAction); Assert.Null(stored.PendingMaxAction);
Assert.Equal($"https://max.test/chat/{externalId}", stored.WebUrl); Assert.Null(stored.DeletedAt);
Assert.Equal(staleUrl, stored.WebUrl);
} }
} }
[Fact] [Fact]
public async Task OutboxSendsQueuedMessagesBeforePendingChatActions() public async Task OutboxSendsQueuedMessagesBeforePendingClearHistoryActions()
{ {
var bridge = new ResolvingDeleteMaxBridgeClient(); var bridge = new ResolvingDeleteMaxBridgeClient();
using var factory = _factory.WithWebHostBuilder(builder => using var factory = _factory.WithWebHostBuilder(builder =>
@@ -1095,14 +1090,14 @@ public sealed class ApiSmokeTests : IDisposable
using var client = factory.CreateClient(); using var client = factory.CreateClient();
await LoginAsync(client); await LoginAsync(client);
var deleteChat = await CreateDirectChatAsync(client, "delete-after-send-chat", "Delete After Send"); var clearChat = await CreateDirectChatAsync(client, "clear-after-send-chat", "Clear After Send");
var deleteResponse = await client.PostAsJsonAsync( var clearResponse = await client.PostAsJsonAsync(
"/api/chats/delete", "/api/chats/clear-history",
new ChatBulkActionRequest([deleteChat.Id]), new ChatBulkActionRequest([clearChat.Id]),
JsonOptions); 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( var messageResponse = await client.PostAsJsonAsync(
$"/api/chats/{sendChat.Id}/messages", $"/api/chats/{sendChat.Id}/messages",
new SendMessageRequest("send before chat action"), new SendMessageRequest("send before chat action"),
@@ -1110,13 +1105,13 @@ public sealed class ApiSmokeTests : IDisposable
await AssertStatusAsync(HttpStatusCode.OK, messageResponse); await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
Assert.Equal(2, await ProcessOutboxAsync(factory)); Assert.Equal(2, await ProcessOutboxAsync(factory));
Assert.Equal(["send:text", "delete"], bridge.OperationOrder); Assert.Equal(["send:text", "clear"], bridge.OperationOrder);
} }
[Fact] [Fact]
public async Task FailedPendingChatActionWaitsBeforeImmediateRetry() public async Task FailedPendingChatActionWaitsBeforeImmediateRetry()
{ {
var bridge = new ResolvingDeleteMaxBridgeClient { AlwaysFailDelete = true }; var bridge = new ResolvingDeleteMaxBridgeClient { AlwaysFailClear = true };
using var factory = _factory.WithWebHostBuilder(builder => using var factory = _factory.WithWebHostBuilder(builder =>
{ {
builder.ConfigureTestServices(services => builder.ConfigureTestServices(services =>
@@ -1129,26 +1124,26 @@ public sealed class ApiSmokeTests : IDisposable
using var client = factory.CreateClient(); using var client = factory.CreateClient();
await LoginAsync(client); await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, "retry-delay-delete-chat", "Retry Delay Delete"); var chat = await CreateDirectChatAsync(client, "retry-delay-clear-chat", "Retry Delay Clear");
var deleteResponse = await client.PostAsJsonAsync( var clearResponse = await client.PostAsJsonAsync(
"/api/chats/delete", "/api/chats/clear-history",
new ChatBulkActionRequest([chat.Id]), new ChatBulkActionRequest([chat.Id]),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); await AssertStatusAsync(HttpStatusCode.NoContent, clearResponse);
Assert.True(await ProcessOutboxAsync(factory) >= 1); Assert.True(await ProcessOutboxAsync(factory) >= 1);
Assert.Single(bridge.DeleteChatUrls); Assert.Single(bridge.ClearChatUrls);
await ProcessOutboxAsync(factory); await ProcessOutboxAsync(factory);
Assert.Single(bridge.DeleteChatUrls); Assert.Single(bridge.ClearChatUrls);
await using var scope = factory.Services.CreateAsyncScope(); await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); 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.Equal(1, stored.PendingMaxActionAttempts);
Assert.NotNull(stored.PendingMaxActionLastAttemptAt); Assert.NotNull(stored.PendingMaxActionLastAttemptAt);
Assert.Equal("Simulated MAX delete failure.", stored.PendingMaxActionError); Assert.Equal("Simulated MAX clear failure.", stored.PendingMaxActionError);
} }
[Fact] [Fact]
@@ -1208,9 +1203,10 @@ public sealed class ApiSmokeTests : IDisposable
} }
[Fact] [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 sentAt = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient var bridge = new ResolvingDeleteMaxBridgeClient
{ {
@@ -1223,7 +1219,7 @@ public sealed class ApiSmokeTests : IDisposable
sentAt, sentAt,
[ [
new MaxMessageUpdate( new MaxMessageUpdate(
"deleted-sync-message", $"deleted-sync-message-{suffix}",
"remote-user", "remote-user",
"Deleted Remote", "Deleted Remote",
false, false,
@@ -1254,33 +1250,30 @@ public sealed class ApiSmokeTests : IDisposable
"/api/chats/delete", "/api/chats/delete",
new ChatBulkActionRequest([chat.Id]), new ChatBulkActionRequest([chat.Id]),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); await AssertStatusAsync(HttpStatusCode.BadRequest, deleteResponse);
var sync = await client.PostAsync("/api/max/sync", null); var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync); await AssertStatusAsync(HttpStatusCode.OK, sync);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions); var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.DoesNotContain(chats!, x => x.ExternalId == externalId); Assert.Contains(chats!, x => x.ExternalId == externalId);
await using var scope = factory.Services.CreateAsyncScope(); await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.Include(x => x.Messages).SingleAsync(x => x.Id == chat.Id); var stored = await db.Chats.Include(x => x.Messages).SingleAsync(x => x.Id == chat.Id);
Assert.NotNull(stored.DeletedAt); Assert.Null(stored.DeletedAt);
Assert.Empty(stored.Messages); Assert.Null(stored.PendingMaxAction);
stored.PendingMaxAction = null; Assert.Contains(stored.Messages, x => x.ExternalId == $"deleted-sync-message-{suffix}");
stored.PendingMaxActionRequestedAt = null;
stored.PendingMaxActionLastAttemptAt = null;
stored.PendingMaxActionError = null;
await db.SaveChangesAsync();
} }
[Fact] [Fact]
public async Task SyncDoesNotRecreateDeletedChatWhenExternalIdChangesButWebUrlMatches() public async Task SyncUpdatesVisibleChatWhenExternalIdChangesButWebUrlMatches()
{ {
const string oldExternalId = "deleted-sync-old-external"; var suffix = Guid.NewGuid().ToString("N");
const string newExternalId = "deleted-sync-new-external"; var oldExternalId = $"deleted-sync-old-external-{suffix}";
const string title = "Deleted Tombstone"; var newExternalId = $"deleted-sync-new-external-{suffix}";
const string chatUrl = "https://max.test/chat/deleted-tombstone"; var title = $"Visible WebUrl Match {suffix}";
var chatUrl = $"https://max.test/chat/visible-web-url-match-{suffix}";
var sentAt = DateTimeOffset.UtcNow; var sentAt = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient var bridge = new ResolvingDeleteMaxBridgeClient
{ {
@@ -1324,25 +1317,21 @@ public sealed class ApiSmokeTests : IDisposable
"/api/chats/delete", "/api/chats/delete",
new ChatBulkActionRequest([chat.Id]), new ChatBulkActionRequest([chat.Id]),
JsonOptions); JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse); await AssertStatusAsync(HttpStatusCode.BadRequest, deleteResponse);
var sync = await client.PostAsync("/api/max/sync", null); var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync); await AssertStatusAsync(HttpStatusCode.OK, sync);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions); var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.DoesNotContain(chats!, x => x.Title == title); Assert.Contains(chats!, x => x.Title == title);
await using var verifyScope = factory.Services.CreateAsyncScope(); await using var verifyScope = factory.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<QMaxDbContext>(); var verifyDb = verifyScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChats = await verifyDb.Chats.Where(x => x.Title == title).ToListAsync(); var storedChats = await verifyDb.Chats.Where(x => x.Title == title).ToListAsync();
var tombstone = Assert.Single(storedChats); var visibleChat = Assert.Single(storedChats);
Assert.Equal(oldExternalId, tombstone.ExternalId); Assert.Equal(newExternalId, visibleChat.ExternalId);
Assert.NotNull(tombstone.DeletedAt); Assert.Null(visibleChat.DeletedAt);
tombstone.PendingMaxAction = null; Assert.Null(visibleChat.PendingMaxAction);
tombstone.PendingMaxActionRequestedAt = null;
tombstone.PendingMaxActionLastAttemptAt = null;
tombstone.PendingMaxActionError = null;
await verifyDb.SaveChangesAsync();
} }
[Fact] [Fact]
+4 -31
View File
@@ -393,16 +393,14 @@ app.post("/chat/clear-history", async (req, res) => {
app.post("/chat/delete", async (req, res) => { app.post("/chat/delete", async (req, res) => {
try { try {
const externalChatId = String(req.body?.externalChatId || "").trim(); const externalChatId = String(req.body?.externalChatId || "").trim();
const chatUrl = normalizeMaxChatUrl(req.body?.chatUrl);
if (!externalChatId) { if (!externalChatId) {
return res.json({ success: false, error: "externalChatId is required." }); return res.json({ success: false, error: "externalChatId is required." });
} }
const result = await withPageLock(async () => { res.json({
const p = await ensurePage("commands"); success: false,
return await deleteChatInMax(p, externalChatId, chatUrl); error: "Chat deletion is disabled because MAX does not remove chats from the web client."
}, "commands"); });
res.json(result);
} catch (error) { } catch (error) {
res.json({ success: false, error: String(error?.message || error) }); res.json({ success: false, error: String(error?.message || error) });
} }
@@ -5740,31 +5738,6 @@ async function clearChatHistoryInMax(p, externalChatId, chatUrl) {
"clear history"); "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) { async function applyChatMenuActionInMax(p, externalChatId, chatUrl, actionLabels, confirmLabels, actionName) {
const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels, actionName === "delete chat"); const menuOpened = await openSidebarChatMenu(p, externalChatId, chatUrl, actionLabels, actionName === "delete chat");
if (!menuOpened) { if (!menuOpened) {