Fix async chat actions and channel sync

This commit is contained in:
sevenhill
2026-07-06 21:38:35 +03:00
parent 84bf7e96c9
commit 7aae5acda3
6 changed files with 1220 additions and 89 deletions
+168 -4
View File
@@ -705,6 +705,42 @@ public sealed class ApiSmokeTests : IDisposable
}
}
[Fact]
public async Task BulkDeleteProcessesSeveralPendingChatActionsPerOutboxTick()
{
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 first = await CreateDirectChatAsync(client, "bulk-delete-one", "Bulk Delete One");
var second = await CreateDirectChatAsync(client, "bulk-delete-two", "Bulk Delete Two");
var third = await CreateDirectChatAsync(client, "bulk-delete-three", "Bulk Delete Three");
var response = await client.PostAsJsonAsync(
"/api/chats/delete",
new ChatBulkActionRequest([first.Id, second.Id, third.Id]),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, response);
Assert.True(await ProcessOutboxAsync(factory) >= 3);
Assert.Equal(3, bridge.DeleteChatUrls.Count);
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var pendingCount = await db.Chats.CountAsync(x =>
(x.Id == first.Id || x.Id == second.Id || x.Id == third.Id) &&
x.PendingMaxAction != null);
Assert.Equal(0, pendingCount);
}
[Fact]
public async Task GetMessagesReturnsSqlCacheWithoutInlineMaxHistorySyncByDefault()
{
@@ -870,6 +906,66 @@ public sealed class ApiSmokeTests : IDisposable
}
}
[Fact]
public async Task SyncCreatesNewChatAndPreviewMessageForFreshIncomingListUpdate()
{
var pushNotifications = new RecordingPushNotificationService();
var marker = Guid.NewGuid().ToString("N");
var externalId = $"fresh-preview-chat-{marker}";
var title = "Fresh Preview Chat";
var previewText = $"fresh incoming {marker}";
var now = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient
{
Updates =
[
new MaxChatUpdate(
externalId,
title,
null,
now,
[],
previewText,
false,
now,
now.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.RemoveAll<IPushNotificationService>();
services.AddSingleton<IMaxBridgeClient>(bridge);
services.AddSingleton<IPushNotificationService>(pushNotifications);
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
await using var scope = factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var chat = await db.Chats.SingleAsync(x => x.ExternalId == externalId);
Assert.Equal(title, chat.Title);
Assert.Equal(previewText, chat.LastMessagePreview);
Assert.Equal(1, chat.UnreadCount);
var message = await db.Messages.SingleAsync(x => x.ChatId == chat.Id);
Assert.Equal(previewText, message.Text);
Assert.Equal(MessageDirection.Incoming, message.Direction);
Assert.StartsWith("preview:", message.ExternalId);
var previewPush = Assert.Single(pushNotifications.SentMessages);
Assert.Equal(chat.Id, previewPush.Chat.Id);
Assert.Equal(previewText, previewPush.Message.Text);
}
[Fact]
public async Task SyncDoesNotCreateTextOnlyMessageForGenericMediaPreviewOnlyIncomingUpdate()
{
@@ -929,10 +1025,7 @@ public sealed class ApiSmokeTests : IDisposable
x.ChatId == chat.Id &&
x.ExternalId != null &&
x.ExternalId.StartsWith("preview:")));
var previewPush = Assert.Single(pushNotifications.SentMessages);
Assert.Equal(chat.Id, previewPush.Chat.Id);
Assert.Equal("\u041c\u0435\u0434\u0438\u0430", previewPush.Message.Text);
Assert.Equal(MessageDirection.Incoming, previewPush.Message.Direction);
Assert.Empty(pushNotifications.SentMessages);
}
[Fact]
@@ -1181,6 +1274,77 @@ public sealed class ApiSmokeTests : IDisposable
await db.SaveChangesAsync();
}
[Fact]
public async Task SyncDoesNotRecreateDeletedChatWhenExternalIdChangesButWebUrlMatches()
{
const string oldExternalId = "deleted-sync-old-external";
const string newExternalId = "deleted-sync-new-external";
const string title = "Deleted Tombstone";
const string chatUrl = "https://max.test/chat/deleted-tombstone";
var sentAt = DateTimeOffset.UtcNow;
var bridge = new ResolvingDeleteMaxBridgeClient
{
Updates =
[
new MaxChatUpdate(
newExternalId,
title,
null,
sentAt,
[],
"remote hello after external id change",
false,
sentAt,
sentAt.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"),
chatUrl)
]
};
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, oldExternalId, title);
await using (var scope = factory.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
stored.WebUrl = chatUrl;
await db.SaveChangesAsync();
}
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.Title == title);
await using var verifyScope = factory.Services.CreateAsyncScope();
var verifyDb = verifyScope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChats = await verifyDb.Chats.Where(x => x.Title == title).ToListAsync();
var tombstone = Assert.Single(storedChats);
Assert.Equal(oldExternalId, tombstone.ExternalId);
Assert.NotNull(tombstone.DeletedAt);
tombstone.PendingMaxAction = null;
tombstone.PendingMaxActionRequestedAt = null;
tombstone.PendingMaxActionLastAttemptAt = null;
tombstone.PendingMaxActionError = null;
await verifyDb.SaveChangesAsync();
}
[Fact]
public async Task FailedMaxTextSendMarksLocalMessageFailed()
{