diff --git a/server/QMax.Api/Controllers/ChatsController.cs b/server/QMax.Api/Controllers/ChatsController.cs index 0af3afd..1a9ca47 100644 --- a/server/QMax.Api/Controllers/ChatsController.cs +++ b/server/QMax.Api/Controllers/ChatsController.cs @@ -805,7 +805,8 @@ public sealed class ChatsController( private static bool IsChatOpenFailure(string? error) { - return error?.Contains("not found or did not open", StringComparison.OrdinalIgnoreCase) == true; + return error?.Contains("not found or did not open", StringComparison.OrdinalIgnoreCase) == true || + error?.Contains("menu was not opened", StringComparison.OrdinalIgnoreCase) == true; } private async Task> LoadChatsWithAttachmentsAsync(IReadOnlyCollection chatIds, CancellationToken cancellationToken) diff --git a/tests/QMax.Tests/ApiSmokeTests.cs b/tests/QMax.Tests/ApiSmokeTests.cs index 62aacb4..18e4351 100644 --- a/tests/QMax.Tests/ApiSmokeTests.cs +++ b/tests/QMax.Tests/ApiSmokeTests.cs @@ -682,6 +682,45 @@ public sealed class ApiSmokeTests : IDisposable Assert.DoesNotContain(chats!, x => x.Id == chat.Id); } + [Fact] + public async Task BulkDeleteRefreshesStaleChatUrlAfterMenuOpenFailure() + { + const string externalId = "stale-menu-url-chat"; + const string staleUrl = "https://max.test/chat/wrong-chat"; + var bridge = new ResolvingDeleteMaxBridgeClient(staleUrl); + using var factory = _factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.AddSingleton(bridge); + }); + }); + using var client = factory.CreateClient(); + await LoginAsync(client); + + var chat = await CreateDirectChatAsync(client, externalId, "Stale Menu Url"); + await using (var scope = factory.Services.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); + stored.WebUrl = staleUrl; + await db.SaveChangesAsync(); + } + + var response = await client.PostAsJsonAsync( + "/api/chats/delete", + new ChatBulkActionRequest([chat.Id]), + JsonOptions); + await AssertStatusAsync(HttpStatusCode.NoContent, response); + + Assert.Equal(externalId, bridge.ResolvedExternalId); + Assert.Equal([staleUrl, $"https://max.test/chat/{externalId}"], bridge.DeleteChatUrls); + + var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); + Assert.DoesNotContain(chats!, x => x.Id == chat.Id); + } + [Fact] public async Task FailedMaxTextSendMarksLocalMessageFailed() { @@ -1457,8 +1496,16 @@ public sealed class ApiSmokeTests : IDisposable private sealed class ResolvingDeleteMaxBridgeClient : IMaxBridgeClient { + private readonly string? _staleUrlToReject; + + public ResolvingDeleteMaxBridgeClient(string? staleUrlToReject = null) + { + _staleUrlToReject = staleUrlToReject; + } + public string? ResolvedExternalId { get; private set; } public string? DeleteChatUrl { get; private set; } + public List DeleteChatUrls { get; } = []; public Task GetStatusAsync(CancellationToken cancellationToken) { @@ -1508,7 +1555,13 @@ public sealed class ApiSmokeTests : IDisposable public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { + DeleteChatUrls.Add(chatUrl); DeleteChatUrl = chatUrl; + if (string.Equals(chatUrl, _staleUrlToReject, StringComparison.Ordinal)) + { + return Task.FromResult(new MaxActionResult(false, "MAX chat menu was not opened.")); + } + return Task.FromResult(string.IsNullOrWhiteSpace(chatUrl) ? new MaxActionResult(false, "MAX chat was not found or did not open.") : new MaxActionResult(true, null));