Isolate MAX chat commands from outbox sends

This commit is contained in:
sevenhill
2026-07-05 22:48:05 +03:00
parent dcc74252c6
commit 0106130744
3 changed files with 143 additions and 11 deletions
+91
View File
@@ -753,6 +753,78 @@ public sealed class ApiSmokeTests : IDisposable
}
}
[Fact]
public async Task OutboxSendsQueuedMessagesBeforePendingChatActions()
{
var bridge = new ResolvingDeleteMaxBridgeClient();
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(bridge);
});
});
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]),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse);
var sendChat = await CreateDirectChatAsync(client, "send-before-delete-chat", "Send Before Delete");
var messageResponse = await client.PostAsJsonAsync(
$"/api/chats/{sendChat.Id}/messages",
new SendMessageRequest("send before chat action"),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
Assert.Equal(2, await ProcessOutboxAsync(factory));
Assert.Equal(["send:text", "delete"], bridge.OperationOrder);
}
[Fact]
public async Task FailedPendingChatActionWaitsBeforeImmediateRetry()
{
var bridge = new ResolvingDeleteMaxBridgeClient { AlwaysFailDelete = true };
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient>(bridge);
});
});
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",
new ChatBulkActionRequest([chat.Id]),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse);
Assert.True(await ProcessOutboxAsync(factory) >= 1);
Assert.Single(bridge.DeleteChatUrls);
await ProcessOutboxAsync(factory);
Assert.Single(bridge.DeleteChatUrls);
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.Equal(ChatPendingMaxAction.DeleteChat, stored.PendingMaxAction);
Assert.Equal(1, stored.PendingMaxActionAttempts);
Assert.NotNull(stored.PendingMaxActionLastAttemptAt);
Assert.Equal("Simulated MAX delete failure.", stored.PendingMaxActionError);
}
[Fact]
public async Task BulkClearHistoryQueuesMaxActionAndClearsLocalMessages()
{
@@ -1662,6 +1734,10 @@ public sealed class ApiSmokeTests : IDisposable
public List<string?> DeleteChatUrls { get; } = [];
public string? ClearChatUrl { get; private set; }
public List<string?> ClearChatUrls { get; } = [];
public List<string> OperationOrder { get; } = [];
public List<string> SentTexts { get; } = [];
public bool AlwaysFailDelete { get; set; }
public bool AlwaysFailClear { get; set; }
public IReadOnlyList<MaxChatUpdate> Updates { get; set; } = [];
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
@@ -1707,8 +1783,14 @@ public sealed class ApiSmokeTests : IDisposable
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
OperationOrder.Add("clear");
ClearChatUrls.Add(chatUrl);
ClearChatUrl = chatUrl;
if (AlwaysFailClear)
{
return Task.FromResult(new MaxActionResult(false, "Simulated MAX clear failure."));
}
if (string.Equals(chatUrl, _staleUrlToReject, StringComparison.Ordinal))
{
return Task.FromResult(new MaxActionResult(false, "MAX chat menu was not opened."));
@@ -1721,8 +1803,14 @@ public sealed class ApiSmokeTests : IDisposable
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
OperationOrder.Add("delete");
DeleteChatUrls.Add(chatUrl);
DeleteChatUrl = chatUrl;
if (AlwaysFailDelete)
{
return Task.FromResult(new MaxActionResult(false, "Simulated MAX delete failure."));
}
if (string.Equals(chatUrl, _staleUrlToReject, StringComparison.Ordinal))
{
return Task.FromResult(new MaxActionResult(false, "MAX chat menu was not opened."));
@@ -1735,11 +1823,14 @@ public sealed class ApiSmokeTests : IDisposable
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
OperationOrder.Add("send:text");
SentTexts.Add(text);
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
}
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken)
{
OperationOrder.Add("send:attachment");
return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null));
}