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.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Hosting; 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" }); }); }); } [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.Equal(MessageDeliveryState.Sent, 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); } [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 UploadAttachmentFlowStoresDownloadsAndMarksSent() { 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.Sent, message!.DeliveryState); 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 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.Sent, 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.Sent, 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.ConfigureServices(services => { 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.False(string.IsNullOrWhiteSpace(message!.ExternalId)); 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 FailedMaxTextSendDoesNotCreateLocalMessage() { using var factory = _factory.WithWebHostBuilder(builder => { builder.ConfigureServices(services => { 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.BadGateway, response); var messages = await client.GetFromJsonAsync($"/api/chats/{chat.Id}/messages", JsonOptions); Assert.NotNull(messages); Assert.Empty(messages!); var chats = await client.GetFromJsonAsync("/api/chats", JsonOptions); var updated = Assert.Single(chats!, x => x.Id == chat.Id); Assert.Null(updated.LastMessagePreview); Assert.Null(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 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(); 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 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 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, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); } public Task SendAttachmentAsync(string externalChatId, 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 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, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(false, null, "Simulated MAX send failure.")); } public Task SendAttachmentAsync(string externalChatId, 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, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); } public Task SendAttachmentAsync(string externalChatId, 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 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, CancellationToken cancellationToken) { return Task.FromResult(null); } public Task SendTextAsync(string externalChatId, string text, CancellationToken cancellationToken) { return Task.FromResult(new MaxSendResult(true, $"external-{Guid.NewGuid():N}", null)); } public Task SendAttachmentAsync(string externalChatId, 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)); } } }