Merge outgoing photo echo attachments

This commit is contained in:
sevenhill
2026-07-05 08:58:38 +03:00
parent b518f22a09
commit 5d33320e3d
4 changed files with 355 additions and 3 deletions
+229
View File
@@ -293,6 +293,55 @@ public sealed class ApiSmokeTests : IDisposable
Assert.Equal(bytes, await download.Content.ReadAsByteArrayAsync());
}
[Fact]
public async Task SyncMergesOutgoingAttachmentEchoIntoLocalUpload()
{
var bridge = new OutgoingAttachmentEchoMaxBridgeClient();
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, bridge.ChatExternalId, "Outgoing Echo");
var localBytes = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x4C, 0x4F, 0x43, 0x41, 0x4C };
using var form = new MultipartFormDataContent();
using var fileContent = new ByteArrayContent(localBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("image/png");
form.Add(fileContent, "file", "local-photo.png");
var upload = await client.PostAsync($"/api/chats/{chat.Id}/attachments", form);
await AssertStatusAsync(HttpStatusCode.OK, upload);
var sent = await upload.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(sent);
Assert.Equal(bridge.MessageExternalId, sent!.ExternalId);
Assert.Single(sent.Attachments);
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
var echoed = Assert.Single(messages!, message => message.ExternalId == bridge.MessageExternalId);
var attachment = Assert.Single(echoed.Attachments);
Assert.Equal("local-photo.png", attachment.FileName);
var download = await client.GetAsync(attachment.DownloadPath);
await AssertStatusAsync(HttpStatusCode.OK, download);
Assert.Equal(localBytes, await download.Content.ReadAsByteArrayAsync());
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedAttachment = await db.MessageAttachments.SingleAsync(x => x.MessageId == echoed.Id);
Assert.Equal(bridge.RemoteAttachmentExternalId, storedAttachment.ExternalId);
Assert.Equal(bridge.RemoteUrl, storedAttachment.RemoteUrl);
}
[Fact]
public async Task ForwardMessageFlowCopiesTextAndAttachments()
{
@@ -879,6 +928,76 @@ public sealed class ApiSmokeTests : IDisposable
}
}
[Fact]
public async Task DatabaseCleanupMergesOutgoingRemoteAttachmentEchoes()
{
var databasePath = Path.Combine(Path.GetTempPath(), $"qmax-attachment-echo-test-{Guid.NewGuid():N}.db");
var dbOptions = new DbContextOptionsBuilder<QMaxDbContext>()
.UseSqlite($"Data Source={databasePath};Pooling=False")
.Options;
var messageId = Guid.NewGuid();
var localAttachmentId = Guid.NewGuid();
try
{
await using (var db = new QMaxDbContext(dbOptions))
{
await db.Database.EnsureCreatedAsync();
var chat = new Chat { Title = "Attachment Echo" };
var message = new Message
{
Id = messageId,
Chat = chat,
ExternalId = "domhist:photo",
Direction = MessageDirection.Outgoing,
DeliveryState = MessageDeliveryState.Sent,
SentAt = DateTimeOffset.UtcNow
};
message.Attachments.Add(new MessageAttachment
{
Id = localAttachmentId,
OriginalFileName = "local-photo.png",
StorageFileName = $"{Guid.NewGuid():N}.png",
ContentType = "image/png",
FileSizeBytes = 10,
Sha256 = "local-sha",
Kind = AttachmentKind.Image,
SortOrder = 0
});
message.Attachments.Add(new MessageAttachment
{
OriginalFileName = "max-image-1.jpg",
StorageFileName = $"{Guid.NewGuid():N}.jpg",
ExternalId = "dommedia:remote",
RemoteUrl = "https://max.test/media/photo.jpg",
ContentType = "image/jpeg",
FileSizeBytes = 8,
Sha256 = "remote-sha",
Kind = AttachmentKind.Image,
SortOrder = 0
});
db.Messages.Add(message);
await db.SaveChangesAsync();
}
await using (var cleanupDb = new QMaxDbContext(dbOptions))
{
await QMaxDatabaseCleanup.MergeOutgoingRemoteAttachmentEchoesAsync(cleanupDb);
}
await using var verifyDb = new QMaxDbContext(dbOptions);
var attachment = await verifyDb.MessageAttachments.SingleAsync(x => x.MessageId == messageId);
Assert.Equal(localAttachmentId, attachment.Id);
Assert.Equal("local-photo.png", attachment.OriginalFileName);
Assert.Equal("dommedia:remote", attachment.ExternalId);
Assert.Equal("https://max.test/media/photo.jpg", attachment.RemoteUrl);
}
finally
{
DeleteSqliteDatabase(databasePath);
}
}
[Fact]
public async Task AdminStatusRequiresPairingCode()
{
@@ -1344,6 +1463,116 @@ public sealed class ApiSmokeTests : IDisposable
}
}
private sealed class OutgoingAttachmentEchoMaxBridgeClient : IMaxBridgeClient
{
public readonly string ChatExternalId = $"mock-outgoing-attachment-echo-{Guid.NewGuid():N}";
public readonly string MessageExternalId = $"outgoing-photo-{Guid.NewGuid():N}";
public readonly string RemoteAttachmentExternalId = $"dommedia:{Guid.NewGuid():N}";
public readonly string RemoteUrl = $"https://max.test/media/{Guid.NewGuid():N}.jpg";
private static readonly byte[] RemoteBytes = [0xFF, 0xD8, 0x52, 0x45, 0x4D, 0x4F, 0x54, 0x45, 0xFF, 0xD9];
public Task<MaxBridgeStatus> GetStatusAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow));
}
public Task<MaxBridgeStatus> BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken)
{
return GetStatusAsync(cancellationToken);
}
public Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken)
{
return GetStatusAsync(cancellationToken);
}
public Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken)
{
return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow));
}
public Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken)
{
var sentAt = DateTimeOffset.UtcNow.AddSeconds(-2);
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(
[
new MaxChatUpdate(
ChatExternalId,
"Outgoing Echo",
null,
sentAt,
[
new MaxMessageUpdate(
MessageExternalId,
"self",
"You",
true,
"",
sentAt,
[
new MaxAttachmentUpdate(
RemoteAttachmentExternalId,
"max-image-1.jpg",
"image/jpeg",
RemoteBytes.Length,
RemoteUrl,
nameof(AttachmentKind.Image),
0)
])
],
"\u041c\u0435\u0434\u0438\u0430",
true,
sentAt)
]);
}
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null));
}
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string path, string caption, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(true, MessageExternalId, null));
}
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxMediaDownload?>(
new MaxMediaDownload(new MemoryStream(RemoteBytes), "image/jpeg", RemoteBytes.Length));
}
}
private sealed class DeferredRemoteMediaMaxBridgeClient : IMaxBridgeClient
{
public const string ChatExternalId = "mock-deferred-remote-media-chat";