Disable chat deletion
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}.")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user