Files
QMAX/tests/QMax.Tests/ApiSmokeTests.cs
T
2026-07-05 21:40:08 +03:00

2072 lines
90 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.TestHost;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using QMax.Api.Configuration;
using QMax.Api.Contracts;
using QMax.Api.Data;
using QMax.Api.Data.Entities;
using QMax.Api.Infrastructure.Max;
using QMax.Api.Infrastructure.Storage;
using QMax.Api.Services;
namespace QMax.Tests;
public sealed class ApiSmokeTests : IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
private readonly string _databasePath = Path.Combine(Path.GetTempPath(), $"qmax-test-{Guid.NewGuid():N}.db");
private readonly WebApplicationFactory<Program> _factory;
public ApiSmokeTests()
{
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["QMax:DatabasePath"] = _databasePath,
["QMax:StoragePath"] = Path.Combine(Path.GetTempPath(), $"qmax-storage-{Guid.NewGuid():N}"),
["QMax:PairingCode"] = "test-pairing",
["QMax:JwtSecret"] = "development-only-qmax-secret-change-before-production",
["QMax:MaxMode"] = "Mock",
["QMax:MaxPollIntervalSeconds"] = "600",
["QMax:MaxOutboxPollIntervalSeconds"] = "600"
});
});
});
}
[Fact]
public async Task LoginSyncAndSendMessageFlowWorks()
{
using var client = _factory.CreateClient();
var authResponse = await client.PostAsJsonAsync(
"/api/auth/device/login",
new DeviceLoginRequest("test-pairing", "xunit"));
await AssertStatusAsync(HttpStatusCode.OK, authResponse);
var auth = await authResponse.Content.ReadFromJsonAsync<AuthResponse>(JsonOptions);
Assert.NotNull(auth);
Assert.False(string.IsNullOrWhiteSpace(auth!.AccessToken));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken);
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.NotNull(chats);
Assert.Contains(chats!, x => x.ExternalId == "mock-qmax");
var created = await client.PostAsJsonAsync(
"/api/chats/direct",
new CreateDirectChatRequest("mock-direct", "Smoke Direct"));
await AssertStatusAsync(HttpStatusCode.OK, created);
var chat = await created.Content.ReadFromJsonAsync<ChatDto>(JsonOptions);
Assert.NotNull(chat);
var messageResponse = await client.PostAsJsonAsync(
$"/api/chats/{chat!.Id}/messages",
new SendMessageRequest("hello from test"));
await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
var message = await messageResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(message);
Assert.Equal("hello from test", message!.Text);
Assert.Null(message.ExternalId);
Assert.Equal(MessageDeliveryState.Sending, message.DeliveryState);
var replyResponse = await client.PostAsJsonAsync(
$"/api/chats/{chat.Id}/messages",
new SendMessageRequest("reply from test", message.Id));
await AssertStatusAsync(HttpStatusCode.OK, replyResponse);
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);
});
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var storedChat = await db.Chats.AsNoTracking().SingleAsync(x => x.Id == chat.Id);
Assert.Equal("https://web.max.ru/mock/mock-direct", storedChat.WebUrl);
}
[Fact]
public async Task ChatListReturnsAllChatsBeyondLegacyLimit()
{
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var bulkPrefix = $"bulk-chat-{Guid.NewGuid():N}-";
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
for (var index = 0; index < 221; index++)
{
db.Chats.Add(new Chat
{
ExternalId = $"{bulkPrefix}{index:D3}",
Title = index switch
{
219 => "\u0421\u0430\u0438\u0434",
220 => "\u0410\u043d\u044e\u0442\u0430",
_ => $"Bulk Chat {index:D3}"
},
Kind = ChatKind.MaxDialog,
LastMessageAt = DateTimeOffset.UtcNow.AddMinutes(-index)
});
}
await db.SaveChangesAsync();
}
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.NotNull(chats);
Assert.Equal(221, chats!.Count(x => x.ExternalId?.StartsWith(bulkPrefix, StringComparison.Ordinal) == true));
Assert.Contains(chats, x => x.Title == "\u0421\u0430\u0438\u0434");
Assert.Contains(chats, x => x.Title == "\u0410\u043d\u044e\u0442\u0430");
}
[Fact]
public async Task PushRegistrationUsesSingleExistingUserWhenTokenUserIsStale()
{
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
Guid currentUserId;
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
db.UserSessions.RemoveRange(db.UserSessions);
db.Users.RemoveRange(db.Users);
var currentUser = new User { DisplayName = "QMAX Owner" };
db.Users.Add(currentUser);
await db.SaveChangesAsync();
currentUserId = currentUser.Id;
}
var response = await client.PostAsJsonAsync(
"/api/push/devices",
new { FirebaseToken = "stale-token-user-fcm-token", Platform = "android" });
await AssertStatusAsync(HttpStatusCode.NoContent, response);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var device = await db.PushDevices.SingleAsync();
Assert.Equal(currentUserId, device.UserId);
Assert.Equal("stale-token-user-fcm-token", device.FirebaseToken);
}
}
[Fact]
public async Task SyncMergesLateDiscoveredAttachmentsIntoExistingMessage()
{
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll<IHostedService>();
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient, LateAttachmentMaxBridgeClient>();
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var firstSync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, firstSync);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
var chat = Assert.Single(chats!, x => x.ExternalId == LateAttachmentMaxBridgeClient.ChatExternalId);
var firstMessages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
var placeholder = Assert.Single(firstMessages!);
var secondSync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, secondSync);
var mergedMessages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
var merged = Assert.Single(mergedMessages!);
Assert.Equal(placeholder.Id, merged.Id);
Assert.Null(merged.Text);
Assert.Null(merged.EditedAt);
var attachment = Assert.Single(merged.Attachments);
Assert.Equal(AttachmentKind.Image, attachment.Kind);
Assert.Equal("max-image.png", attachment.FileName);
var download = await client.GetAsync(attachment.DownloadPath);
await AssertStatusAsync(HttpStatusCode.OK, download);
Assert.Equal(LateAttachmentMaxBridgeClient.MediaBytes, await download.Content.ReadAsByteArrayAsync());
var updatedChats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
var updatedChat = Assert.Single(updatedChats!, x => x.Id == chat.Id);
Assert.Equal("\u041c\u0435\u0434\u0438\u0430", updatedChat.LastMessagePreview);
}
[Fact]
public async Task DownloadingRemotePlaceholderCachesMediaInStorage()
{
var bridge = new DeferredRemoteMediaMaxBridgeClient();
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);
Guid chatId;
Guid attachmentId;
var manualExternalId = $"manual-deferred-media-{Guid.NewGuid():N}";
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var chat = new Chat
{
ExternalId = manualExternalId,
Title = "Deferred Media",
Kind = ChatKind.MaxDialog,
LastMessagePreview = "\u041c\u0435\u0434\u0438\u0430",
LastMessageAt = DateTimeOffset.Parse("2026-07-02T12:05:00+00:00")
};
var message = new Message
{
Chat = chat,
ExternalId = "deferred-message-1",
SenderName = "MAX Sender",
Direction = MessageDirection.Incoming,
SentAt = DateTimeOffset.Parse("2026-07-02T12:05:00+00:00"),
DeliveryState = MessageDeliveryState.Delivered
};
var attachment = new MessageAttachment
{
Message = message,
ExternalId = "deferred-media-1",
OriginalFileName = "\u0424\u043e\u0442\u043e",
StorageFileName = $"remote-{Guid.NewGuid():N}",
ContentType = "image/png",
FileSizeBytes = 0,
Kind = AttachmentKind.Image,
SortOrder = 0,
RemoteUrl = "https://max.test/media/deferred-media-1.png"
};
db.Chats.Add(chat);
db.Messages.Add(message);
db.MessageAttachments.Add(attachment);
await db.SaveChangesAsync();
chatId = chat.Id;
attachmentId = attachment.Id;
}
bridge.AllowMediaDownload = true;
var download = await client.GetAsync($"/api/chats/{chatId}/attachments/{attachmentId}/content");
await AssertStatusAsync(HttpStatusCode.OK, download);
Assert.Equal(DeferredRemoteMediaMaxBridgeClient.MediaBytes, await download.Content.ReadAsByteArrayAsync());
var afterDownload = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chatId}/messages", JsonOptions);
var cached = Assert.Single(Assert.Single(afterDownload!).Attachments);
Assert.Equal(DeferredRemoteMediaMaxBridgeClient.MediaBytes.Length, cached.FileSizeBytes);
Assert.Equal("image/png", cached.ContentType);
Assert.Equal("max-image.png", cached.FileName);
}
[Fact]
public async Task RemoteMediaStorageSavesVoiceNoteAtomically()
{
var storagePath = Path.Combine(Path.GetTempPath(), $"qmax-remote-media-{Guid.NewGuid():N}");
var storage = new AttachmentStorageService(Options.Create(new QMaxOptions
{
StoragePath = storagePath,
MaxUploadBytes = 1024 * 1024
}));
var bytes = new byte[] { 0x00, 0x01, 0x02, 0x03, 0x04 };
await using var stream = new MemoryStream(bytes);
var stored = await storage.SaveRemoteAsync(
"voice.m4a",
"audio/mp4",
stream,
bytes.Length,
null,
CancellationToken.None);
var finalPath = storage.GetPath(stored.StorageFileName);
Assert.True(File.Exists(finalPath));
Assert.False(File.Exists(finalPath + ".part"));
Assert.Equal(bytes.Length, stored.FileSizeBytes);
Assert.Equal(AttachmentKind.VoiceNote, stored.Kind);
Assert.Equal(bytes, await File.ReadAllBytesAsync(finalPath));
Directory.Delete(storagePath, recursive: true);
}
[Fact]
public async Task UploadAttachmentFlowStoresDownloadsAndQueuesSend()
{
using var client = _factory.CreateClient();
await LoginAsync(client);
var created = await client.PostAsJsonAsync(
"/api/chats/direct",
new CreateDirectChatRequest("mock-upload-chat", "Upload Smoke"));
await AssertStatusAsync(HttpStatusCode.OK, created);
var chat = await created.Content.ReadFromJsonAsync<ChatDto>(JsonOptions);
Assert.NotNull(chat);
var bytes = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x01, 0x02, 0x03 };
using var form = new MultipartFormDataContent();
using var fileContent = new ByteArrayContent(bytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("image/png");
form.Add(fileContent, "file", "sample.png");
form.Add(new StringContent("caption"), "caption");
var baseMessageResponse = await client.PostAsJsonAsync(
$"/api/chats/{chat!.Id}/messages",
new SendMessageRequest("attachment reply source"));
await AssertStatusAsync(HttpStatusCode.OK, baseMessageResponse);
var baseMessage = await baseMessageResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(baseMessage);
form.Add(new StringContent(baseMessage!.Id.ToString()), "replyToMessageId");
var upload = await client.PostAsync($"/api/chats/{chat.Id}/attachments", form);
await AssertStatusAsync(HttpStatusCode.OK, upload);
var message = await upload.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(message);
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);
Assert.Equal("sample.png", attachment.FileName);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
var updated = Assert.Single(chats!, x => x.Id == chat.Id);
Assert.Equal("caption", updated.LastMessagePreview);
var download = await client.GetAsync(attachment.DownloadPath);
await AssertStatusAsync(HttpStatusCode.OK, download);
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.Null(sent!.ExternalId);
Assert.Equal(MessageDeliveryState.Sending, sent.DeliveryState);
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);
Assert.Equal(sent.Id, echoed.Id);
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()
{
using var client = _factory.CreateClient();
await LoginAsync(client);
var source = await CreateDirectChatAsync(client, "mock-forward-source", "Forward Source");
var target = await CreateDirectChatAsync(client, "mock-forward-target", "Forward Target");
var textResponse = await client.PostAsJsonAsync(
$"/api/chats/{source.Id}/messages",
new SendMessageRequest("forward text marker"));
await AssertStatusAsync(HttpStatusCode.OK, textResponse);
var sourceMessage = await textResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(sourceMessage);
var textForwardResponse = await client.PostAsJsonAsync(
$"/api/chats/{source.Id}/messages/{sourceMessage!.Id}/forward",
new ForwardMessageRequest(target.Id));
await AssertStatusAsync(HttpStatusCode.OK, textForwardResponse);
var forwardedText = await textForwardResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(forwardedText);
Assert.Equal(target.Id, forwardedText!.ChatId);
Assert.Equal("forward text marker", forwardedText.Text);
Assert.Equal("You", forwardedText.ForwardedFrom);
Assert.Equal(MessageDeliveryState.Sending, forwardedText.DeliveryState);
var bytes = new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x44, 0x41, 0x54, 0x41 };
using var form = new MultipartFormDataContent();
using var fileContent = new ByteArrayContent(bytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("image/png");
form.Add(fileContent, "file", "forward.png");
form.Add(new StringContent("forward caption"), "caption");
var upload = await client.PostAsync($"/api/chats/{source.Id}/attachments", form);
await AssertStatusAsync(HttpStatusCode.OK, upload);
var sourceAttachmentMessage = await upload.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(sourceAttachmentMessage);
var attachmentForwardResponse = await client.PostAsJsonAsync(
$"/api/chats/{source.Id}/messages/{sourceAttachmentMessage!.Id}/forward",
new ForwardMessageRequest(target.Id));
await AssertStatusAsync(HttpStatusCode.OK, attachmentForwardResponse);
var forwardedAttachmentMessage = await attachmentForwardResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(forwardedAttachmentMessage);
Assert.Equal(target.Id, forwardedAttachmentMessage!.ChatId);
Assert.Equal("forward caption", forwardedAttachmentMessage.Text);
Assert.Equal("You", forwardedAttachmentMessage.ForwardedFrom);
Assert.Equal(MessageDeliveryState.Sending, forwardedAttachmentMessage.DeliveryState);
var forwardedAttachment = Assert.Single(forwardedAttachmentMessage.Attachments);
Assert.Equal("forward.png", forwardedAttachment.FileName);
Assert.Equal(AttachmentKind.Image, forwardedAttachment.Kind);
Assert.NotEqual(sourceAttachmentMessage.Attachments.Single().Id, forwardedAttachment.Id);
var download = await client.GetAsync(forwardedAttachment.DownloadPath);
await AssertStatusAsync(HttpStatusCode.OK, download);
Assert.Equal(bytes, await download.Content.ReadAsByteArrayAsync());
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
var updatedTarget = Assert.Single(chats!, x => x.Id == target.Id);
Assert.Equal("forward caption", updatedTarget.LastMessagePreview);
}
[Fact]
public async Task EditAndDeleteMessageFlowUpdatesHistoryAndChatPreview()
{
using var client = _factory.CreateClient();
await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, "mock-edit-delete-chat", "Edit Delete Smoke");
var firstResponse = await client.PostAsJsonAsync(
$"/api/chats/{chat.Id}/messages",
new SendMessageRequest("first message"));
await AssertStatusAsync(HttpStatusCode.OK, firstResponse);
var first = await firstResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(first);
var secondResponse = await client.PostAsJsonAsync(
$"/api/chats/{chat.Id}/messages",
new SendMessageRequest("second message"));
await AssertStatusAsync(HttpStatusCode.OK, secondResponse);
var second = await secondResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(second);
using var editRequest = new HttpRequestMessage(HttpMethod.Patch, $"/api/chats/{chat.Id}/messages/{second!.Id}")
{
Content = JsonContent.Create(new EditMessageRequest("edited second"), options: JsonOptions)
};
var editResponse = await client.SendAsync(editRequest);
await AssertStatusAsync(HttpStatusCode.OK, editResponse);
var edited = await editResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(edited);
Assert.Equal("edited second", edited!.Text);
Assert.NotNull(edited.EditedAt);
var chatsAfterEdit = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
var editedChat = Assert.Single(chatsAfterEdit!, x => x.Id == chat.Id);
Assert.Equal("edited second", editedChat.LastMessagePreview);
var deleteResponse = await client.DeleteAsync($"/api/chats/{chat.Id}/messages/{second.Id}");
await AssertStatusAsync(HttpStatusCode.NoContent, deleteResponse);
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
Assert.NotNull(messages);
Assert.Contains(messages!, x => x.Id == first!.Id);
Assert.DoesNotContain(messages!, x => x.Id == second.Id);
var chatsAfterDelete = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
var updatedChat = Assert.Single(chatsAfterDelete!, x => x.Id == chat.Id);
Assert.Equal("first message", updatedChat.LastMessagePreview);
}
[Fact]
public async Task ReactionFlowSetsReplacesAndClearsOwnReaction()
{
using var client = _factory.CreateClient();
await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, "mock-reaction-chat", "Reaction Smoke");
var messageResponse = await client.PostAsJsonAsync(
$"/api/chats/{chat.Id}/messages",
new SendMessageRequest("reaction target"));
await AssertStatusAsync(HttpStatusCode.OK, messageResponse);
var message = await messageResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(message);
var setResponse = await client.PutAsJsonAsync(
$"/api/chats/{chat.Id}/messages/{message!.Id}/reaction",
new SetReactionRequest("\ud83d\udc4d"),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.OK, setResponse);
var reacted = await setResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(reacted);
var thumbsUp = Assert.Single(reacted!.Reactions);
Assert.Equal("\ud83d\udc4d", thumbsUp.Emoji);
Assert.Equal(1, thumbsUp.Count);
Assert.True(thumbsUp.ReactedByMe);
var replaceResponse = await client.PutAsJsonAsync(
$"/api/chats/{chat.Id}/messages/{message.Id}/reaction",
new SetReactionRequest("\u2764\ufe0f"),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.OK, replaceResponse);
var replaced = await replaceResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(replaced);
var heart = Assert.Single(replaced!.Reactions);
Assert.Equal("\u2764\ufe0f", heart.Emoji);
var clearResponse = await client.DeleteAsync($"/api/chats/{chat.Id}/messages/{message.Id}/reaction");
await AssertStatusAsync(HttpStatusCode.OK, clearResponse);
var cleared = await clearResponse.Content.ReadFromJsonAsync<MessageDto>(JsonOptions);
Assert.NotNull(cleared);
Assert.Empty(cleared!.Reactions);
}
[Fact]
public async Task MaxActionFailureDoesNotMutateLocalMessageState()
{
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient, FailingActionMaxBridgeClient>();
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, "max-action-failure-chat", "Action Failure");
var messageResponse = await client.PostAsJsonAsync(
$"/api/chats/{chat.Id}/messages",
new SendMessageRequest("original guarded text"));
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}")
{
Content = JsonContent.Create(new EditMessageRequest("should not persist"), options: JsonOptions)
};
var editResponse = await client.SendAsync(editRequest);
await AssertStatusAsync(HttpStatusCode.Conflict, editResponse);
var afterEdit = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
var unchanged = Assert.Single(afterEdit!, x => x.Id == message.Id);
Assert.Equal("original guarded text", unchanged.Text);
Assert.Null(unchanged.EditedAt);
var reactionResponse = await client.PutAsJsonAsync(
$"/api/chats/{chat.Id}/messages/{message.Id}/reaction",
new SetReactionRequest("\ud83d\udc4d"),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.Conflict, reactionResponse);
var afterReaction = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
Assert.Empty(Assert.Single(afterReaction!, x => x.Id == message.Id).Reactions);
var deleteResponse = await client.DeleteAsync($"/api/chats/{chat.Id}/messages/{message.Id}");
await AssertStatusAsync(HttpStatusCode.Conflict, deleteResponse);
var afterDelete = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
Assert.Contains(afterDelete!, x => x.Id == message.Id && x.DeletedAt is null);
}
[Fact]
public async Task BulkDeleteResolvesMissingChatUrlBeforeMaxAction()
{
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, "missing-url-chat", "Missing Url");
var response = await client.PostAsJsonAsync(
"/api/chats/delete",
new ChatBulkActionRequest([chat.Id]),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, response);
Assert.Equal("missing-url-chat", bridge.ResolvedExternalId);
Assert.Equal("https://max.test/chat/missing-url-chat", bridge.DeleteChatUrl);
var chats = await client.GetFromJsonAsync<ChatDto[]>("/api/chats", JsonOptions);
Assert.DoesNotContain(chats!, x => x.Id == chat.Id);
}
[Fact]
public async Task BulkDeleteRefreshesStaleChatUrlAfterMenuOpenFailure()
{
const string externalId = "stale-menu-url-chat";
const string staleUrl = "https://max.test/chat/wrong-chat";
var bridge = new ResolvingDeleteMaxBridgeClient(staleUrl);
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, "Stale Menu Url");
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 = staleUrl;
await db.SaveChangesAsync();
}
var response = await client.PostAsJsonAsync(
"/api/chats/delete",
new ChatBulkActionRequest([chat.Id]),
JsonOptions);
await AssertStatusAsync(HttpStatusCode.NoContent, response);
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);
}
[Fact]
public async Task FailedMaxTextSendMarksLocalMessageFailed()
{
using var factory = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.RemoveAll<IMaxBridgeClient>();
services.AddSingleton<IMaxBridgeClient, FailingSendMaxBridgeClient>();
});
});
using var client = factory.CreateClient();
await LoginAsync(client);
var chat = await CreateDirectChatAsync(client, "max-send-failure-chat", "Send Failure");
var response = await client.PostAsJsonAsync(
$"/api/chats/{chat.Id}/messages",
new SendMessageRequest("this must not be stored"));
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);
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.Equal("this must not be stored", updated.LastMessagePreview);
Assert.NotNull(updated.LastMessageAt);
}
[Fact]
public async Task ChatSearchFindsMessageTextAndAttachmentNames()
{
using var client = _factory.CreateClient();
await LoginAsync(client);
var created = await client.PostAsJsonAsync(
"/api/chats/direct",
new CreateDirectChatRequest("mock-search-chat", "Search Smoke"));
await AssertStatusAsync(HttpStatusCode.OK, created);
var chat = await created.Content.ReadFromJsonAsync<ChatDto>(JsonOptions);
Assert.NotNull(chat);
var textResponse = await client.PostAsJsonAsync(
$"/api/chats/{chat!.Id}/messages",
new SendMessageRequest("findable espresso marker"));
await AssertStatusAsync(HttpStatusCode.OK, textResponse);
using var form = new MultipartFormDataContent();
using var fileContent = new ByteArrayContent([0x25, 0x50, 0x44, 0x46]);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
form.Add(fileContent, "file", "needle-report.pdf");
var upload = await client.PostAsync($"/api/chats/{chat.Id}/attachments", form);
await AssertStatusAsync(HttpStatusCode.OK, upload);
var textSearch = await client.GetFromJsonAsync<MessageDto[]>(
$"/api/chats/{chat.Id}/search?q=espresso",
JsonOptions);
Assert.NotNull(textSearch);
Assert.Contains(textSearch!, x => x.Text == "findable espresso marker");
var fileSearch = await client.GetFromJsonAsync<MessageDto[]>(
$"/api/chats/{chat.Id}/search?q=needle",
JsonOptions);
Assert.NotNull(fileSearch);
Assert.Contains(fileSearch!, x => x.Attachments.Any(a => a.FileName == "needle-report.pdf"));
var emptySearch = await client.GetAsync($"/api/chats/{chat.Id}/search?q=");
await AssertStatusAsync(HttpStatusCode.BadRequest, emptySearch);
}
[Fact]
public void RemoteMediaStorageNormalizesMaxFallbackFileNames()
{
Assert.Equal(
"max-video.mp4",
AttachmentStorageService.NormalizeRemoteFileName(
"\u0412\u0430\u0448 \u0431\u0440\u0430\u0443\u0437\u0435\u0440 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0432\u0438\u0434\u0435\u043e",
"video/mp4",
AttachmentKind.Video));
Assert.Equal(
"max-image.webp",
AttachmentStorageService.NormalizeRemoteFileName(
"\ud83e\udd1d",
"image/webp",
AttachmentKind.Image));
}
[Fact]
public void MessageTextSanitizerHidesQuestionMarkArtifacts()
{
Assert.Equal(
"\u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0435",
MessageTextSanitizer.CleanChatPreview("[????] ??????:"));
Assert.Equal(
"\u041c\u0435\u0434\u0438\u0430",
MessageTextSanitizer.CleanChatPreview("\u0424\u043e\u0442\u043e"));
Assert.Null(MessageTextSanitizer.CleanMessageText("\u0424\u043e\u0442\u043e", hasAttachments: true));
Assert.Equal("\u0424\u043e\u0442\u043e", MessageTextSanitizer.CleanMessageText("\u0424\u043e\u0442\u043e", hasAttachments: false));
Assert.Null(MessageTextSanitizer.CleanMessageText("[????] ??????:", hasAttachments: true));
Assert.Equal("hello ????", MessageTextSanitizer.CleanChatPreview("hello ????"));
}
[Fact]
public async Task SyncDoesNotPushListPreviewEchoOfOwnOutgoingMessage()
{
var pushNotifications = new RecordingPushNotificationService();
var bridge = new OutgoingPreviewEchoMaxBridgeClient(
$"mock-self-echo-chat-{Guid.NewGuid():N}",
"message from me");
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);
Guid chatId;
var sentAt = DateTimeOffset.UtcNow.AddSeconds(-20);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var chat = new Chat
{
ExternalId = bridge.ChatExternalId,
Title = "Self Echo",
LastMessagePreview = bridge.PreviewText,
LastMessageAt = sentAt,
UnreadCount = 0
};
db.Chats.Add(chat);
db.Messages.Add(new Message
{
Chat = chat,
ExternalId = "self-echo-message",
Direction = MessageDirection.Outgoing,
Text = bridge.PreviewText,
SentAt = sentAt,
DeliveryState = MessageDeliveryState.Sent
});
await db.SaveChangesAsync();
chatId = chat.Id;
}
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
Assert.Empty(pushNotifications.SentMessages);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var chat = await db.Chats.FindAsync(chatId);
Assert.NotNull(chat);
Assert.Equal(0, chat!.UnreadCount);
Assert.Equal(bridge.PreviewText, chat.LastMessagePreview);
}
}
[Fact]
public async Task SyncDoesNotPushListPreviewEchoOfOwnOutgoingAttachment()
{
var pushNotifications = new RecordingPushNotificationService();
var bridge = new OutgoingPreviewEchoMaxBridgeClient(
$"mock-self-echo-attachment-chat-{Guid.NewGuid():N}",
"\u0424\u043e\u0442\u043e");
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);
Guid chatId;
var sentAt = DateTimeOffset.UtcNow.AddSeconds(-20);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var chat = new Chat
{
ExternalId = bridge.ChatExternalId,
Title = "Self Attachment Echo",
LastMessagePreview = "\u041c\u0435\u0434\u0438\u0430",
LastMessageAt = sentAt,
UnreadCount = 0
};
var message = new Message
{
Chat = chat,
ExternalId = "self-echo-attachment-message",
Direction = MessageDirection.Outgoing,
SentAt = sentAt,
DeliveryState = MessageDeliveryState.Sent
};
message.Attachments.Add(new MessageAttachment
{
Message = message,
OriginalFileName = "sent-photo.jpg",
StorageFileName = $"{Guid.NewGuid():N}-sent-photo.jpg",
ContentType = "image/jpeg",
FileSizeBytes = 1024,
Kind = AttachmentKind.Image
});
db.Chats.Add(chat);
db.Messages.Add(message);
await db.SaveChangesAsync();
chatId = chat.Id;
}
var sync = await client.PostAsync("/api/max/sync", null);
await AssertStatusAsync(HttpStatusCode.OK, sync);
Assert.Empty(pushNotifications.SentMessages);
using (var scope = factory.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
var chat = await db.Chats.FindAsync(chatId);
Assert.NotNull(chat);
Assert.Equal(0, chat!.UnreadCount);
Assert.Equal("\u041c\u0435\u0434\u0438\u0430", chat.LastMessagePreview);
}
}
[Fact]
public async Task FirebasePushSkipsOutgoingMessages()
{
var databasePath = Path.Combine(Path.GetTempPath(), $"qmax-push-test-{Guid.NewGuid():N}.db");
try
{
var dbOptions = new DbContextOptionsBuilder<QMaxDbContext>()
.UseSqlite($"Data Source={databasePath};Pooling=False")
.Options;
await using (var db = new QMaxDbContext(dbOptions))
{
await db.Database.EnsureCreatedAsync();
var user = new User { DisplayName = "Push Test" };
db.Users.Add(user);
db.PushDevices.Add(new PushDevice { User = user, FirebaseToken = "test-token", Platform = "android" });
await db.SaveChangesAsync();
var service = new FirebasePushNotificationService(
db,
new ThrowingHttpClientFactory(),
Options.Create(new QMaxOptions
{
PushEnabled = true,
FirebaseProjectId = "qmax-test",
FirebaseServiceAccountJson = "{ this is not valid json"
}),
NullLogger<FirebasePushNotificationService>.Instance);
var chat = new Chat { Title = "Self Chat" };
var message = new Message
{
Chat = chat,
Direction = MessageDirection.Outgoing,
Text = "message from me",
DeliveryState = MessageDeliveryState.Sent
};
await service.SendNewMessageAsync(chat, message, CancellationToken.None);
Assert.Equal(1, await db.PushDevices.CountAsync());
}
}
finally
{
foreach (var suffix in new[] { "", "-shm", "-wal" })
{
var path = databasePath + suffix;
if (File.Exists(path))
{
File.Delete(path);
}
}
}
}
[Fact]
public async Task DatabaseCleanupRemovesLegacyWebFileAttachmentDuplicateAndClearsSelfUnread()
{
var databasePath = Path.Combine(Path.GetTempPath(), $"qmax-legacy-web-file-test-{Guid.NewGuid():N}.db");
var dbOptions = new DbContextOptionsBuilder<QMaxDbContext>()
.UseSqlite($"Data Source={databasePath};Pooling=False")
.Options;
var chatId = Guid.NewGuid();
const string attachmentHash = "same-photo-sha";
var legacySentAt = DateTimeOffset.UtcNow.AddMinutes(-40);
try
{
await using (var db = new QMaxDbContext(dbOptions))
{
await db.Database.EnsureCreatedAsync();
db.Chats.Add(new Chat
{
Id = chatId,
Title = "Legacy Photo",
LastMessagePreview = "\u041c\u0435\u0434\u0438\u0430",
LastMessageAt = legacySentAt.AddMinutes(30),
UnreadCount = 2
});
var legacyMessage = new Message
{
ChatId = chatId,
ExternalId = "web-file-123",
Direction = MessageDirection.Outgoing,
SentAt = legacySentAt,
DeliveryState = MessageDeliveryState.Sent
};
legacyMessage.Attachments.Add(new MessageAttachment
{
OriginalFileName = "legacy.jpg",
StorageFileName = $"{Guid.NewGuid():N}.jpg",
ContentType = "image/jpeg",
FileSizeBytes = 10,
Sha256 = attachmentHash,
Kind = AttachmentKind.Image
});
var confirmedMessage = new Message
{
ChatId = chatId,
ExternalId = "domhist:real-photo",
Direction = MessageDirection.Outgoing,
SentAt = legacySentAt.AddMinutes(30),
DeliveryState = MessageDeliveryState.Sent
};
confirmedMessage.Attachments.Add(new MessageAttachment
{
OriginalFileName = "confirmed.jpg",
StorageFileName = $"{Guid.NewGuid():N}.jpg",
ContentType = "image/jpeg",
FileSizeBytes = 10,
Sha256 = attachmentHash,
Kind = AttachmentKind.Image
});
db.Messages.AddRange(legacyMessage, confirmedMessage);
await db.SaveChangesAsync();
}
await using (var cleanupDb = new QMaxDbContext(dbOptions))
{
await QMaxDatabaseCleanup.RemoveLegacyWebFileAttachmentEchoesAsync(cleanupDb);
await QMaxDatabaseCleanup.ClearUnreadCountsForLatestOutgoingChatsAsync(cleanupDb);
}
await using var verifyDb = new QMaxDbContext(dbOptions);
var messages = await verifyDb.Messages
.Where(message => message.ChatId == chatId)
.ToListAsync();
Assert.DoesNotContain(messages, message => message.ExternalId == "web-file-123");
Assert.Contains(messages, message => message.ExternalId == "domhist:real-photo");
var chat = await verifyDb.Chats.FindAsync(chatId);
Assert.NotNull(chat);
Assert.Equal(0, chat!.UnreadCount);
}
finally
{
DeleteSqliteDatabase(databasePath);
}
}
[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()
{
using var client = _factory.CreateClient();
var unauthorized = await client.GetAsync("/admin/api/status");
await AssertStatusAsync(HttpStatusCode.Unauthorized, unauthorized);
var authorized = await client.GetAsync("/admin/api/status?pairingCode=test-pairing");
await AssertStatusAsync(HttpStatusCode.OK, authorized);
using var json = await JsonDocument.ParseAsync(await authorized.Content.ReadAsStreamAsync());
Assert.Equal("QMAX Bridge", json.RootElement.GetProperty("name").GetString());
Assert.True(json.RootElement.GetProperty("maxAuthorized").GetBoolean());
}
[Fact]
public async Task AdminUsersCanListAndDeleteUsers()
{
using var client = _factory.CreateClient();
await LoginAsync(client);
var usersResponse = await client.GetAsync("/admin/api/users?pairingCode=test-pairing");
await AssertStatusAsync(HttpStatusCode.OK, usersResponse);
using var usersJson = await JsonDocument.ParseAsync(await usersResponse.Content.ReadAsStreamAsync());
var user = Assert.Single(usersJson.RootElement.EnumerateArray());
var userId = user.GetProperty("id").GetGuid();
Assert.Equal("QMAX Owner", user.GetProperty("displayName").GetString());
Assert.NotEmpty(user.GetProperty("sessions").EnumerateArray());
var delete = await client.DeleteAsync($"/admin/api/users/{userId}?pairingCode=test-pairing");
await AssertStatusAsync(HttpStatusCode.NoContent, delete);
var afterDelete = await client.GetAsync("/admin/api/users?pairingCode=test-pairing");
await AssertStatusAsync(HttpStatusCode.OK, afterDelete);
using var afterDeleteJson = await JsonDocument.ParseAsync(await afterDelete.Content.ReadAsStreamAsync());
Assert.Empty(afterDeleteJson.RootElement.EnumerateArray());
}
public void Dispose()
{
_factory.Dispose();
DeleteSqliteDatabase(_databasePath);
}
private static void DeleteSqliteDatabase(string databasePath)
{
foreach (var suffix in new[] { "", "-shm", "-wal" })
{
var path = databasePath + suffix;
if (File.Exists(path))
{
File.Delete(path);
}
}
}
private static async Task LoginAsync(HttpClient client)
{
var authResponse = await client.PostAsJsonAsync(
"/api/auth/device/login",
new DeviceLoginRequest("test-pairing", "xunit"));
await AssertStatusAsync(HttpStatusCode.OK, authResponse);
var auth = await authResponse.Content.ReadFromJsonAsync<AuthResponse>(JsonOptions);
Assert.NotNull(auth);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth!.AccessToken);
}
private static async Task<ChatDto> CreateDirectChatAsync(HttpClient client, string externalId, string title)
{
var response = await client.PostAsJsonAsync(
"/api/chats/direct",
new CreateDirectChatRequest(externalId, title));
await AssertStatusAsync(HttpStatusCode.OK, response);
var chat = await response.Content.ReadFromJsonAsync<ChatDto>(JsonOptions);
Assert.NotNull(chat);
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)
{
var body = await response.Content.ReadAsStringAsync();
throw new Xunit.Sdk.XunitException($"Expected {expected}, got {response.StatusCode}. Body: {body}");
}
}
private sealed class ThrowingHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name)
{
throw new InvalidOperationException("Push for outgoing messages must not reach Firebase HTTP calls.");
}
}
private sealed class RecordingPushNotificationService : IPushNotificationService
{
public List<(Chat Chat, Message Message)> SentMessages { get; } = [];
public Task SendNewMessageAsync(Chat chat, Message message, CancellationToken cancellationToken)
{
SentMessages.Add((chat, message));
return Task.CompletedTask;
}
}
private sealed class OutgoingPreviewEchoMaxBridgeClient : IMaxBridgeClient
{
public OutgoingPreviewEchoMaxBridgeClient(string chatExternalId, string previewText)
{
ChatExternalId = chatExternalId;
PreviewText = previewText;
}
public string ChatExternalId { get; }
public string PreviewText { get; }
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)
{
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(
[
new MaxChatUpdate(
ChatExternalId,
"Self Echo",
null,
DateTimeOffset.UtcNow,
[],
PreviewText,
null,
null,
DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"))
]);
}
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
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)
{
return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", 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?>(null);
}
}
private sealed class FailingActionMaxBridgeClient : IMaxBridgeClient
{
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)
{
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(Array.Empty<MaxChatUpdate>());
}
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Fail();
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Fail();
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
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)
{
return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null));
}
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
{
return Fail();
}
public Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken)
{
return Fail();
}
public Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return Fail();
}
public Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return Fail();
}
public Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxMediaDownload?>(null);
}
private static Task<MaxActionResult> Fail()
{
return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure."));
}
}
private sealed class ResolvingDeleteMaxBridgeClient : IMaxBridgeClient
{
private readonly string? _staleUrlToReject;
public ResolvingDeleteMaxBridgeClient(string? staleUrlToReject = null)
{
_staleUrlToReject = staleUrlToReject;
}
public string? ResolvedExternalId { get; private set; }
public string? DeleteChatUrl { get; private set; }
public List<string?> DeleteChatUrls { get; } = [];
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)
{
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(Array.Empty<MaxChatUpdate>());
}
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
ResolvedExternalId = externalChatId;
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
DeleteChatUrls.Add(chatUrl);
DeleteChatUrl = 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<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
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)
{
return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", 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?>(null);
}
}
private sealed class FailingSendMaxBridgeClient : IMaxBridgeClient
{
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)
{
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(Array.Empty<MaxChatUpdate>());
}
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure."));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure."));
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX send failure."));
}
public Task<MaxSendResult> SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX attachment failure."));
}
public Task<MaxActionResult> EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure."));
}
public Task<MaxActionResult> DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure."));
}
public Task<MaxActionResult> SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure."));
}
public Task<MaxActionResult> ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure."));
}
public Task<MaxMediaDownload?> DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxMediaDownload?>(null);
}
}
private sealed class LateAttachmentMaxBridgeClient : IMaxBridgeClient
{
public const string ChatExternalId = "mock-late-attachment-chat";
public static readonly byte[] MediaBytes = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x44, 0x41, 0x54, 0x41];
private int _fetchCount;
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)
{
_fetchCount++;
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>([BuildUpdate(includeAttachment: _fetchCount > 1)]);
}
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
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)
{
return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", 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(MediaBytes), "image/png", MediaBytes.Length));
}
private static MaxChatUpdate BuildUpdate(bool includeAttachment)
{
IReadOnlyList<MaxAttachmentUpdate> attachments = includeAttachment
?
[
new MaxAttachmentUpdate(
"late-media-1",
"\u0424\u043e\u0442\u043e",
"image/png",
MediaBytes.Length,
"https://max.test/media/late-media-1.png",
nameof(AttachmentKind.Image),
0)
]
: [];
return new MaxChatUpdate(
ChatExternalId,
"Late Attachment",
null,
DateTimeOffset.Parse("2026-07-02T12:00:00+00:00"),
[
new MaxMessageUpdate(
"late-message-1",
"sender-1",
"MAX Sender",
false,
"\u0424\u043e\u0442\u043e",
DateTimeOffset.Parse("2026-07-02T12:00:00+00:00"),
attachments)
]);
}
}
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, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
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)
{
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";
public static readonly byte[] MediaBytes = [0x89, 0x50, 0x4E, 0x47, 0x44, 0x45, 0x46, 0x45, 0x52];
public bool AllowMediaDownload { get; set; }
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)
{
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(
[
new MaxChatUpdate(
ChatExternalId,
"Deferred Media",
null,
DateTimeOffset.Parse("2026-07-02T12:05:00+00:00"),
[
new MaxMessageUpdate(
"deferred-message-1",
"sender-1",
"MAX Sender",
false,
"\u0424\u043e\u0442\u043e",
DateTimeOffset.Parse("2026-07-02T12:05:00+00:00"),
[
new MaxAttachmentUpdate(
"deferred-media-1",
"\u0424\u043e\u0442\u043e",
"image/png",
null,
"https://max.test/media/deferred-media-1.png",
nameof(AttachmentKind.Image),
0)
])
])
]);
}
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatUpdate?>(null);
}
public Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult<MaxChatPresence?>(null);
}
public Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null));
}
public Task<MaxActionResult> ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxActionResult> DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
{
return Task.FromResult(new MaxActionResult(true, null));
}
public Task<MaxSendResult> SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken)
{
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)
{
return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", 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)
{
if (!AllowMediaDownload)
{
return Task.FromResult<MaxMediaDownload?>(null);
}
return Task.FromResult<MaxMediaDownload?>(
new MaxMediaDownload(new MemoryStream(MediaBytes), "image/png", MediaBytes.Length));
}
}
}