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