Add SQL outbox for MAX sends

This commit is contained in:
sevenhill
2026-07-05 13:47:07 +03:00
parent 5d33320e3d
commit 6dd5c7c6fb
8 changed files with 462 additions and 123 deletions
+44 -12
View File
@@ -46,7 +46,8 @@ public sealed class ApiSmokeTests : IDisposable
["QMax:PairingCode"] = "test-pairing",
["QMax:JwtSecret"] = "development-only-qmax-secret-change-before-production",
["QMax:MaxMode"] = "Mock",
["QMax:MaxPollIntervalSeconds"] = "600"
["QMax:MaxPollIntervalSeconds"] = "600",
["QMax:MaxOutboxPollIntervalSeconds"] = "600"
});
});
});
@@ -89,7 +90,8 @@ public sealed class ApiSmokeTests : IDisposable
var message = await messageResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(message);
Assert.Equal("hello from test", message!.Text);
Assert.Equal(MessageDeliveryState.Sent, message.DeliveryState);
Assert.Null(message.ExternalId);
Assert.Equal(MessageDeliveryState.Sending, message.DeliveryState);
var replyResponse = await client.PostAsJsonAsync(
$"/api/chats/{chat.Id}/messages",
@@ -98,6 +100,14 @@ public sealed class ApiSmokeTests : IDisposable
var reply = await replyResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(reply);
Assert.Equal(message.Id, reply!.ReplyToMessageId);
Assert.True(await ProcessOutboxAsync(_factory) >= 2);
var sentMessages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
Assert.All(sentMessages!, sent =>
{
Assert.False(string.IsNullOrWhiteSpace(sent.ExternalId));
Assert.Equal(MessageDeliveryState.Sent, sent.DeliveryState);
});
}
[Fact]
@@ -248,7 +258,7 @@ public sealed class ApiSmokeTests : IDisposable
}
[Fact]
public async Task UploadAttachmentFlowStoresDownloadsAndMarksSent()
public async Task UploadAttachmentFlowStoresDownloadsAndQueuesSend()
{
using var client = _factory.CreateClient();
await LoginAsync(client);
@@ -278,7 +288,8 @@ public sealed class ApiSmokeTests : IDisposable
await AssertStatusAsync(HttpStatusCode.OK, upload);
var message = await upload.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(message);
Assert.Equal(MessageDeliveryState.Sent, message!.DeliveryState);
Assert.Equal(MessageDeliveryState.Sending, message!.DeliveryState);
Assert.Null(message.ExternalId);
Assert.Equal(baseMessage.Id, message.ReplyToMessageId);
var attachment = Assert.Single(message.Attachments);
Assert.Equal(AttachmentKind.Image, attachment.Kind);
@@ -320,7 +331,8 @@ public sealed class ApiSmokeTests : IDisposable
await AssertStatusAsync(HttpStatusCode.OK, upload);
var sent = await upload.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(sent);
Assert.Equal(bridge.MessageExternalId, sent!.ExternalId);
Assert.Null(sent!.ExternalId);
Assert.Equal(MessageDeliveryState.Sending, sent.DeliveryState);
Assert.Single(sent.Attachments);
var sync = await client.PostAsync("/api/max/sync", null);
@@ -328,6 +340,7 @@ public sealed class ApiSmokeTests : IDisposable
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
var echoed = Assert.Single(messages!, message => message.ExternalId == bridge.MessageExternalId);
Assert.Equal(sent.Id, echoed.Id);
var attachment = Assert.Single(echoed.Attachments);
Assert.Equal("local-photo.png", attachment.FileName);
@@ -367,7 +380,7 @@ public sealed class ApiSmokeTests : IDisposable
Assert.Equal(target.Id, forwardedText!.ChatId);
Assert.Equal("forward text marker", forwardedText.Text);
Assert.Equal("You", forwardedText.ForwardedFrom);
Assert.Equal(MessageDeliveryState.Sent, forwardedText.DeliveryState);
Assert.Equal(MessageDeliveryState.Sending, forwardedText.DeliveryState);
var bytes = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x44, 0x41, 0x54, 0x41 };
using var form = new MultipartFormDataContent();
@@ -389,7 +402,7 @@ public sealed class ApiSmokeTests : IDisposable
Assert.Equal(target.Id, forwardedAttachmentMessage!.ChatId);
Assert.Equal("forward caption", forwardedAttachmentMessage.Text);
Assert.Equal("You", forwardedAttachmentMessage.ForwardedFrom);
Assert.Equal(MessageDeliveryState.Sent, forwardedAttachmentMessage.DeliveryState);
Assert.Equal(MessageDeliveryState.Sending, forwardedAttachmentMessage.DeliveryState);
var forwardedAttachment = Assert.Single(forwardedAttachmentMessage.Attachments);
Assert.Equal("forward.png", forwardedAttachment.FileName);
Assert.Equal(AttachmentKind.Image, forwardedAttachment.Kind);
@@ -518,7 +531,12 @@ public sealed class ApiSmokeTests : IDisposable
await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
var message = await messageResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(message);
Assert.True(await ProcessOutboxAsync(factory) >= 1);
var afterOutbox = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
message = Assert.Single(afterOutbox!, x => x.Id == message!.Id);
Assert.False(string.IsNullOrWhiteSpace(message!.ExternalId));
Assert.Equal(MessageDeliveryState.Sent, message.DeliveryState);
using var editRequest = new HttpRequestMessage(HttpMethod.Patch, $"/api/chats/{chat.Id}/messages/{message.Id}")
{
@@ -549,7 +567,7 @@ public sealed class ApiSmokeTests : IDisposable
}
[Fact]
public async Task FailedMaxTextSendDoesNotCreateLocalMessage()
public async Task FailedMaxTextSendMarksLocalMessageFailed()
{
using var factory = _factory.WithWebHostBuilder(builder =>
{
@@ -566,16 +584,23 @@ public sealed class ApiSmokeTests : IDisposable
var response = await client.PostAsJsonAsync(
$"/api/chats/{chat.Id}/messages",
new SendMessageRequest("this must not be stored"));
await AssertStatusAsync(HttpStatusCode.BadGateway, response);
await AssertStatusAsync(HttpStatusCode.OK, response);
var queued = await response.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(queued);
Assert.Equal(MessageDeliveryState.Sending, queued!.DeliveryState);
Assert.True(await ProcessOutboxAsync(factory) >= 1);
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
Assert.NotNull(messages);
Assert.Empty(messages!);
var failed = Assert.Single(messages!, x => x.Id == queued.Id);
Assert.Equal(queued.Id, failed.Id);
Assert.Equal(MessageDeliveryState.Failed, failed.DeliveryState);
Assert.Contains("Simulated MAX send failure.", failed.Error);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
var updated = Assert.Single(chats!, x => x.Id == chat.Id);
Assert.Null(updated.LastMessagePreview);
Assert.Null(updated.LastMessageAt);
Assert.Equal("this must not be stored", updated.LastMessagePreview);
Assert.NotNull(updated.LastMessageAt);
}
[Fact]
@@ -1077,6 +1102,13 @@ public sealed class ApiSmokeTests : IDisposable
return chat!;
}
private static async Task<int> ProcessOutboxAsync(WebApplicationFactory<Program> factory)
{
await using var scope = factory.Services.CreateAsyncScope();
var outbox = scope.ServiceProvider.GetRequiredService<MaxOutboxService>();
return await outbox.ProcessOnceAsync(CancellationToken.None);
}
private static async Task AssertStatusAsync(HttpStatusCode expected, HttpResponseMessage response)
{
if (response.StatusCode != expected)