Backfill preview-only incoming messages
This commit is contained in:
@@ -83,6 +83,11 @@ public sealed class ChatsController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (before is null)
|
||||||
|
{
|
||||||
|
await EnsureLastPreviewMessageAsync(chat, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
if (before is null && chat.UnreadCount > 0)
|
if (before is null && chat.UnreadCount > 0)
|
||||||
{
|
{
|
||||||
chat.UnreadCount = 0;
|
chat.UnreadCount = 0;
|
||||||
@@ -108,6 +113,95 @@ public sealed class ChatsController(
|
|||||||
return messages.OrderBy(x => x.SentAt).Select(projection.ToDto).ToArray();
|
return messages.OrderBy(x => x.SentAt).Select(projection.ToDto).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task EnsureLastPreviewMessageAsync(Chat chat, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var preview = MessageTextSanitizer.CleanChatPreview(chat.LastMessagePreview);
|
||||||
|
if (string.IsNullOrWhiteSpace(preview) || chat.LastMessageAt is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewAt = chat.LastMessageAt.Value;
|
||||||
|
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var latestMessage = (await db.Messages
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.ChatId == chat.Id && x.DeletedAt == null)
|
||||||
|
.ToArrayAsync(cancellationToken))
|
||||||
|
.OrderByDescending(x => x.SentAt)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (latestMessage is not null && latestMessage.SentAt >= previewAt.AddSeconds(-1))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var externalId = PreviewExternalId(chat.Id, preview, previewAt);
|
||||||
|
if (await HasPreviewPlaceholderAsync(chat.Id, externalId, preview, previewAt, cancellationToken))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.Messages.Add(new Message
|
||||||
|
{
|
||||||
|
ChatId = chat.Id,
|
||||||
|
ExternalId = externalId,
|
||||||
|
Direction = MessageDirection.Incoming,
|
||||||
|
Text = preview,
|
||||||
|
SentAt = previewAt,
|
||||||
|
DeliveryState = MessageDeliveryState.Delivered,
|
||||||
|
SenderExternalId = chat.ExternalId,
|
||||||
|
SenderName = chat.Title
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> HasPreviewPlaceholderAsync(
|
||||||
|
Guid chatId,
|
||||||
|
string externalId,
|
||||||
|
string preview,
|
||||||
|
DateTimeOffset previewAt,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var previewMessages = await db.Messages
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.ChatId == chatId &&
|
||||||
|
x.DeletedAt == null &&
|
||||||
|
x.Direction == MessageDirection.Incoming &&
|
||||||
|
x.ExternalId != null &&
|
||||||
|
(x.ExternalId == externalId || x.ExternalId.StartsWith("preview:")))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return previewMessages.Any(message =>
|
||||||
|
string.Equals(message.ExternalId, externalId, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(
|
||||||
|
MessageTextSanitizer.CleanMessageText(message.Text, false),
|
||||||
|
preview,
|
||||||
|
StringComparison.Ordinal) &&
|
||||||
|
Math.Abs((message.SentAt.ToUniversalTime() - previewAt.ToUniversalTime()).TotalMinutes) <= 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PreviewExternalId(Guid chatId, string preview, DateTimeOffset previewAt)
|
||||||
|
{
|
||||||
|
var previewMinute = previewAt.ToUniversalTime().Ticks / TimeSpan.TicksPerMinute;
|
||||||
|
return $"preview:{chatId:N}:{previewMinute}:{StableHash(preview.Trim().ToLowerInvariant())}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StableHash(string value)
|
||||||
|
{
|
||||||
|
var hash = 2166136261u;
|
||||||
|
foreach (var character in value)
|
||||||
|
{
|
||||||
|
hash ^= character;
|
||||||
|
hash *= 16777619;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash.ToString("x8");
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{chatId:guid}/read")]
|
[HttpPost("{chatId:guid}/read")]
|
||||||
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
|
public async Task<IActionResult> MarkRead(Guid chatId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public sealed class MaxBridgeSyncService(
|
|||||||
IHubContext<QMaxHub> hubContext,
|
IHubContext<QMaxHub> hubContext,
|
||||||
ILogger<MaxBridgeSyncService> logger)
|
ILogger<MaxBridgeSyncService> logger)
|
||||||
{
|
{
|
||||||
|
private const string PreviewExternalIdPrefix = "preview:";
|
||||||
private static readonly ConcurrentDictionary<string, DateTimeOffset> PreviewNotifications = new();
|
private static readonly ConcurrentDictionary<string, DateTimeOffset> PreviewNotifications = new();
|
||||||
|
|
||||||
public async Task<int> SyncOnceAsync(CancellationToken cancellationToken)
|
public async Task<int> SyncOnceAsync(CancellationToken cancellationToken)
|
||||||
@@ -72,7 +73,6 @@ public sealed class MaxBridgeSyncService(
|
|||||||
var changed = 0;
|
var changed = 0;
|
||||||
var createdMessages = new List<(Chat Chat, Message Message)>();
|
var createdMessages = new List<(Chat Chat, Message Message)>();
|
||||||
var updatedMessages = new List<(Chat Chat, Message Message)>();
|
var updatedMessages = new List<(Chat Chat, Message Message)>();
|
||||||
var previewPushNotifications = new List<(Chat Chat, Message Message)>();
|
|
||||||
var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal);
|
var trackedChatsByExternalId = new Dictionary<string, Chat>(StringComparer.Ordinal);
|
||||||
|
|
||||||
foreach (var update in updates)
|
foreach (var update in updates)
|
||||||
@@ -176,14 +176,22 @@ public sealed class MaxBridgeSyncService(
|
|||||||
x.SentAt))
|
x.SentAt))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
if (ApplyListPreview(
|
var previewResult = ApplyListPreview(
|
||||||
update,
|
update,
|
||||||
chat,
|
chat,
|
||||||
chatWasCreated,
|
chatWasCreated,
|
||||||
incrementUnread,
|
incrementUnread,
|
||||||
sendPushNotifications,
|
lastKnownMessage);
|
||||||
previewPushNotifications,
|
if (previewResult.Message is not null)
|
||||||
lastKnownMessage))
|
{
|
||||||
|
if (!await HasPreviewPlaceholderAsync(db, chat.Id, previewResult.Message, cancellationToken))
|
||||||
|
{
|
||||||
|
db.Messages.Add(previewResult.Message);
|
||||||
|
createdMessages.Add((chat, previewResult.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previewResult.Changed)
|
||||||
{
|
{
|
||||||
changed++;
|
changed++;
|
||||||
}
|
}
|
||||||
@@ -266,6 +274,37 @@ public sealed class MaxBridgeSyncService(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var previewPlaceholder = await FindPreviewPlaceholderAsync(db, chat.Id, incoming, cancellationToken);
|
||||||
|
if (previewPlaceholder is not null)
|
||||||
|
{
|
||||||
|
var merge = await MergeExistingMessageAsync(
|
||||||
|
db,
|
||||||
|
previewPlaceholder,
|
||||||
|
incoming,
|
||||||
|
maxBridgeClient,
|
||||||
|
storage,
|
||||||
|
cancellationToken);
|
||||||
|
if (!string.Equals(previewPlaceholder.ExternalId, incoming.ExternalId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
previewPlaceholder.ExternalId = incoming.ExternalId;
|
||||||
|
merge = merge with { Changed = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (merge.Changed)
|
||||||
|
{
|
||||||
|
updatedMessages.Add((chat, previewPlaceholder));
|
||||||
|
if (chat.LastMessageAt is null || previewPlaceholder.SentAt >= chat.LastMessageAt)
|
||||||
|
{
|
||||||
|
chat.LastMessagePreview = LastMessagePreview(previewPlaceholder.Text, previewPlaceholder.Attachments.Concat(merge.AddedAttachments));
|
||||||
|
chat.LastMessageAt = previewPlaceholder.SentAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var message = new Message
|
var message = new Message
|
||||||
{
|
{
|
||||||
Chat = chat,
|
Chat = chat,
|
||||||
@@ -311,11 +350,6 @@ public sealed class MaxBridgeSyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var (chat, message) in previewPushNotifications)
|
|
||||||
{
|
|
||||||
await pushNotifications.SendNewMessageAsync(chat, message, cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var (chat, message) in updatedMessages)
|
foreach (var (chat, message) in updatedMessages)
|
||||||
{
|
{
|
||||||
var updated = await db.Messages
|
var updated = await db.Messages
|
||||||
@@ -335,24 +369,22 @@ public sealed class MaxBridgeSyncService(
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ApplyListPreview(
|
private static ListPreviewApplyResult ApplyListPreview(
|
||||||
MaxChatUpdate update,
|
MaxChatUpdate update,
|
||||||
Chat chat,
|
Chat chat,
|
||||||
bool chatWasCreated,
|
bool chatWasCreated,
|
||||||
bool incrementUnread,
|
bool incrementUnread,
|
||||||
bool sendPushNotifications,
|
|
||||||
ICollection<(Chat Chat, Message Message)> previewPushNotifications,
|
|
||||||
LastKnownMessageSnapshot? lastKnownMessage)
|
LastKnownMessageSnapshot? lastKnownMessage)
|
||||||
{
|
{
|
||||||
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
|
var preview = MessageTextSanitizer.CleanChatPreview(update.LastMessagePreview);
|
||||||
if (string.IsNullOrWhiteSpace(preview))
|
if (string.IsNullOrWhiteSpace(preview))
|
||||||
{
|
{
|
||||||
return false;
|
return new ListPreviewApplyResult(false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsPresencePreview(preview))
|
if (IsPresencePreview(preview))
|
||||||
{
|
{
|
||||||
return false;
|
return new ListPreviewApplyResult(false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
var previousPreview = chat.LastMessagePreview;
|
var previousPreview = chat.LastMessagePreview;
|
||||||
@@ -360,7 +392,7 @@ public sealed class MaxBridgeSyncService(
|
|||||||
var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt);
|
var previewAt = ResolveListPreviewAt(update, previousPreviewAt ?? chat.UpdatedAt);
|
||||||
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
|
if (chat.HistoryClearedAt is { } historyClearedAt && previewAt <= historyClearedAt)
|
||||||
{
|
{
|
||||||
return false;
|
return new ListPreviewApplyResult(false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal);
|
var samePreview = string.Equals(previousPreview, preview, StringComparison.Ordinal);
|
||||||
@@ -368,7 +400,7 @@ public sealed class MaxBridgeSyncService(
|
|||||||
Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1;
|
Math.Abs((previousPreviewAt.Value - previewAt).TotalSeconds) < 1;
|
||||||
if (samePreview && samePreviewAt)
|
if (samePreview && samePreviewAt)
|
||||||
{
|
{
|
||||||
return false;
|
return new ListPreviewApplyResult(false, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
chat.LastMessagePreview = preview;
|
chat.LastMessagePreview = preview;
|
||||||
@@ -390,25 +422,10 @@ public sealed class MaxBridgeSyncService(
|
|||||||
chat.UnreadCount++;
|
chat.UnreadCount++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sendPushNotifications && shouldNotify)
|
var message = shouldNotify
|
||||||
{
|
? CreatePreviewMessage(chat, preview, previewAt)
|
||||||
previewPushNotifications.Add((
|
: null;
|
||||||
chat,
|
return new ListPreviewApplyResult(true, message);
|
||||||
new Message
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
ChatId = chat.Id,
|
|
||||||
Chat = chat,
|
|
||||||
Direction = MessageDirection.Incoming,
|
|
||||||
Text = preview,
|
|
||||||
SentAt = previewAt,
|
|
||||||
DeliveryState = MessageDeliveryState.Delivered,
|
|
||||||
SenderExternalId = chat.ExternalId,
|
|
||||||
SenderName = chat.Title
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsKnownOutgoingPreviewEcho(
|
private static bool IsKnownOutgoingPreviewEcho(
|
||||||
@@ -587,8 +604,111 @@ public sealed class MaxBridgeSyncService(
|
|||||||
text.Contains("file", StringComparison.Ordinal);
|
text.Contains("file", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record ListPreviewApplyResult(bool Changed, Message? Message);
|
||||||
|
|
||||||
private sealed record MessageMergeResult(bool Changed, IReadOnlyList<MessageAttachment> AddedAttachments);
|
private sealed record MessageMergeResult(bool Changed, IReadOnlyList<MessageAttachment> AddedAttachments);
|
||||||
|
|
||||||
|
private static Message CreatePreviewMessage(Chat chat, string preview, DateTimeOffset previewAt)
|
||||||
|
{
|
||||||
|
return new Message
|
||||||
|
{
|
||||||
|
ChatId = chat.Id,
|
||||||
|
Chat = chat,
|
||||||
|
ExternalId = PreviewExternalId(chat.Id, preview, previewAt),
|
||||||
|
Direction = MessageDirection.Incoming,
|
||||||
|
Text = preview,
|
||||||
|
SentAt = previewAt,
|
||||||
|
DeliveryState = MessageDeliveryState.Delivered,
|
||||||
|
SenderExternalId = chat.ExternalId,
|
||||||
|
SenderName = chat.Title
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Message?> FindPreviewPlaceholderAsync(
|
||||||
|
QMaxDbContext db,
|
||||||
|
Guid chatId,
|
||||||
|
MaxMessageUpdate incoming,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (incoming.IsOutgoing)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var incomingAttachments = DistinctAttachments(incoming.Attachments ?? []);
|
||||||
|
var incomingText = MessageTextSanitizer.CleanMessageText(
|
||||||
|
incoming.Text,
|
||||||
|
incomingAttachments.Count > 0);
|
||||||
|
if (string.IsNullOrWhiteSpace(incomingText))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidates = await db.Messages
|
||||||
|
.Include(x => x.Attachments)
|
||||||
|
.Where(x =>
|
||||||
|
x.ChatId == chatId &&
|
||||||
|
x.DeletedAt == null &&
|
||||||
|
x.Direction == MessageDirection.Incoming &&
|
||||||
|
x.ExternalId != null &&
|
||||||
|
x.ExternalId.StartsWith(PreviewExternalIdPrefix))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.Where(candidate => candidate.Attachments.Count == 0)
|
||||||
|
.Where(candidate => string.Equals(
|
||||||
|
MessageTextSanitizer.CleanMessageText(candidate.Text, false),
|
||||||
|
incomingText,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
.Where(candidate => Math.Abs((candidate.SentAt.ToUniversalTime() - incoming.SentAt.ToUniversalTime()).TotalMinutes) <= 30)
|
||||||
|
.OrderBy(candidate => Math.Abs((candidate.SentAt.ToUniversalTime() - incoming.SentAt.ToUniversalTime()).TotalSeconds))
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> HasPreviewPlaceholderAsync(
|
||||||
|
QMaxDbContext db,
|
||||||
|
Guid chatId,
|
||||||
|
Message previewMessage,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var previewText = MessageTextSanitizer.CleanMessageText(previewMessage.Text, false);
|
||||||
|
var candidates = await db.Messages
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.ChatId == chatId &&
|
||||||
|
x.DeletedAt == null &&
|
||||||
|
x.Direction == MessageDirection.Incoming &&
|
||||||
|
x.ExternalId != null &&
|
||||||
|
x.ExternalId.StartsWith(PreviewExternalIdPrefix))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
|
||||||
|
return candidates.Any(candidate =>
|
||||||
|
string.Equals(candidate.ExternalId, previewMessage.ExternalId, StringComparison.Ordinal) ||
|
||||||
|
string.Equals(
|
||||||
|
MessageTextSanitizer.CleanMessageText(candidate.Text, false),
|
||||||
|
previewText,
|
||||||
|
StringComparison.Ordinal) &&
|
||||||
|
Math.Abs((candidate.SentAt.ToUniversalTime() - previewMessage.SentAt.ToUniversalTime()).TotalMinutes) <= 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string PreviewExternalId(Guid chatId, string preview, DateTimeOffset previewAt)
|
||||||
|
{
|
||||||
|
var previewMinute = previewAt.ToUniversalTime().Ticks / TimeSpan.TicksPerMinute;
|
||||||
|
return $"{PreviewExternalIdPrefix}{chatId:N}:{previewMinute}:{StableHash(preview.Trim().ToLowerInvariant())}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StableHash(string value)
|
||||||
|
{
|
||||||
|
var hash = 2166136261u;
|
||||||
|
foreach (var character in value)
|
||||||
|
{
|
||||||
|
hash ^= character;
|
||||||
|
hash *= 16777619;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash.ToString("x8");
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<Message?> FindPendingOutgoingEchoAsync(
|
private static async Task<Message?> FindPendingOutgoingEchoAsync(
|
||||||
QMaxDbContext db,
|
QMaxDbContext db,
|
||||||
Guid chatId,
|
Guid chatId,
|
||||||
|
|||||||
@@ -739,6 +739,103 @@ public sealed class ApiSmokeTests : IDisposable
|
|||||||
Assert.Equal(1, bridge.HistoryFetchCount);
|
Assert.Equal(1, bridge.HistoryFetchCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMessagesBackfillsLastPreviewWhenMessageRowIsMissing()
|
||||||
|
{
|
||||||
|
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.RemoveAll<IHostedService>();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client);
|
||||||
|
|
||||||
|
var marker = Guid.NewGuid().ToString("N");
|
||||||
|
var previewText = $"message only in preview {marker}";
|
||||||
|
var chat = await CreateDirectChatAsync(client, $"preview-backfill-chat-{marker}", "Preview Backfill");
|
||||||
|
var previewAt = DateTimeOffset.UtcNow;
|
||||||
|
await using (var scope = factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||||
|
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
|
||||||
|
stored.LastMessagePreview = previewText;
|
||||||
|
stored.LastMessageAt = previewAt;
|
||||||
|
stored.UnreadCount = 1;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages = await client.GetFromJsonAsync<MessageDto[]>($"/api/chats/{chat.Id}/messages", JsonOptions);
|
||||||
|
|
||||||
|
var backfilled = Assert.Single(messages!, x => x.Text == previewText);
|
||||||
|
Assert.Equal(MessageDirection.Incoming, backfilled.Direction);
|
||||||
|
Assert.StartsWith("preview:", backfilled.ExternalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SyncCreatesMessageRowForFreshPreviewOnlyIncomingUpdate()
|
||||||
|
{
|
||||||
|
var marker = Guid.NewGuid().ToString("N");
|
||||||
|
var externalId = $"preview-sync-chat-{marker}";
|
||||||
|
var previewText = $"fresh preview message {marker}";
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var bridge = new ResolvingDeleteMaxBridgeClient
|
||||||
|
{
|
||||||
|
Updates =
|
||||||
|
[
|
||||||
|
new MaxChatUpdate(
|
||||||
|
externalId,
|
||||||
|
"Preview Sync",
|
||||||
|
null,
|
||||||
|
now,
|
||||||
|
[],
|
||||||
|
previewText,
|
||||||
|
false,
|
||||||
|
now,
|
||||||
|
now.ToOffset(TimeSpan.FromHours(3)).ToString("HH:mm"),
|
||||||
|
$"https://max.test/chat/{externalId}")
|
||||||
|
]
|
||||||
|
};
|
||||||
|
using var factory = _factory.WithWebHostBuilder(builder =>
|
||||||
|
{
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.RemoveAll<IHostedService>();
|
||||||
|
services.RemoveAll<IMaxBridgeClient>();
|
||||||
|
services.AddSingleton<IMaxBridgeClient>(bridge);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
using var client = factory.CreateClient();
|
||||||
|
await LoginAsync(client);
|
||||||
|
|
||||||
|
var chat = await CreateDirectChatAsync(client, externalId, "Preview Sync");
|
||||||
|
await using (var scope = factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||||
|
var stored = await db.Chats.SingleAsync(x => x.Id == chat.Id);
|
||||||
|
stored.LastMessagePreview = "previous preview";
|
||||||
|
stored.LastMessageAt = now.AddMinutes(-5);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var sync = await client.PostAsync("/api/max/sync", null);
|
||||||
|
await AssertStatusAsync(HttpStatusCode.OK, sync);
|
||||||
|
|
||||||
|
await using (var scope = factory.Services.CreateAsyncScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<QMaxDbContext>();
|
||||||
|
var message = await db.Messages.SingleAsync(x =>
|
||||||
|
x.ChatId == chat.Id &&
|
||||||
|
x.Text == previewText &&
|
||||||
|
x.ExternalId != null &&
|
||||||
|
x.ExternalId.StartsWith("preview:"));
|
||||||
|
Assert.Equal(previewText, message.Text);
|
||||||
|
Assert.Equal(MessageDirection.Incoming, message.Direction);
|
||||||
|
Assert.StartsWith("preview:", message.ExternalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure()
|
public async Task BulkDeleteOutboxRefreshesStaleChatUrlAfterMenuOpenFailure()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user