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 _factory; public ApiSmokeTests() { _factory = new WebApplicationFactory() .WithWebHostBuilder(builder => { builder.ConfigureAppConfiguration((_, config) => { config.AddInMemoryCollection(new Dictionary { ["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(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("/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(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(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(JsonOptions); Assert.NotNull(reply); Assert.Equal(message.Id, reply!.ReplyToMessageId); Assert.True(await ProcessOutboxAsync(_factory) >= 2); var sentMessages = await client.GetFromJsonAsync($"/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(); var storedChat = await db.Chats.AsNoTracking().SingleAsync(x => x.Id == chat.Id); Assert.Equal("pymax://mock/mock-direct", storedChat.WebUrl); } [Fact] public async Task ChatListReturnsAllChatsBeyondLegacyLimit() { using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); }); }); 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(); 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("/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(); }); }); using var client = factory.CreateClient(); await LoginAsync(client); Guid currentUserId; using (var scope = factory.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); 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(); 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(); services.RemoveAll(); services.AddSingleton(); }); }); 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("/api/chats", JsonOptions); var chat = Assert.Single(chats!, x => x.ExternalId == LateAttachmentMaxBridgeClient.ChatExternalId); var firstMessages = await client.GetFromJsonAsync($"/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($"/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("/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(); services.RemoveAll(); services.AddSingleton(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(); 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($"/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(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(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(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("/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(); services.RemoveAll(); services.AddSingleton(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(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($"/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(); 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(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(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(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(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("/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(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(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(JsonOptions); Assert.NotNull(edited); Assert.Equal("edited second", edited!.Text); Assert.NotNull(edited.EditedAt); var chatsAfterEdit = await client.GetFromJsonAsync("/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($"/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("/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(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(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(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(JsonOptions); Assert.NotNull(cleared); Assert.Empty(cleared!.Reactions); } [Fact] public async Task MaxActionFailureDoesNotMutateLocalMessageState() { using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); services.AddSingleton(); }); }); 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(JsonOptions); Assert.NotNull(message); Assert.True(await ProcessOutboxAsync(factory) >= 1); var afterOutbox = await client.GetFromJsonAsync($"/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($"/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($"/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($"/api/chats/{chat.Id}/messages", JsonOptions); Assert.Contains(afterDelete!, x => x.Id == message.Id && x.DeletedAt is null); } [Fact] public async Task DeleteChatsIsDisabledAndKeepsChatVisible() { var bridge = new ResolvingDeleteMaxBridgeClient(); using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); services.AddSingleton(bridge); }); }); using var client = factory.CreateClient(); await LoginAsync(client); var externalId = $"missing-url-chat-{Guid.NewGuid():N}"; var chat = await CreateDirectChatAsync(client, externalId, "Missing Url"); var response = await client.PostAsJsonAsync( "/api/chats/delete", new ChatBulkActionRequest([chat.Id]), JsonOptions); await AssertStatusAsync(HttpStatusCode.BadRequest, response); Assert.Null(bridge.ResolvedExternalId); Assert.Empty(bridge.DeleteChatUrls); await using (var scope = factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); Assert.Null(stored.DeletedAt); Assert.Null(stored.PendingMaxAction); Assert.Null(stored.PendingMaxActionRequestedAt); } var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); Assert.Contains(chats!, x => x.Id == chat.Id); } [Fact] public async Task DeleteChatsRejectsBulkRequestWithoutPendingActions() { var bridge = new ResolvingDeleteMaxBridgeClient(); using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); services.AddSingleton(bridge); }); }); using var client = factory.CreateClient(); await LoginAsync(client); var suffix = Guid.NewGuid().ToString("N"); var first = await CreateDirectChatAsync(client, $"bulk-delete-one-{suffix}", "Bulk Delete One"); var second = await CreateDirectChatAsync(client, $"bulk-delete-two-{suffix}", "Bulk Delete Two"); var third = await CreateDirectChatAsync(client, $"bulk-delete-three-{suffix}", "Bulk Delete Three"); var response = await client.PostAsJsonAsync( "/api/chats/delete", new ChatBulkActionRequest([first.Id, second.Id, third.Id]), JsonOptions); await AssertStatusAsync(HttpStatusCode.BadRequest, response); Assert.Empty(bridge.DeleteChatUrls); await using var scope = factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var storedChats = await db.Chats .Where(x => x.Id == first.Id || x.Id == second.Id || x.Id == third.Id) .ToListAsync(); Assert.All(storedChats, stored => { Assert.Null(stored.DeletedAt); Assert.Null(stored.PendingMaxAction); }); var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); Assert.Equal(3, chats!.Count(x => x.Id == first.Id || x.Id == second.Id || x.Id == third.Id)); } [Fact] public async Task GetMessagesReturnsSqlCacheWithoutInlineMaxHistorySyncByDefault() { var bridge = new ResolvingDeleteMaxBridgeClient(); using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); services.AddSingleton(bridge); }); }); using var client = factory.CreateClient(); await LoginAsync(client); var chat = await CreateDirectChatAsync(client, "cache-first-chat", "Cache First"); var messageResponse = await client.PostAsJsonAsync( $"/api/chats/{chat.Id}/messages", new SendMessageRequest("cached sql message"), JsonOptions); await AssertStatusAsync(HttpStatusCode.OK, messageResponse); var messages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); Assert.Contains(messages!, x => x.Text == "cached sql message"); Assert.Equal(0, bridge.HistoryFetchCount); messages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages?sync=true", JsonOptions); Assert.Contains(messages!, x => x.Text == "cached sql message"); Assert.Equal(1, bridge.HistoryFetchCount); } [Fact] public async Task GetMessagesBackfillsLastPreviewWhenMessageRowIsMissing() { using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); }); }); using var client = factory.CreateClient(); await LoginAsync(client); var marker = Guid.NewGuid().ToString("N"); var previewText = $"message only in preview {marker}"; var chat = await CreateDirectChatAsync(client, $"preview-backfill-chat-{marker}", "Preview Backfill"); var previewAt = DateTimeOffset.UtcNow; await using (var scope = factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); stored.LastMessagePreview = previewText; stored.LastMessageAt = previewAt; stored.UnreadCount = 1; await db.SaveChangesAsync(); } var messages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); var backfilled = Assert.Single(messages!, x => x.Text == previewText); Assert.Equal(MessageDirection.Incoming, backfilled.Direction); Assert.StartsWith("preview:", backfilled.ExternalId); } [Fact] public async Task GetMessagesDoesNotBackfillGenericMediaPreviewWhenAttachmentRowIsMissing() { using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); }); }); using var client = factory.CreateClient(); await LoginAsync(client); var marker = Guid.NewGuid().ToString("N"); var chat = await CreateDirectChatAsync(client, $"generic-media-preview-chat-{marker}", "Generic Media Preview"); await using (var scope = factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); stored.LastMessagePreview = "\u0424\u043e\u0442\u043e"; stored.LastMessageAt = DateTimeOffset.UtcNow; stored.UnreadCount = 1; await db.SaveChangesAsync(); } var messages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); Assert.NotNull(messages); Assert.DoesNotContain(messages!, x => x.ExternalId?.StartsWith("preview:", StringComparison.Ordinal) == true); await using var verifyScope = factory.Services.CreateAsyncScope(); var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); Assert.False(await verifyDb.Messages.AnyAsync(x => x.ChatId == chat.Id)); } [Fact] public async Task SyncCreatesMessageRowForFreshPreviewOnlyIncomingUpdate() { var marker = Guid.NewGuid().ToString("N"); var externalId = $"preview-sync-chat-{marker}"; var previewText = $"fresh preview message {marker}"; var now = DateTimeOffset.UtcNow; var bridge = new ResolvingDeleteMaxBridgeClient { Updates = [ new MaxChatUpdate( externalId, "Preview Sync", 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(); services.RemoveAll(); services.AddSingleton(bridge); }); }); using var client = factory.CreateClient(); await LoginAsync(client); var chat = await CreateDirectChatAsync(client, externalId, "Preview Sync"); await using (var scope = factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); stored.LastMessagePreview = "previous preview"; stored.LastMessageAt = now.AddMinutes(-5); await db.SaveChangesAsync(); } 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(); var message = await db.Messages.SingleAsync(x => x.ChatId == chat.Id && x.Text == previewText && x.ExternalId != null && x.ExternalId.StartsWith("preview:")); Assert.Equal(previewText, message.Text); Assert.Equal(MessageDirection.Incoming, message.Direction); Assert.StartsWith("preview:", message.ExternalId); } } [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(); services.RemoveAll(); services.RemoveAll(); services.AddSingleton(bridge); services.AddSingleton(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(); 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() { var pushNotifications = new RecordingPushNotificationService(); var marker = Guid.NewGuid().ToString("N"); var externalId = $"generic-media-sync-chat-{marker}"; var now = DateTimeOffset.UtcNow; var bridge = new ResolvingDeleteMaxBridgeClient { Updates = [ new MaxChatUpdate( externalId, "Generic Media Sync", null, now, [], "\u0424\u043e\u0442\u043e", 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(); services.RemoveAll(); services.RemoveAll(); services.AddSingleton(bridge); services.AddSingleton(pushNotifications); }); }); using var client = factory.CreateClient(); await LoginAsync(client); var chat = await CreateDirectChatAsync(client, externalId, "Generic Media Sync"); await using (var scope = factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); stored.LastMessagePreview = "previous preview"; stored.LastMessageAt = now.AddMinutes(-5); await db.SaveChangesAsync(); } var sync = await client.PostAsync("/api/max/sync", null); await AssertStatusAsync(HttpStatusCode.OK, sync); await using var verifyScope = factory.Services.CreateAsyncScope(); var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); var storedChat = await verifyDb.Chats.SingleAsync(x => x.Id == chat.Id); Assert.Equal("\u041c\u0435\u0434\u0438\u0430", storedChat.LastMessagePreview); Assert.False(await verifyDb.Messages.AnyAsync(x => x.ChatId == chat.Id && x.ExternalId != null && x.ExternalId.StartsWith("preview:"))); Assert.Empty(pushNotifications.SentMessages); } [Fact] public async Task DeleteChatsDoesNotRefreshStaleChatUrlOrHideChat() { var suffix = Guid.NewGuid().ToString("N"); var externalId = $"stale-menu-url-chat-{suffix}"; var staleUrl = $"https://max.test/chat/wrong-chat-{suffix}"; var bridge = new ResolvingDeleteMaxBridgeClient(staleUrl); using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); services.AddSingleton(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(); 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.BadRequest, response); Assert.Empty(bridge.DeleteChatUrls); Assert.Null(bridge.ResolvedExternalId); var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); Assert.Contains(chats!, x => x.Id == chat.Id); await using (var scope = factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); Assert.Null(stored.PendingMaxAction); Assert.Null(stored.DeletedAt); Assert.Equal(staleUrl, stored.WebUrl); } } [Fact] public async Task OutboxSendsQueuedMessagesBeforePendingClearHistoryActions() { var bridge = new ResolvingDeleteMaxBridgeClient(); using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); services.AddSingleton(bridge); }); }); using var client = factory.CreateClient(); await LoginAsync(client); var clearChat = await CreateDirectChatAsync(client, "clear-after-send-chat", "Clear After Send"); var clearResponse = await client.PostAsJsonAsync( "/api/chats/clear-history", new ChatBulkActionRequest([clearChat.Id]), JsonOptions); await AssertStatusAsync(HttpStatusCode.NoContent, clearResponse); var sendChat = await CreateDirectChatAsync(client, "send-before-clear-chat", "Send Before Clear"); var messageResponse = await client.PostAsJsonAsync( $"/api/chats/{sendChat.Id}/messages", new SendMessageRequest("send before chat action"), JsonOptions); await AssertStatusAsync(HttpStatusCode.OK, messageResponse); Assert.True(await ProcessOutboxAsync(factory) >= 2); var sendIndex = bridge.OperationOrder.IndexOf("send:text"); var clearIndex = bridge.OperationOrder.IndexOf("clear"); Assert.True(sendIndex >= 0, "Expected a text send operation."); Assert.True(clearIndex > sendIndex, "Expected text send before pending clear-history action."); } [Fact] public async Task FailedPendingChatActionWaitsBeforeImmediateRetry() { var bridge = new ResolvingDeleteMaxBridgeClient { AlwaysFailClear = true }; using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); services.AddSingleton(bridge); }); }); using var client = factory.CreateClient(); await LoginAsync(client); var chat = await CreateDirectChatAsync(client, "retry-delay-clear-chat", "Retry Delay Clear"); var clearResponse = await client.PostAsJsonAsync( "/api/chats/clear-history", new ChatBulkActionRequest([chat.Id]), JsonOptions); await AssertStatusAsync(HttpStatusCode.NoContent, clearResponse); Assert.True(await ProcessOutboxAsync(factory) >= 1); Assert.Single(bridge.ClearChatUrls); await ProcessOutboxAsync(factory); Assert.Single(bridge.ClearChatUrls); await using var scope = factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); Assert.Equal(ChatPendingMaxAction.ClearHistory, stored.PendingMaxAction); Assert.Equal(1, stored.PendingMaxActionAttempts); Assert.NotNull(stored.PendingMaxActionLastAttemptAt); Assert.Equal("Simulated MAX clear failure.", stored.PendingMaxActionError); } [Fact] public async Task BulkClearHistoryQueuesMaxActionAndClearsLocalMessages() { const string externalId = "clear-history-chat"; var bridge = new ResolvingDeleteMaxBridgeClient(); using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); services.AddSingleton(bridge); }); }); using var client = factory.CreateClient(); await LoginAsync(client); var chat = await CreateDirectChatAsync(client, externalId, "Clear History"); var messageResponse = await client.PostAsJsonAsync( $"/api/chats/{chat.Id}/messages", new SendMessageRequest("local message before clear"), JsonOptions); await AssertStatusAsync(HttpStatusCode.OK, messageResponse); var response = await client.PostAsJsonAsync( "/api/chats/clear-history", new ChatBulkActionRequest([chat.Id]), JsonOptions); await AssertStatusAsync(HttpStatusCode.NoContent, response); Assert.Empty(bridge.ClearChatUrls); var messages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); Assert.Empty(messages!); await using (var scope = factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); Assert.Null(stored.DeletedAt); Assert.NotNull(stored.HistoryClearedAt); Assert.Null(stored.LastMessagePreview); Assert.Equal(ChatPendingMaxAction.ClearHistory, stored.PendingMaxAction); } Assert.True(await ProcessOutboxAsync(factory) >= 1); Assert.Equal($"https://max.test/chat/{externalId}", bridge.ClearChatUrl); await using (var scope = factory.Services.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id); Assert.Null(stored.PendingMaxAction); Assert.Equal($"https://max.test/chat/{externalId}", stored.WebUrl); } } [Fact] public async Task SyncKeepsChatVisibleAfterDisabledDeleteRequest() { var suffix = Guid.NewGuid().ToString("N"); var externalId = $"deleted-sync-chat-{suffix}"; var sentAt = DateTimeOffset.UtcNow; var bridge = new ResolvingDeleteMaxBridgeClient { Updates = [ new MaxChatUpdate( externalId, "Deleted Remote", null, sentAt, [ new MaxMessageUpdate( $"deleted-sync-message-{suffix}", "remote-user", "Deleted Remote", false, "remote hello after delete", sentAt) ], "remote hello after delete", false, sentAt, sentAt.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"), $"https://max.test/chat/{externalId}") ] }; using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); services.AddSingleton(bridge); }); }); using var client = factory.CreateClient(); await LoginAsync(client); var chat = await CreateDirectChatAsync(client, externalId, "Deleted Remote"); var deleteResponse = await client.PostAsJsonAsync( "/api/chats/delete", new ChatBulkActionRequest([chat.Id]), JsonOptions); await AssertStatusAsync(HttpStatusCode.BadRequest, deleteResponse); var sync = await client.PostAsync("/api/max/sync", null); await AssertStatusAsync(HttpStatusCode.OK, sync); var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); Assert.Contains(chats!, x => x.ExternalId == externalId); await using var scope = factory.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var stored = await db.Chats.Include(x => x.Messages).SingleAsync(x => x.Id == chat.Id); Assert.Null(stored.DeletedAt); Assert.Null(stored.PendingMaxAction); Assert.Contains(stored.Messages, x => x.ExternalId == $"deleted-sync-message-{suffix}"); } [Fact] public async Task SyncUpdatesVisibleChatWhenExternalIdChangesButWebUrlMatches() { var suffix = Guid.NewGuid().ToString("N"); var oldExternalId = $"deleted-sync-old-external-{suffix}"; var newExternalId = $"deleted-sync-new-external-{suffix}"; var title = $"Visible WebUrl Match {suffix}"; var chatUrl = $"https://max.test/chat/visible-web-url-match-{suffix}"; 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(); services.RemoveAll(); services.AddSingleton(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(); 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.BadRequest, deleteResponse); var sync = await client.PostAsync("/api/max/sync", null); await AssertStatusAsync(HttpStatusCode.OK, sync); var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); Assert.Contains(chats!, x => x.Title == title); await using var verifyScope = factory.Services.CreateAsyncScope(); var verifyDb = verifyScope.ServiceProvider.GetRequiredService(); var storedChats = await verifyDb.Chats.Where(x => x.Title == title).ToListAsync(); var visibleChat = Assert.Single(storedChats); Assert.Equal(newExternalId, visibleChat.ExternalId); Assert.Null(visibleChat.DeletedAt); Assert.Null(visibleChat.PendingMaxAction); } [Fact] public async Task FailedMaxTextSendMarksLocalMessageFailed() { using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureTestServices(services => { services.RemoveAll(); services.RemoveAll(); services.AddSingleton(); }); }); 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(JsonOptions); Assert.NotNull(queued); Assert.Equal(MessageDeliveryState.Sending, queued!.DeliveryState); Assert.True(await ProcessOutboxAsync(factory) >= 1); var messages = await client.GetFromJsonAsync($"/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("/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(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( $"/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( $"/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(); services.RemoveAll(); services.RemoveAll(); services.AddSingleton(bridge); services.AddSingleton(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(); 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(); 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(); services.RemoveAll(); services.RemoveAll(); services.AddSingleton(bridge); services.AddSingleton(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(); 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(); 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() .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.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() .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() .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(JsonOptions); Assert.NotNull(auth); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth!.AccessToken); } private static async Task 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(JsonOptions); Assert.NotNull(chat); return chat!; } private static async Task ProcessOutboxAsync(WebApplicationFactory factory) { await using var scope = factory.Services.CreateAsyncScope(); var outbox = scope.ServiceProvider.GetRequiredService(); 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 GetStatusAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); } public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task GetSnapshotAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); } public Task> FetchUpdatesAsync(CancellationToken cancellationToken) { return Task.FromResult>( [ new MaxChatUpdate( ChatExternalId, "Self Echo", null, DateTimeOffset.UtcNow, [], PreviewText, null, null, DateTimeOffset.UtcNow.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm")) ]); } public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) { return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); } public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); } public Task 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 EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } } private sealed class FailingActionMaxBridgeClient : IMaxBridgeClient { public Task GetStatusAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); } public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task GetSnapshotAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); } public Task> FetchUpdatesAsync(CancellationToken cancellationToken) { return Task.FromResult>(Array.Empty()); } public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) { return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); } public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Fail(); } public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Fail(); } public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); } public Task 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 EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) { return Fail(); } public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) { return Fail(); } public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Fail(); } public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Fail(); } public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } private static Task 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 DeleteChatUrls { get; } = []; public string? ClearChatUrl { get; private set; } public List ClearChatUrls { get; } = []; public List OperationOrder { get; } = []; public List SentTexts { get; } = []; public bool AlwaysFailDelete { get; set; } public bool AlwaysFailClear { get; set; } public int HistoryFetchCount { get; private set; } public IReadOnlyList Updates { get; set; } = []; public Task GetStatusAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); } public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task GetSnapshotAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); } public Task> FetchUpdatesAsync(CancellationToken cancellationToken) { return Task.FromResult(Updates); } public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { HistoryFetchCount++; return Task.FromResult(null); } public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) { ResolvedExternalId = externalChatId; return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); } public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { OperationOrder.Add("clear"); ClearChatUrls.Add(chatUrl); ClearChatUrl = chatUrl; if (AlwaysFailClear) { return Task.FromResult(new MaxActionResult(false, "Simulated MAX clear failure.")); } 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 DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { OperationOrder.Add("delete"); DeleteChatUrls.Add(chatUrl); DeleteChatUrl = chatUrl; if (AlwaysFailDelete) { return Task.FromResult(new MaxActionResult(false, "Simulated MAX delete failure.")); } 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 SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { OperationOrder.Add("send:text"); SentTexts.Add(text); return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); } public Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken) { OperationOrder.Add("send:attachment"); return Task.FromResult(new MaxSendResult(true, $"external-file-{Guid.NewGuid():N}", null)); } public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } } private sealed class FailingSendMaxBridgeClient : IMaxBridgeClient { public Task GetStatusAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); } public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task GetSnapshotAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); } public Task> FetchUpdatesAsync(CancellationToken cancellationToken) { return Task.FromResult>(Array.Empty()); } public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) { return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); } public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); } public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); } public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX send failure.")); } public Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX attachment failure.")); } public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); } public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); } public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); } public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(false, "Simulated MAX action failure.")); } public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) { return Task.FromResult(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 GetStatusAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); } public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task GetSnapshotAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); } public Task> FetchUpdatesAsync(CancellationToken cancellationToken) { _fetchCount++; return Task.FromResult>([BuildUpdate(includeAttachment: _fetchCount > 1)]); } public Task FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) { return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); } public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); } public Task 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 EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) { return Task.FromResult( new MaxMediaDownload(new MemoryStream(MediaBytes), "image/png", MediaBytes.Length)); } private static MaxChatUpdate BuildUpdate(bool includeAttachment) { IReadOnlyList 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 GetStatusAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); } public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task GetSnapshotAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); } public Task> FetchUpdatesAsync(CancellationToken cancellationToken) { var sentAt = DateTimeOffset.UtcNow.AddSeconds(-2); return Task.FromResult>( [ 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 FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) { return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); } public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); } public Task SendAttachmentAsync(string externalChatId, string? chatUrl, string path, string caption, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, MessageExternalId, null)); } public Task EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) { return Task.FromResult( 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 GetStatusAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBridgeStatus("Test", true, "Authorized", "Ready", null, "Test", null, DateTimeOffset.UtcNow)); } public Task BeginPhoneLoginAsync(string phoneNumber, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task SubmitLoginCodeAsync(string code, CancellationToken cancellationToken) { return GetStatusAsync(cancellationToken); } public Task GetSnapshotAsync(CancellationToken cancellationToken) { return Task.FromResult(new MaxBrowserSnapshot(null, "Test", "", "", DateTimeOffset.UtcNow)); } public Task> FetchUpdatesAsync(CancellationToken cancellationToken) { return Task.FromResult>( [ 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 FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken) { return Task.FromResult(new MaxChatUrlResult(true, $"https://max.test/chat/{Uri.EscapeDataString(externalChatId)}", null)); } public Task ClearChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DeleteChatAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task SendTextAsync(string externalChatId, string? chatUrl, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); } public Task 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 EditMessageAsync(string externalChatId, string externalMessageId, string? currentText, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DeleteMessageAsync(string externalChatId, string externalMessageId, string? currentText, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task SetReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task ClearReactionAsync(string externalChatId, string externalMessageId, string? currentText, string emoji, CancellationToken cancellationToken) { return Task.FromResult(new MaxActionResult(true, null)); } public Task DownloadMediaAsync(string remoteUrl, CancellationToken cancellationToken) { if (!AllowMediaDownload) { return Task.FromResult(null); } return Task.FromResult( new MaxMediaDownload(new MemoryStream(MediaBytes), "image/png", MediaBytes.Length)); } } }