Queue chat bulk actions through SQL outbox
This commit is contained in:
@@ -654,7 +654,7 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkDeleteResolvesMissingChatUrlBeforeMaxAction()
|
||||
public async Task BulkDeleteQueuesMaxActionAndOutboxDeletesWithChatUrl()
|
||||
{
|
||||
var bridge = new ResolvingDeleteMaxBridgeClient();
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
@@ -675,15 +675,36 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
JsonOptions);
|
||||
await AssertStatusAsync(HttpStatusCode.NoContent, response);
|
||||
|
||||
Assert.Equal("missing-url-chat", bridge.ResolvedExternalId);
|
||||
Assert.Equal("https://max.test/chat/missing-url-chat", bridge.DeleteChatUrl);
|
||||
Assert.Null(bridge.ResolvedExternalId);
|
||||
Assert.Empty(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.NotNull(stored.DeletedAt);
|
||||
Assert.Equal(ChatPendingMaxAction.DeleteChat, stored.PendingMaxAction);
|
||||
Assert.NotNull(stored.PendingMaxActionRequestedAt);
|
||||
}
|
||||
|
||||
var chats = await client.GetFromJsonAsync<ChatDto[]>("/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<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]
|
||||
public async Task BulkDeleteRefreshesStaleChatUrlAfterMenuOpenFailure()
|
||||
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure()
|
||||
{
|
||||
const string externalId = "stale-menu-url-chat";
|
||||
const string staleUrl = "https://max.test/chat/wrong-chat";
|
||||
@@ -714,11 +735,144 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
JsonOptions);
|
||||
await AssertStatusAsync(HttpStatusCode.NoContent, 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);
|
||||
|
||||
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
|
||||
Assert.DoesNotContain(chats!, x => x.Id == chat.Id);
|
||||
|
||||
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/{externalId}", stored.WebUrl);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkClearHistoryQueuesMaxActionAndClearsLocalMessages()
|
||||
{
|
||||
const string externalId = "clear-history-chat";
|
||||
var bridge = new ResolvingDeleteMaxBridgeClient();
|
||||
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||
{
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<IMaxBridgeClient>();
|
||||
services.AddSingleton<IMaxBridgeClient>(bridge);
|
||||
});
|
||||
});
|
||||
using var client = factory.CreateClient();
|
||||
await LoginAsync(client);
|
||||
|
||||
var chat = await CreateDirectChatAsync(client, externalId, "Clear History");
|
||||
var messageResponse = await client.PostAsJsonAsync(
|
||||
$"/api/chats/{chat.Id}/messages",
|
||||
new SendMessageRequest("local message before clear"),
|
||||
JsonOptions);
|
||||
await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/chats/clear-history",
|
||||
new ChatBulkActionRequest([chat.Id]),
|
||||
JsonOptions);
|
||||
await AssertStatusAsync(HttpStatusCode.NoContent, response);
|
||||
|
||||
Assert.Empty(bridge.ClearChatUrls);
|
||||
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
|
||||
Assert.Empty(messages!);
|
||||
|
||||
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.DeletedAt);
|
||||
Assert.NotNull(stored.HistoryClearedAt);
|
||||
Assert.Null(stored.LastMessagePreview);
|
||||
Assert.Equal(ChatPendingMaxAction.ClearHistory, stored.PendingMaxAction);
|
||||
}
|
||||
|
||||
Assert.True(await ProcessOutboxAsync(factory) >= 1);
|
||||
Assert.Equal($"https://max.test/chat/{externalId}", bridge.ClearChatUrl);
|
||||
|
||||
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/{externalId}", stored.WebUrl);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncDoesNotRecreateLocallyDeletedChat()
|
||||
{
|
||||
const string externalId = "deleted-sync-chat";
|
||||
var sentAt = DateTimeOffset.UtcNow;
|
||||
var bridge = new ResolvingDeleteMaxBridgeClient
|
||||
{
|
||||
Updates =
|
||||
[
|
||||
new MaxChatUpdate(
|
||||
externalId,
|
||||
"Deleted Remote",
|
||||
null,
|
||||
sentAt,
|
||||
[
|
||||
new MaxMessageUpdate(
|
||||
"deleted-sync-message",
|
||||
"remote-user",
|
||||
"Deleted Remote",
|
||||
false,
|
||||
"remote hello after delete",
|
||||
sentAt)
|
||||
],
|
||||
"remote hello after delete",
|
||||
false,
|
||||
sentAt,
|
||||
sentAt.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"),
|
||||
$"https://max.test/chat/{externalId}")
|
||||
]
|
||||
};
|
||||
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, externalId, "Deleted Remote");
|
||||
var deleteResponse = await client.PostAsJsonAsync(
|
||||
"/api/chats/delete",
|
||||
new ChatBulkActionRequest([chat.Id]),
|
||||
JsonOptions);
|
||||
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse);
|
||||
|
||||
var sync = await client.PostAsync("/api/max/sync", null);
|
||||
await AssertStatusAsync(HttpStatusCode.OK, sync);
|
||||
|
||||
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
|
||||
Assert.DoesNotContain(chats!, x => x.ExternalId == externalId);
|
||||
|
||||
await using var scope = factory.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||
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();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -1506,6 +1660,9 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
public string? ResolvedExternalId { get; private set; }
|
||||
public string? DeleteChatUrl { get; private set; }
|
||||
public List<string?> DeleteChatUrls { get; } = [];
|
||||
public string? ClearChatUrl { get; private set; }
|
||||
public List<string?> ClearChatUrls { get; } = [];
|
||||
public IReadOnlyList<MaxChatUpdate> Updates { get; set; } = [];
|
||||
|
||||
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1529,7 +1686,7 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
|
||||
public Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(Array.Empty<MaxChatUpdate>());
|
||||
return Task.FromResult(Updates);
|
||||
}
|
||||
|
||||
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||
@@ -1550,7 +1707,16 @@ public sealed class ApiSmokeTests : IDisposable
|
||||
|
||||
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new MaxActionResult(true, null));
|
||||
ClearChatUrls.Add(chatUrl);
|
||||
ClearChatUrl = 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));
|
||||
}
|
||||
|
||||
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||
|
||||
Reference in New Issue
Block a user